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/.env.example b/.env.example index cf703e75e..16fea2081 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ 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. celld v0.4.1+ dials it +# over cloudflare:sockets. Do not put a Postgres URL in MAPLE_DB_URL (PGlite dir). +# MAPLE_PG_URL=postgres://maple:maple@127.0.0.1:5499/maple # 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..42d5c797f --- /dev/null +++ b/apps/alerting/wrangler.celld.jsonc @@ -0,0 +1,30 @@ +{ + // 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", + "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/pg-connection-scope.ts b/apps/api/src/platform/pg-connection-scope.ts index 972d59c7e..a45669f6f 100644 --- a/apps/api/src/platform/pg-connection-scope.ts +++ b/apps/api/src/platform/pg-connection-scope.ts @@ -168,7 +168,8 @@ 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 = wrapMaplePgClient(open.sql, { onQuery: hooks.collect }) + return await fn(db) }, extraAttributes), ) }), diff --git a/apps/api/src/platform/pg-connection-source.test.ts b/apps/api/src/platform/pg-connection-source.test.ts index 7ff3003bd..3daadcefa 100644 --- a/apps/api/src/platform/pg-connection-source.test.ts +++ b/apps/api/src/platform/pg-connection-source.test.ts @@ -29,4 +29,58 @@ describe("mapleDbConnectionFromEnv", () => { it("reports an absent database for an empty env", () => { expect(mapleDbConnectionFromEnv({})).toStrictEqual(Option.none()) }) + + it("synthesizes a connection from MAPLE_PG_URL without leaking credentials", () => { + const connection = mapleDbConnectionFromEnv({ + MAPLE_PG_URL: "postgres://maple:s3cret@127.0.0.1:5499/maple", + }) + + expect(connection).toStrictEqual( + Option.some({ + connectionString: "postgres://maple:s3cret@127.0.0.1:5499/maple", + attributes: { + "db.namespace": "maple", + "server.address": "127.0.0.1", + "server.port": 5499, + }, + }), + ) + expect(JSON.stringify(Option.getOrThrow(connection).attributes)).not.toContain("s3cret") + }) + + it("ignores MAPLE_PG_URL when a Hyperdrive binding is present", () => { + const connection = mapleDbConnectionFromEnv({ + [MAPLE_DB_BINDING]: hyperdriveBinding, + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + }) + + expect(connection).toStrictEqual( + Option.some({ + 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 connection = mapleDbConnectionFromEnv({ + [MAPLE_DB_BINDING]: "postgres://maple:maple@127.0.0.1:5499/maple", + MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple", + }) + + expect(Option.getOrThrow(connection).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 absent when MAPLE_PG_URL is %s", + (pgUrl) => { + expect(mapleDbConnectionFromEnv({ MAPLE_PG_URL: pgUrl })).toStrictEqual(Option.none()) + }, + ) }) diff --git a/apps/api/src/platform/pg-connection-source.ts b/apps/api/src/platform/pg-connection-source.ts index 048ae1c3c..26f24082b 100644 --- a/apps/api/src/platform/pg-connection-source.ts +++ b/apps/api/src/platform/pg-connection-source.ts @@ -4,22 +4,87 @@ * is checked) and turned into what the Postgres layers need. 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 and postgres.js dials it + * over TCP (`cloudflare:sockets` on celld). A string `MAPLE_DB` is still + * absent on purpose — that binding is an object or it is absent. */ import { readMapleDbBinding } from "@maple/infra/cloudflare" import { Layer, Option } from "effect" import { type DatabaseConnection, MapleDbConnection } from "./bindings" -/** The port's value for one Worker env record: `None` on a stage without a database. */ -export const mapleDbConnectionFromEnv = (env: Record): Option.Option => - Option.map(readMapleDbBinding(env), (binding) => ({ - connectionString: binding.connectionString, +export const MAPLE_PG_URL_VAR = "MAPLE_PG_URL" + +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 connectionFromPostgresUrl = (raw: string): Option.Option => { + const parsed = parsePostgresUrl(raw) + if (parsed === undefined) return Option.none() + return Option.some({ + connectionString: parsed.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, + "db.namespace": parsed.database, + "server.address": parsed.host, + "server.port": parsed.port, }, - })) + }) +} + +/** The port's value for one Worker env record: `None` on a stage without a database. */ +export const mapleDbConnectionFromEnv = (env: Record): Option.Option => { + const binding = readMapleDbBinding(env) + if (Option.isSome(binding)) { + return Option.some({ + connectionString: binding.value.connectionString, + attributes: { + // The read path normalizes Hyperdrive's opaque host/database to its sentinel node. + "db.namespace": binding.value.database, + "server.address": binding.value.host, + "server.port": binding.value.port, + }, + }) + } + + const pgUrl = readNonEmptyString(env[MAPLE_PG_URL_VAR]) + if (pgUrl !== undefined) return connectionFromPostgresUrl(pgUrl) + return Option.none() +} /** The port over an env record in hand — a Worker's, a Durable Object's, a Workflow run's, a cron fire's. */ export const mapleDbConnectionLayer = (env: Record): Layer.Layer => diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index c59e59eaf..0954b0109 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -599,6 +599,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/services/org/OrgClickHouseSettingsService.ts b/apps/api/src/services/org/OrgClickHouseSettingsService.ts index fec514b39..acbb2c3b9 100644 --- a/apps/api/src/services/org/OrgClickHouseSettingsService.ts +++ b/apps/api/src/services/org/OrgClickHouseSettingsService.ts @@ -1247,7 +1247,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/api/wrangler.celld.jsonc b/apps/api/wrangler.celld.jsonc new file mode 100644 index 000000000..cc2b3c6c6 --- /dev/null +++ b/apps/api/wrangler.celld.jsonc @@ -0,0 +1,64 @@ +{ + // 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", + "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 003a55932..b12218e0a 100644 --- a/apps/electric-sync/src/worker.ts +++ b/apps/electric-sync/src/worker.ts @@ -88,7 +88,11 @@ const AppLayer = Layer.unwrap( 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: without these exposed headers // @electric-sql/client cannot advance the shape cursor through the // proxy, and every stream stalls after its first chunk. 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/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 1cbb6b8d3..7952eaafd 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/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), diff --git a/apps/web/src/lib/collections/shape-fetch.ts b/apps/web/src/lib/collections/shape-fetch.ts index c1f77726e..a034b59d3 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" @@ -15,8 +15,11 @@ import { tracedFetch } from "@/lib/services/common/telemetry" * authenticates, injects the org scope from the BEARER (never from `org=`, which * is there for cache keying only — see `createSyncedCollection`), 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 @@ -87,7 +90,7 @@ export const createSyncedCollection = >(config: { schema: config.schema, getKey: config.getKey, shapeOptions: { - url: syncProxyUrl, + url: syncProxyUrl(), params: { shape: config.shape, // Present so the URL differs per tenant, and read by NOBODY: the proxy 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 72ef05bb6..3e414785f 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, HttpClient, HttpClientError } from "effect/unstable/http" import { Clock, Duration, Effect, 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/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, 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 09e6fe931..efc8dc73a 100644 --- a/bun.lock +++ b/bun.lock @@ -1774,56 +1774,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.3", "", { "os": "android", "cpu": "arm" }, "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.3", "", { "os": "android", "cpu": "arm64" }, "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.3", "", { "os": "none", "cpu": "arm64" }, "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="], - "@rrweb/replay": ["@rrweb/replay@2.1.1", "", { "dependencies": { "@rrweb/types": "^2.1.1", "rrweb": "^2.1.1" } }, "sha512-jq64eyJvrh/ioBS1MpDrs/Lj+7QY2TW4xGxp4g4LJKdltSkqMjMbUmhTDcpzaEDZt66S0gasGcgqCulsksSXnw=="], "@rrweb/types": ["@rrweb/types@2.1.1", "", {}, "sha512-g0I3nCNL1S7slDXwhunxOuOoswkY8WVZJQrPFiwixOc6xoD514d1JLTuyAzCKIox8g/PaLKtV5g31Uk42inQSQ=="], @@ -3694,8 +3646,6 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], - "rollup": ["rollup@4.62.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.3", "@rollup/rollup-android-arm64": "4.62.3", "@rollup/rollup-darwin-arm64": "4.62.3", "@rollup/rollup-darwin-x64": "4.62.3", "@rollup/rollup-freebsd-arm64": "4.62.3", "@rollup/rollup-freebsd-x64": "4.62.3", "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", "@rollup/rollup-linux-arm-musleabihf": "4.62.3", "@rollup/rollup-linux-arm64-gnu": "4.62.3", "@rollup/rollup-linux-arm64-musl": "4.62.3", "@rollup/rollup-linux-loong64-gnu": "4.62.3", "@rollup/rollup-linux-loong64-musl": "4.62.3", "@rollup/rollup-linux-ppc64-gnu": "4.62.3", "@rollup/rollup-linux-ppc64-musl": "4.62.3", "@rollup/rollup-linux-riscv64-gnu": "4.62.3", "@rollup/rollup-linux-riscv64-musl": "4.62.3", "@rollup/rollup-linux-s390x-gnu": "4.62.3", "@rollup/rollup-linux-x64-gnu": "4.62.3", "@rollup/rollup-linux-x64-musl": "4.62.3", "@rollup/rollup-openbsd-x64": "4.62.3", "@rollup/rollup-openharmony-arm64": "4.62.3", "@rollup/rollup-win32-arm64-msvc": "4.62.3", "@rollup/rollup-win32-ia32-msvc": "4.62.3", "@rollup/rollup-win32-x64-gnu": "4.62.3", "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q=="], - "rou3": ["rou3@0.6.3", "", {}, "sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -3764,8 +3714,6 @@ "smol-toml": ["smol-toml@1.7.1", "", {}, "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ=="], - "solid-js": ["solid-js@1.6.12", "", { "dependencies": { "csstype": "^3.1.0" } }, "sha512-JFqRobfG3q5r1l4RYVOAukk6+FWtHpXGIjgh/GEsHKweN/kK+iHOtzUALE6+P5t/jIcSNeGiVitX8gmJg+cYvQ=="], - "sort-css-media-queries": ["sort-css-media-queries@3.0.5", "", {}, "sha512-wRgTa9kOgx5nV+lp/uwT0XBlH/WN5dpsOxyIkbtQud65Ie66TYjHceWH/8d1C0siMjcdSjjXg4zV022QmvLsaw=="], "sorted-btree": ["sorted-btree@1.8.1", "", {}, "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ=="], 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..2f9920849 --- /dev/null +++ b/deploy/celld-self-host/compose.yml @@ -0,0 +1,296 @@ +# 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" + 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} + 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} + 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} + 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" + + 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:pg + 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: + 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} + volumes: + - celld-vars:/run/maple + - celld-api-watch:/var/lib/celld + depends_on: + vars: + condition: service_started + migrate: + condition: service_completed_successfully + 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 + migrate: + condition: service_completed_successfully + 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 + migrate: + condition: service_completed_successfully + 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..67aa5503c --- /dev/null +++ b/deploy/celld-self-host/docker/Dockerfile.celld @@ -0,0 +1,83 @@ +# 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.1 +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; \ + 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; \ + 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 + +COPY --from=prod /app /app +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/entrypoint-celld.sh b/deploy/celld-self-host/docker/entrypoint-celld.sh new file mode 100755 index 000000000..bcc186a80 --- /dev/null +++ b/deploy/celld-self-host/docker/entrypoint-celld.sh @@ -0,0 +1,38 @@ +#!/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 -eu + +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}" +# 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)" +"$CELLD_BIN" deploy "$CONFIG" --bucket "$BUCKET" --endpoint "$ENDPOINT" --region "$REGION" + +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 "${LISTEN_HOST}:${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..b4e39fbd5 --- /dev/null +++ b/deploy/celld-self-host/docker/write-vars.sh @@ -0,0 +1,26 @@ +#!/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 \ + 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..5e3e13fcd --- /dev/null +++ b/deploy/celld-self-host/env.example @@ -0,0 +1,39 @@ +# 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 +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..764b68362 --- /dev/null +++ b/deploy/celld-self-host/k8s/alerting.yaml @@ -0,0 +1,58 @@ +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 + imagePullPolicy: IfNotPresent + 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" + - name: RUST_LOG + value: warn + 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..db02c74a6 --- /dev/null +++ b/deploy/celld-self-host/k8s/api.yaml @@ -0,0 +1,89 @@ +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 + imagePullPolicy: IfNotPresent + 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" + - name: RUST_LOG + value: warn + volumeMounts: + - name: vars + mountPath: /run/maple + - name: watch + mountPath: /var/lib/celld + ports: + - containerPort: 3472 + readinessProbe: + httpGet: + path: /health + port: 3472 + initialDelaySeconds: 20 + periodSeconds: 5 + failureThreshold: 24 + 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..088e1e6b1 --- /dev/null +++ b/deploy/celld-self-host/k8s/caddy.yaml @@ -0,0 +1,76 @@ +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 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/configmap.yaml b/deploy/celld-self-host/k8s/configmap.yaml new file mode 100644 index 000000000..5832bb102 --- /dev/null +++ b/deploy/celld-self-host/k8s/configmap.yaml @@ -0,0 +1,52 @@ +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 + 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 + 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/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 new file mode 100644 index 000000000..0c7239cb9 --- /dev/null +++ b/deploy/celld-self-host/k8s/kustomization.yaml @@ -0,0 +1,23 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: maple +resources: + - namespace.yaml + - configmap.yaml + - postgres.yaml + - clickhouse.yaml + - electric.yaml + - minio.yaml + - otel.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: 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..a52b73d11 --- /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:pg + 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/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/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..94b816115 --- /dev/null +++ b/deploy/celld-self-host/k8s/overlays/example/kustomization.yaml @@ -0,0 +1,36 @@ +# 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 +resources: + - ../.. +patches: + - target: + kind: Deployment + patch: |- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: unused + spec: + template: + spec: + nodeSelector: + kubernetes.io/hostname: YOUR_NODE + - target: + kind: Job + patch: |- + apiVersion: batch/v1 + kind: Job + metadata: + name: unused + spec: + template: + spec: + nodeSelector: + 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/sync.yaml b/deploy/celld-self-host/k8s/sync.yaml new file mode 100644 index 000000000..d30f19a15 --- /dev/null +++ b/deploy/celld-self-host/k8s/sync.yaml @@ -0,0 +1,72 @@ +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 + imagePullPolicy: IfNotPresent + 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" + - name: RUST_LOG + value: warn + 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..a4b72e758 --- /dev/null +++ b/deploy/celld-self-host/k8s/web.yaml @@ -0,0 +1,33 @@ +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 + imagePullPolicy: IfNotPresent + 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..09e74a613 --- /dev/null +++ b/docs/celld-self-host.md @@ -0,0 +1,301 @@ +# 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.1 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. Installs celld **v0.4.1** into `.tools/celld` if needed (macOS arm64 gzip from + GitHub releases). +3. 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) +4. 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) use postgres.js over `cloudflare:sockets` to docker Postgres. + +```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 | `MAPLE_PG_URL`. celld dials this over TCP | +| 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` +- `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.1 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. celld uses `MAPLE_PG_URL` over `cloudflare:sockets` (postgres.js) | +| TCP `connect()` / `cloudflare:sockets` | Yes (v0.4.1). Socket dies with the event; the request-scoped pool already matches that. `node:net` is still unimplemented | +| 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 | Yes in v0.4.1 (one handler per occurrence; no cron on a service-binding target) | +| 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` as api. `fetch` 404; crons run on 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 + +celld v0.4.1 implements outbound TCP through `cloudflare:sockets`. +`createMaplePgSocket` uses the same postgres.js path as wrangler (`prepare: +false`, request-scoped pool, `end()` at the boundary). `celld deploy` bundles +with `--conditions=workerd`, so that path is the `cf` build: `connect()` from +`cloudflare:sockets`, not `node:net`. The socket cannot outlive the event that +opened it; Maple already does not reuse a Postgres client across requests. + +Local docker Postgres is unencrypted. A TLS origin uses postgres.js SSL over +`connect()` (`secureTransport`); celld verifies against its Mozilla root store. + +## 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 +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`. + +### 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/ +``` + +### 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-otel` | 29 MB | +| ClickHouse / Electric / Caddy | upstream | + +## 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`). Do not publish + `:5499` past localhost. celld dials `MAPLE_PG_URL` over `cloudflare:sockets`. +2. **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 900790664..7d6156576 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -15,6 +15,8 @@ "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", "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/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) diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 58a1b6bd9..027e8d81a 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -78,6 +78,9 @@ export const toDrizzleLogger = (onQuery: ((query: string) => void) | undefined) * Cloudflare's own example now suggests `prepare: true`; that only pays off * across reuse of one long-lived connection, which a request-lived client by * definition does not have. Do not flip it back without measuring. + * + * celld and wrangler share this path. celld v0.4.1 dials through + * `cloudflare:sockets` (`connect()`); do not insert a WebSocket proxy. */ export const createMaplePgSocket = ( connectionString: string, 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 new file mode 100755 index 000000000..6f5baa3b0 --- /dev/null +++ b/scripts/celld-dev.sh @@ -0,0 +1,357 @@ +#!/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.1}" +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_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" == *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 want="${CELLD_VERSION#v}" + local version_ok=0 + if [[ -x "$CELLD_BIN" ]] && "$CELLD_BIN" --version 2>/dev/null | grep -q "$want"; then + version_ok=1 + elif command -v celld >/dev/null 2>&1 && celld --version 2>/dev/null | grep -q "$want"; 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_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" + DATABASE_URL="postgres://maple:maple@127.0.0.1:${PG_PORT}/maple" \ + bun run --cwd packages/db db:ensure-electric-publication +} + +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" + export RUST_LOG="${RUST_LOG:-warn}" + 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 +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