diff --git a/.env.example b/.env.example index 16f1bda1c4..d3c7488e50 100644 --- a/.env.example +++ b/.env.example @@ -187,6 +187,11 @@ INGEST_REQUIRE_TLS=false # CLOUDFLARE_OAUTH_REVOKE_URL=https://dash.cloudflare.com/oauth2/revoke # MAPLE_CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 +# Google Cloud integration (the project owner runs a generated gcloud script in Cloud Shell). +# Logs need no configuration. For metrics, set the email of a Google service account Maple owns; +# the setup script lets it impersonate a read-only metrics account in the customer's project. +# MAPLE_GCP_SERVICE_ACCOUNT_EMAIL= + # OpenTelemetry (API self-observability) — read by @maple-dev/effect-sdk # MAPLE_ENVIRONMENT=local # "local" = no export; any other value enables OTLP # MAPLE_ENDPOINT=http://127.0.0.1:3474 # Ingest gateway endpoint (enriches with org_id) diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index e38ccc8cdd..46814eecfb 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -79,4 +79,5 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains optionalSecret("GITHUB_APP_WEBHOOK_SECRET"), cloudflareOAuthEnv, planetScaleOAuthEnv, + optionalPlain("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), ) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts new file mode 100644 index 0000000000..bf5c51a5de --- /dev/null +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/http" +import { HttpApiBuilder } from "effect/http-api" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" +import { Env } from "@maple/backend/platform/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" +import { ApiAuthorizationV2Live } from "@maple/backend/services/auth/ApiAuthorizationV2Live" +import { AuthService } from "@maple/backend/services/auth/AuthService" +import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" +import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService" +import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + PlanetScaleServiceStubsLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" + +/** + * `/v2/integrations/gcp` over an embedded PGlite with the real connector service: the wire shape + * a script parses, the scope and capability rules, the admin gate, and who can reach the push + * secret. + */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_gcp_e2e") +const USER = Schema.decodeUnknownSync(UserId)("user_gcp_e2e") +const BASE = "/v2/integrations/gcp" + +const testConfig = (metricsAvailable: boolean) => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3478", + MCP_PORT: "3479", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + ...(metricsAvailable + ? { MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: "collector@maple-prod.iam.gserviceaccount.com" } + : undefined), + }), + ) + +const makeHarness = (options: { readonly metricsAvailable: boolean } = { metricsAvailable: true }) => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig(options.metricsAvailable))) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provideMerge(ApiAuthorizationV2Live), + Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + + /** Root-role key (API keys resolve as `root` unless their metadata pins roles). */ + const adminKey = (scopes?: ReadonlyArray) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { name: "gcp-admin", scopes }) + }), + ) + + const memberKey = () => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { + name: "gcp-member", + metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" }, + }) + }), + ) + + const request = async (method: string, path: string, token: string, body?: unknown) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined ? { "content-type": "application/json" } : undefined), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } + } + + return { + request, + adminKey, + memberKey, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +const CONNECTORS = `${BASE}/connectors` +const organizationConnector = { + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-host", + metrics_enabled: true, +} +const projectConnector = { scope_type: "project", scope_id: "acme-prod" } + +describe("v2 gcp integration over HTTP", () => { + it("creates connectors per scope, lists them for any member, deletes with a cleanup script", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const member = await harness.memberKey() + + const organization = await harness.request("POST", CONNECTORS, admin.secret, organizationConnector) + expect(organization.status).toBe(200) + expect(organization.body).toEqual({ + id: expect.stringMatching(/^gcpc_/), + object: "gcp_connector", + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-host", + logs_enabled: true, + metrics_enabled: true, + created_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.*Z$/), + last_log_received_at: null, + last_log_error: null, + applied_logs_enabled: null, + applied_metrics_enabled: null, + setup_reported_at: null, + }) + + // A project is its own host project, and forwards logs only unless asked otherwise. + const project = await harness.request("POST", CONNECTORS, admin.secret, projectConnector) + expect(project.body).toMatchObject({ + scope_type: "project", + scope_id: "acme-prod", + project_id: "acme-prod", + logs_enabled: true, + metrics_enabled: false, + }) + + const status = await harness.request("GET", BASE, member.secret) + expect(status.status).toBe(200) + expect(status.body).toMatchObject({ object: "gcp_integration", metrics_available: true }) + expect(status.body.connectors).toEqual(expect.arrayContaining([organization.body, project.body])) + expect(status.body.connectors).toHaveLength(2) + + const path = `${CONNECTORS}/${organization.body.id}` + const deleted = await harness.request("DELETE", path, admin.secret) + expect(deleted.status).toBe(200) + expect(deleted.body).toEqual({ + id: organization.body.id, + object: "gcp_connector", + deleted: true, + cleanup_script: expect.stringContaining( + 'gcloud logging sinks delete "$SINK" --organization="$SCOPE_ID"', + ), + }) + // The connector is gone: its cleanup script has nothing to report to. + expect(deleted.body.cleanup_script).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{32}/) + + const gone = await harness.request("POST", `${path}/setup_scripts`, admin.secret, {}) + expect(gone.status).toBe(404) + expect(gone.body.error).toMatchObject({ + _tag: "@maple/http/errors/IntegrationsNotFoundError", + code: "integration_not_found", + }) + await harness.dispose() + }) + + it("renders scripts that follow the connector's scope and switches", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const created = await harness.request("POST", CONNECTORS, admin.secret, { + ...organizationConnector, + scope_type: "folder", + }) + const path = `${CONNECTORS}/${created.body.id}` + + const both = await harness.request("POST", `${path}/setup_scripts`, admin.secret, { + log_filter: "include_gke_container_logs", + }) + expect(both.status).toBe(200) + expect(Object.keys(both.body).sort()).toEqual(["cleanup_script", "object", "setup_script"]) + expect(both.body.object).toBe("gcp_connector.setup_scripts") + expect(both.body.setup_script).toMatch( + /PUSH_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}\?secret=maple_gcp_/, + ) + expect(both.body.setup_script).toContain("PROJECT_ID='acme-host'") + expect(both.body.setup_script).toContain("SCOPE_ID='123456789012'") + expect(both.body.setup_script).toContain('--folder="$SCOPE_ID" --include-children') + expect(both.body.setup_script).toContain( + `LOG_FILTER_MODE='set'\nLOG_FILTER='NOT log_id("cloudaudit.googleapis.com/data_access") AND NOT httpRequest.userAgent:"GoogleHC" AND NOT protoPayload.methodName="io.k8s.coordination.v1.leases.update" AND NOT logName:"serialconsole.googleapis.com"'`, + ) + expect(both.body.setup_script).toContain( + 'gcloud resource-manager folders add-iam-policy-binding "$SCOPE_ID"', + ) + // What is copied is the script as a here-document for a bash process of its own. + expect(both.body.setup_script).toMatch(/^ \{ .*\nbash \/dev\/fd\/3 3<<'MAPLE_SETUP_SCRIPT'\n/) + expect(both.body.cleanup_script).toMatch(/\nMAPLE_CLEANUP_SCRIPT\n\}\n$/) + // The cleanup script tells Maple that it ran, with the connector's secret. + expect(both.body.cleanup_script).toContain("?secret=maple_gcp_") + + // Without an option an existing sink keeps its filter and a new one gets the default, which + // leaves out GKE container logs; with logs off there is none to carry. + const kept = await harness.request("POST", `${path}/setup_scripts`, admin.secret, {}) + expect(kept.body.setup_script).toContain("LOG_FILTER_MODE='keep'") + expect(kept.body.setup_script).toContain(` AND NOT resource.type="k8s_container"'\n`) + const unknown = await harness.request("POST", `${path}/setup_scripts`, admin.secret, { + log_filter: "everything", + }) + expect(unknown.status).toBe(400) + + const patched = await harness.request("PATCH", path, admin.secret, { logs_enabled: false }) + expect(patched.status).toBe(200) + expect(patched.body).toEqual({ ...created.body, logs_enabled: false }) + + const metricsOnly = await harness.request("POST", `${path}/setup_scripts`, admin.secret, {}) + expect(metricsOnly.body.setup_script).not.toContain("LOG_FILTER") + expect(metricsOnly.body.setup_script).toContain("gcloud logging sinks delete") + await harness.dispose() + }) + + it("keeps one capability on, and refuses metrics on a deployment without a Google identity", async () => { + const harness = makeHarness({ metricsAvailable: false }) + const admin = await harness.adminKey() + + const unavailable = await harness.request("POST", CONNECTORS, admin.secret, organizationConnector) + expect(unavailable.status).toBe(409) + expect(unavailable.body.error).toMatchObject({ + _tag: "@maple/http/errors/GcpMetricsUnavailableError", + code: "gcp_metrics_unavailable", + param: "metrics_enabled", + }) + + const nothing = await harness.request("POST", CONNECTORS, admin.secret, { + ...projectConnector, + logs_enabled: false, + }) + expect(nothing.status).toBe(400) + expect(nothing.body.error.code).toBe("integration_request_invalid") + + const created = await harness.request("POST", CONNECTORS, admin.secret, projectConnector) + const path = `${CONNECTORS}/${created.body.id}` + expect((await harness.request("GET", BASE, admin.secret)).body.metrics_available).toBe(false) + + const turnOn = await harness.request("PATCH", path, admin.secret, { metrics_enabled: true }) + expect(turnOn.status).toBe(409) + expect(turnOn.body.error.code).toBe("gcp_metrics_unavailable") + const turnOff = await harness.request("PATCH", path, admin.secret, { logs_enabled: false }) + expect(turnOff.status).toBe(400) + expect(turnOff.body.error.code).toBe("integration_request_invalid") + await harness.dispose() + }) + + it("keeps the push secret from non-admin members and from read-scoped keys", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const member = await harness.memberKey() + const readOnly = await harness.adminKey(["integrations:read"]) + const created = await harness.request("POST", CONNECTORS, admin.secret, projectConnector) + const path = `${CONNECTORS}/${created.body.id}` + + for (const [method, target, body] of [ + ["POST", CONNECTORS, { scope_type: "project", scope_id: "other-project" }], + ["PATCH", path, { metrics_enabled: true }], + ["POST", `${path}/setup_scripts`, {}], + ["DELETE", path, undefined], + ] as const) { + const asMember = await harness.request(method, target, member.secret, body) + expect(asMember.status, `${method} as member`).toBe(403) + expect(asMember.body.error).toMatchObject({ + type: "permission_error", + code: "insufficient_permissions", + }) + const asReadOnly = await harness.request(method, target, readOnly.secret, body) + expect(asReadOnly.status, `${method} with a read-only key`).toBe(403) + expect(asReadOnly.body.error.code).toBe("insufficient_scope") + } + + const status = await harness.request("GET", BASE, readOnly.secret) + expect(status.status).toBe(200) + expect(status.body.connectors).toEqual([created.body]) + expect(JSON.stringify(status.body)).not.toContain("maple_gcp_") + await harness.dispose() + }) + + it("answers a scope that is already connected with a 409 naming the field", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + await harness.request("POST", CONNECTORS, admin.secret, organizationConnector) + + const { status, body } = await harness.request("POST", CONNECTORS, admin.secret, { + ...organizationConnector, + project_id: "another-host", + }) + expect(status).toBe(409) + expect(body.error).toMatchObject({ + _tag: "@maple/http/errors/GcpScopeAlreadyConnectedError", + type: "conflict_error", + code: "gcp_scope_already_connected", + param: "scope_id", + }) + await harness.dispose() + }) + + it("answers malformed scopes and connector ids with 400, an unknown connector with 404", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + + for (const body of [ + { scope_type: "project", scope_id: "acme'; echo pwned; '" }, + { scope_type: "project", scope_id: "Acme-Prod" }, + { scope_type: "project", scope_id: "$(id)-project" }, + // A project hosts itself. + { scope_type: "project", scope_id: "acme-prod", project_id: "acme-host" }, + // Folders and organizations are numbers and need a host project. + { scope_type: "organization", scope_id: "acme.example", project_id: "acme-host" }, + { scope_type: "folder", scope_id: "123456789012" }, + { scope_type: "folder", scope_id: "123456789012", project_id: "123456789012" }, + { scope_type: "billing_account", scope_id: "123456789012", project_id: "acme-host" }, + { scope_id: "acme-prod" }, + ]) { + const response = await harness.request("POST", CONNECTORS, admin.secret, body) + expect(response.status, JSON.stringify(body)).toBe(400) + expect(response.body.error.type).toBe("invalid_request_error") + } + // The accepted spelling of the rejected project case above. + const selfHosted = await harness.request("POST", CONNECTORS, admin.secret, { + ...projectConnector, + project_id: "acme-prod", + }) + expect(selfHosted.status).toBe(200) + + const wrongPrefix = encodePublicId("scrp", "018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const malformed = await harness.request("DELETE", `${CONNECTORS}/${wrongPrefix}`, admin.secret) + expect(malformed.status).toBe(400) + + const unknown = `${CONNECTORS}/${encodePublicId("gcpc", "018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8")}` + expect((await harness.request("DELETE", unknown, admin.secret)).status).toBe(404) + const patch = await harness.request("PATCH", unknown, admin.secret, { logs_enabled: true }) + expect(patch.status).toBe(404) + await harness.dispose() + }) +}) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts new file mode 100644 index 0000000000..1ca6f0c641 --- /dev/null +++ b/apps/api/src/routes/v2/integrations-gcp.http.ts @@ -0,0 +1,129 @@ +import { HttpApiBuilder } from "effect/http-api" +import { CurrentTenant } from "@maple/domain/http" +import type { V2GcpConnector } from "@maple/domain/http/v2" +import { + MapleApiV2, + isoTimestamp, + isoTimestampOrNull, + V2InsufficientPermissions, +} from "@maple/domain/http/v2" +import { Effect } from "effect" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { requireAdmin } from "@maple/backend/services/auth/auth" +import { + GcpConnectorService, + type GcpConnector, +} from "@maple/backend/services/integrations/GcpConnectorService" + +// Reading the status is open to every member. Everything else is admin-only: a connector decides +// what may write logs into the org and what Maple reads, and its setup script carries the push +// secret. +const adminOnly = () => V2InsufficientPermissions.make("Only org admins can manage Google Cloud connectors") + +const toV2Connector = (connector: GcpConnector): V2GcpConnector => ({ + id: connector.id, + object: "gcp_connector", + scope_type: connector.scopeType, + scope_id: connector.scopeId, + project_id: connector.projectId, + logs_enabled: connector.logsEnabled, + metrics_enabled: connector.metricsEnabled, + created_at: isoTimestamp(connector.createdAt), + last_log_received_at: isoTimestampOrNull(connector.lastLogReceivedAt), + last_log_error: connector.lastLogError, + applied_logs_enabled: connector.appliedLogsEnabled, + applied_metrics_enabled: connector.appliedMetricsEnabled, + setup_reported_at: isoTimestampOrNull(connector.setupReportedAt), +}) + +const auditMetadata = (connector: GcpConnector) => ({ + scope_type: connector.scopeType, + scope_id: connector.scopeId, + project_id: connector.projectId, + logs_enabled: connector.logsEnabled, + metrics_enabled: connector.metricsEnabled, +}) + +export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIntegration", (handlers) => + Effect.gen(function* () { + const gcp = yield* GcpConnectorService + + return handlers + .handle("status", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const status = yield* gcp.status(tenant.orgId) + return { + object: "gcp_integration" as const, + metrics_available: status.metricsAvailable, + connectors: status.connectors.map(toV2Connector), + } + }), + ) + .handle("createConnector", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const connector = yield* gcp.create(tenant.orgId, tenant.userId, { + scopeType: payload.scope_type, + scopeId: payload.scope_id, + // A project hosts its own resources; the contract rejects any other value. + projectId: payload.scope_type === "project" ? payload.scope_id : payload.project_id, + logsEnabled: payload.logs_enabled ?? true, + metricsEnabled: payload.metrics_enabled ?? false, + }) + yield* recordHttpAudit("gcp_connector.created", { + resourceId: connector.id, + metadata: auditMetadata(connector), + }) + return toV2Connector(connector) + }), + ) + .handle("updateConnector", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const connector = yield* gcp.update(tenant.orgId, params.id, { + logsEnabled: payload.logs_enabled, + metricsEnabled: payload.metrics_enabled, + }) + yield* recordHttpAudit("gcp_connector.updated", { + resourceId: connector.id, + metadata: auditMetadata(connector), + }) + return toV2Connector(connector) + }), + ) + .handle("setupScripts", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const scripts = yield* gcp.scripts(tenant.orgId, params.id, { + logFilter: payload.log_filter ?? "keep", + }) + return { + object: "gcp_connector.setup_scripts" as const, + setup_script: scripts.setupScript, + cleanup_script: scripts.cleanupScript, + } + }), + ) + .handle("deleteConnector", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const deleted = yield* gcp.delete(tenant.orgId, params.id) + yield* recordHttpAudit("gcp_connector.deleted", { + resourceId: params.id, + metadata: auditMetadata(deleted.connector), + }) + return { + id: params.id, + object: "gcp_connector" as const, + deleted: true as const, + cleanup_script: deleted.cleanupScript, + } + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 4d22173950..56033f7649 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -42,7 +42,9 @@ import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" import { HttpV2DashboardsLive } from "./dashboards.http" import { HttpV2IngestKeysLive } from "./ingest-keys.http" import { HttpV2ChatIntegrationsLive } from "./integrations-chat.http" +import { HttpV2GcpIntegrationsLive } from "./integrations-gcp.http" import { HttpV2PlanetScaleIntegrationsLive } from "./integrations.http" +import { GcpConnectorService } from "@maple/backend/services/integrations/GcpConnectorService" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" import { HttpV2InvestigationsLive } from "./investigations.http" @@ -155,6 +157,8 @@ const v2GroupLayersExceptOnboardingChecklist = (chatWorkspace: Layer.Layer` shares the `integrations:read` / `integrations:write` family. Two things never move with a provider, no matter how much of it is promoted: the OAuth **callback** and any webhook **receiver**. Both are raw `HttpRouter` routes under `/api/…` serving a browser redirect or a provider POST, and a receiver URL is already registered in the provider's settings. So `POST /v2/integrations/planetscale/connect` still mints a `/api/integrations/planetscale/callback` URL, and `webhook_config` still reports a `/api/…` receiver. That is the intended end state. @@ -48,7 +50,7 @@ POST /v2/traces/search complex reads are POST .../search Every v2 object has a prefixed public ID (`key_4CzLmR…`, `dash_…`, `alrt_…`). Public IDs are opaque; internally they are a reversible base58 encoding of the internal ID, computed at the API boundary in `packages/domain/src/http/v2/public-id.ts`, which also holds the prefix registry (`PublicIdPrefixes`, the source of truth). No database migration: rows keep their raw UUIDs / internal strings. -Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dshr` (dashboard share), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `evt` (alert delivery event), `actor` (actor), `alog` (audit log entry), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), `mdev` (mobile device), `chatw` (chat workspace), `afb` (agent feedback), and `log` (synthetic log identity); `we` is reserved for webhooks. +Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dshr` (dashboard share), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `evt` (alert delivery event), `actor` (actor), `alog` (audit log entry), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), `mdev` (mobile device), `chatw` (chat workspace), `afb` (agent feedback), `gcpc` (Google Cloud connector), and `log` (synthetic log identity); `we` is reserved for webhooks. Exception: Clerk-issued `org_…` / `user_…` IDs are already prefixed public IDs and pass through unchanged. @@ -151,34 +153,35 @@ Stripe-style `expand[]` is deliberately omitted: responses embed the small, alwa Implemented in phases; the pilot (`api_keys`) ships first and proves every convention. -| Resource | Endpoints | Backing service | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | -| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | -| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | -| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | -| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | -| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | -| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | -| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | -| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | -| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | -| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | -| `onboarding/checklist` ✅ | retrieve + claim (singleton; steps verified per request, claim admin-only and idempotent) | `OnboardingChecklistService` | -| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | -| `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | -| `integrations/chat_*` ✅ | connector list (manifest + linked workspaces) + admin-only install/settings/unlink; the chat platform is a value, never a path segment | `ChatWorkspaceService` | -| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | -| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | -| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | -| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | -| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | -| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | -| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | -| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | -| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | -| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | +| Resource | Endpoints | Backing service | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | +| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | +| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | +| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | +| `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | +| `alerts/incidents` ✅ | list/retrieve | `AlertsService` | +| `alerts/deliveries` ✅ | list delivery attempts | `AlertsService` | +| `error_issues` 🟡 | list/retrieve/`service_counts` ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | +| `investigations` ✅ | list/retrieve/create/status | `InvestigationService` | +| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue, `service_counts` aggregate + `PATCH` settings | `anomalies` | +| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `RecommendationIssueService` | +| `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | +| `onboarding/checklist` ✅ | retrieve + claim (singleton; steps verified per request, claim admin-only and idempotent) | `OnboardingChecklistService` | +| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | +| `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | +| `integrations/chat_*` ✅ | connector list (manifest + linked workspaces) + admin-only install/settings/unlink; the chat platform is a value, never a path segment | `ChatWorkspaceService` | +| `integrations/planetscale` ✅ | status + connect/organizations/`select_organization`/`metrics_token`/disconnect + databases/`webhook_config`/`query_insights`/`events` | `PlanetScaleConnectionService`, `PlanetScaleOAuthService`, `PlanetScaleService` | +| `integrations/gcp` ✅ | status (connectors + `metrics_available`) + admin-only connector create/update/delete (project, folder or organization scope) and `setup_scripts` | `GcpConnectorService` | +| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | +| `mobile_devices` ✅ | list (mine) / `PUT …/{token}` register-or-refresh / `DELETE …/{token}`; per user, per org; drives push fan-out | `MobileDevicesService` | +| `agent_feedback` ✅ | create/list; feedback about Maple from agents: `kind`, `agent` (type/name/model/version), `reason`, optional `impact`/`details`/`related_to` | `AgentFeedbackService` | +| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | +| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | +| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | +| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | +| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/services/{name}/overview` (summary + all-signal series + operations, one round-trip) | `queryEngine` | +| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | Also mounted, outside this table: `audit_log` (list), `environments`, `instrumentation/signals` (retrieve), `widget_credentials` (mint/revoke), `widget_summary` (retrieve), and `share` (share-link reads). The contracts are in `packages/domain/src/http/v2/`. diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index 22f77e12cd..5c77a117f1 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -144,6 +144,11 @@ export interface EnvConfig { readonly GITHUB_APP_CLIENT_SECRET: Option.Option> readonly GITHUB_APP_WEBHOOK_SECRET: Option.Option> readonly GITHUB_API_BASE_URL: string + /** + * Maple's own Google service account. Customers grant it permission to impersonate the + * metrics reader their setup script creates. Unset: the GCP integration is logs-only. + */ + readonly MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: Option.Option readonly CLOUDFLARE_OAUTH_CLIENT_ID: Option.Option readonly CLOUDFLARE_OAUTH_CLIENT_SECRET: Option.Option> readonly CLOUDFLARE_OAUTH_SCOPES: string @@ -251,6 +256,7 @@ const envConfig = Config.all({ GITHUB_APP_CLIENT_SECRET: optionalRedacted("GITHUB_APP_CLIENT_SECRET"), GITHUB_APP_WEBHOOK_SECRET: optionalRedacted("GITHUB_APP_WEBHOOK_SECRET"), GITHUB_API_BASE_URL: stringWithDefault("GITHUB_API_BASE_URL", "https://api.github.com"), + MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: optionalString("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), CLOUDFLARE_OAUTH_CLIENT_ID: optionalString("CLOUDFLARE_OAUTH_CLIENT_ID"), CLOUDFLARE_OAUTH_CLIENT_SECRET: optionalRedacted("CLOUDFLARE_OAUTH_CLIENT_SECRET"), // Cloudflare OAuth scope ids are DOT-delimited (mirroring API-token permission names; diff --git a/packages/backend/src/services/audit/audit-actions.ts b/packages/backend/src/services/audit/audit-actions.ts index 027f6a620a..73443af9f4 100644 --- a/packages/backend/src/services/audit/audit-actions.ts +++ b/packages/backend/src/services/audit/audit-actions.ts @@ -63,6 +63,8 @@ export const AuditResources = { dashboard_share: { prefix: PublicIdPrefixes.dashboardShare, verbs: ["created", "rotated", "deleted"] }, /** Verbs mirror the issue event types — `recordEvent` audits every one it attributes. */ error_issue: { prefix: PublicIdPrefixes.errorIssue, verbs: ErrorIssueEventType.literals }, + /** One connected Google Cloud scope; the scope and what it collects are in `metadata`. */ + gcp_connector: { prefix: PublicIdPrefixes.gcpConnector, verbs: ["created", "updated", "deleted"] }, /** Org-singleton public/private pair; which one rolled is in `metadata`. */ ingest_key: { verbs: ["rolled"] }, /** diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts new file mode 100644 index 0000000000..1337c6353c --- /dev/null +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -0,0 +1,291 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { hashIngestKey } from "@maple/db" +import { gcpConnectorResourceNames } from "@maple/domain/gcp" +import { GcpProjectId, GcpResourceNumber, OrgId, UserId } from "@maple/domain/primitives" +import { Env } from "@maple/backend/platform/Env" +import { + cleanupTestDbs, + createTestDb, + executeSql, + queryFirstRow, + type TestDb, +} from "@maple/backend/platform/test-pglite" +import { GcpConnectorService, type CreateGcpConnectorInput } from "./GcpConnectorService" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const LOOKUP_HMAC_KEY = "maple-test-lookup-secret" +const MAPLE_ACCOUNT = "collector@maple-prod.iam.gserviceaccount.com" + +// A fresh layer per call: one test builds the service twice, under two configurations. +const makeLayer = (testDb: TestDb, mapleServiceAccount?: string) => + Layer.effect(GcpConnectorService, GcpConnectorService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(Env.layer), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3472", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"), + // Padded: the gateway trims this key before hashing, so the API must too. + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: ` ${LOOKUP_HMAC_KEY} `, + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test/", + ...(mapleServiceAccount === undefined + ? undefined + : { MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: mapleServiceAccount }), + }), + ), + ), + ) + +const orgId = Schema.decodeUnknownSync(OrgId)("org_gcp") +const otherOrgId = Schema.decodeUnknownSync(OrgId)("org_other") +const userId = Schema.decodeUnknownSync(UserId)("user_1") +const project = Schema.decodeUnknownSync(GcpProjectId) +const number = Schema.decodeUnknownSync(GcpResourceNumber) + +const projectScope = (id: string, flags?: Partial): CreateGcpConnectorInput => ({ + scopeType: "project", + scopeId: project(id), + projectId: project(id), + logsEnabled: true, + metricsEnabled: false, + ...flags, +}) + +const organizationScope: CreateGcpConnectorInput = { + scopeType: "organization", + scopeId: number("123456789012"), + projectId: project("acme-host"), + logsEnabled: true, + metricsEnabled: true, +} + +const scriptOptions = { logFilter: "keep" } as const + +interface StoredConnector { + readonly secret_ciphertext: string + readonly secret_hash: string + readonly created_by: string +} + +describe("GcpConnectorService", () => { + it.effect("stores a connector whose secret hash is the one the ingest gateway computes", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const connector = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + assert.strictEqual(connector.scopeId, "acme-prod") + assert.strictEqual(connector.projectId, "acme-prod") + assert.isNull(connector.lastLogReceivedAt) + assert.isNull(connector.lastLogError) + assert.isNull(connector.appliedLogsEnabled) + assert.isNull(connector.setupReportedAt) + + const { setupScript } = yield* gcp.scripts(orgId, connector.id, scriptOptions) + const endpoint = /PUSH_ENDPOINT='([^']+)'/.exec(setupScript)?.[1] + assert.isDefined(endpoint) + const url = new URL(endpoint!) + assert.strictEqual( + url.origin + url.pathname, + `https://ingest.test/v1/logpush/gcp/${connector.id}`, + ) + const secret = url.searchParams.get("secret")! + assert.match(secret, /^maple_gcp_[A-Za-z0-9_-]{32}$/) + + const row = yield* Effect.promise(() => + queryFirstRow(testDb, "SELECT * FROM gcp_connectors WHERE id = $1", [ + connector.id, + ]), + ) + assert.strictEqual(row!.secret_hash, hashIngestKey(secret, LOOKUP_HMAC_KEY)) + assert.notInclude(row!.secret_ciphertext, secret) + assert.strictEqual(row!.created_by, userId) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("connects a scope once per organization, whatever its host project", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + yield* gcp.create(orgId, userId, organizationScope) + const duplicate = yield* Effect.flip( + gcp.create(orgId, userId, { ...organizationScope, projectId: project("another-host") }), + ) + assert.strictEqual(duplicate._tag, "@maple/http/errors/GcpScopeAlreadyConnectedError") + + // A folder with the same number is a different scope, and so is a project inside it. + yield* gcp.create(orgId, userId, { ...organizationScope, scopeType: "folder" }) + yield* gcp.create(orgId, userId, projectScope("acme-host")) + yield* gcp.create(otherOrgId, userId, organizationScope) + assert.lengthOf((yield* gcp.status(orgId)).connectors, 3) + }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) + }) + + it.effect("requires one capability to stay on and refuses metrics without a Google identity", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + assert.isFalse((yield* gcp.status(orgId)).metricsAvailable) + + const nothing = yield* Effect.flip( + gcp.create(orgId, userId, projectScope("acme-prod", { logsEnabled: false })), + ) + assert.strictEqual(nothing._tag, "@maple/http/errors/IntegrationsValidationError") + const metrics = yield* Effect.flip( + gcp.create(orgId, userId, projectScope("acme-prod", { metricsEnabled: true })), + ) + assert.strictEqual(metrics._tag, "@maple/http/errors/GcpMetricsUnavailableError") + + const connector = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + const turnOn = yield* Effect.flip(gcp.update(orgId, connector.id, { metricsEnabled: true })) + assert.strictEqual(turnOn._tag, "@maple/http/errors/GcpMetricsUnavailableError") + const turnOff = yield* Effect.flip(gcp.update(orgId, connector.id, { logsEnabled: false })) + assert.strictEqual(turnOff._tag, "@maple/http/errors/IntegrationsValidationError") + // An empty patch changes nothing. + assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, {}), connector) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("switches capabilities, and the scripts follow the stored flags", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + assert.isTrue((yield* gcp.status(orgId)).metricsAvailable) + const connector = yield* gcp.create(orgId, userId, organizationScope) + const both = yield* gcp.scripts(orgId, connector.id, { + logFilter: "include_gke_container_logs", + }) + assert.include(both.setupScript, `MAPLE_SERVICE_ACCOUNT='${MAPLE_ACCOUNT}'`) + assert.notInclude(both.setupScript, "k8s_container") + assert.include(both.setupScript, '--organization="$SCOPE_ID" --include-children') + assert.include(both.setupScript, "PROJECT_ID='acme-host'") + assert.include(both.setupScript, "MAPLE_URL='http://127.0.0.1:3471/integrations?integration=gcp'") + // Both scripts tell Maple that they ran, so both carry the secret. + assert.include(both.cleanupScript, "?secret=maple_gcp_") + + const metricsOnly = yield* gcp.update(orgId, connector.id, { logsEnabled: false }) + assert.deepStrictEqual(metricsOnly, { ...connector, logsEnabled: false }) + const afterOptOut = yield* gcp.scripts(orgId, connector.id, scriptOptions) + assert.notInclude(afterOptOut.setupScript, "LOG_FILTER") + assert.include(afterOptOut.setupScript, "gcloud logging sinks delete") + + const logsOnly = yield* gcp.update(orgId, connector.id, { + logsEnabled: true, + metricsEnabled: false, + }) + assert.deepStrictEqual(logsOnly, { ...connector, metricsEnabled: false }) + assert.deepStrictEqual((yield* gcp.status(orgId)).connectors, [logsOnly]) + }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) + }) + + it.effect("forgets the last push when log forwarding is switched back on", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const connector = yield* gcp.create(orgId, userId, organizationScope) + yield* Effect.promise(() => + executeSql( + testDb, + `UPDATE gcp_connectors SET last_received_at = $1, last_error = $2, + applied_logs_enabled = true, setup_reported_at = $1 WHERE id = $3`, + ["2026-10-08T09:12:00.000Z", "payload was not a LogEntry", connector.id], + ), + ) + // What a setup run reported describes Google Cloud, so no switch changes it. + const reported = { + ...connector, + appliedLogsEnabled: true, + setupReportedAt: Date.parse("2026-10-08T09:12:00.000Z"), + } + const lastPush = { + lastLogReceivedAt: Date.parse("2026-10-08T09:12:00.000Z"), + lastLogError: "payload was not a LogEntry", + } + + // Kept while logs stay on, and while they are off. + const stillOn = yield* gcp.update(orgId, connector.id, { logsEnabled: true }) + assert.deepStrictEqual(stillOn, { ...reported, ...lastPush }) + const off = yield* gcp.update(orgId, connector.id, { logsEnabled: false }) + assert.deepStrictEqual(off, { ...reported, ...lastPush, logsEnabled: false }) + + const backOn = yield* gcp.update(orgId, connector.id, { logsEnabled: true }) + assert.deepStrictEqual(backOn, reported) + assert.deepStrictEqual((yield* gcp.status(orgId)).connectors, [reported]) + }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) + }) + + it.effect("reports what the ingest gateway recorded for the last push", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const connector = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + yield* Effect.promise(() => + executeSql( + testDb, + "UPDATE gcp_connectors SET last_received_at = $1, last_error = $2 WHERE id = $3", + ["2026-10-08T09:12:00.000Z", "payload was not a LogEntry", connector.id], + ), + ) + assert.deepStrictEqual((yield* gcp.status(orgId)).connectors, [ + { + ...connector, + lastLogReceivedAt: Date.parse("2026-10-08T09:12:00.000Z"), + lastLogError: "payload was not a LogEntry", + }, + ]) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("refuses to render a secret that was moved onto another connector's row", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const source = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + const target = yield* gcp.create(orgId, userId, projectScope("acme-staging")) + yield* Effect.promise(() => + executeSql( + testDb, + `UPDATE gcp_connectors AS t + SET secret_ciphertext = s.secret_ciphertext, secret_iv = s.secret_iv, secret_tag = s.secret_tag + FROM gcp_connectors AS s WHERE s.id = $1 AND t.id = $2`, + [source.id, target.id], + ), + ) + const error = yield* Effect.flip(gcp.scripts(orgId, target.id, scriptOptions)) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsPersistenceError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("scopes updates, scripts and deletion to the owning organization", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const connector = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + + for (const foreign of [ + gcp.update(otherOrgId, connector.id, { logsEnabled: true }), + gcp.scripts(otherOrgId, connector.id, scriptOptions), + gcp.delete(otherOrgId, connector.id), + ]) { + const error = yield* Effect.flip(foreign) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsNotFoundError") + } + + const deleted = yield* gcp.delete(orgId, connector.id) + assert.deepStrictEqual(deleted.connector, connector) + assert.include(deleted.cleanupScript, gcpConnectorResourceNames(connector.id).sink) + assert.notMatch(deleted.cleanupScript, /maple_gcp_[A-Za-z0-9_-]{32}/) + assert.deepStrictEqual((yield* gcp.status(orgId)).connectors, []) + const again = yield* Effect.flip(gcp.delete(orgId, connector.id)) + assert.strictEqual(again._tag, "@maple/http/errors/IntegrationsNotFoundError") + }).pipe(Effect.provide(makeLayer(testDb))) + }) +}) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts new file mode 100644 index 0000000000..faad3a8d8e --- /dev/null +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -0,0 +1,367 @@ +import { randomBytes, randomUUID } from "node:crypto" +import * as PG from "@maple-dev/effect-orm/postgres" +import { hashIngestKey, parseIngestKeyLookupHmacKey } from "@maple/db" +import { GcpConnectors, type GcpConnectorRow } from "@maple/db/tables" +import { + GcpMetricsUnavailableError, + GcpScopeAlreadyConnectedError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, + IntegrationsValidationError, +} from "@maple/domain/http" +import { + GcpConnectorId, + type GcpLogFilter, + type GcpProjectId, + type GcpResourceNumber, + type GcpScopeType, + type OrgId, + type UserId, +} from "@maple/domain/primitives" +import { Clock, Context, Effect, Layer, Option, Redacted } from "effect" +import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { Env } from "@maple/backend/platform/Env" +import { renderGcpCleanupScript, renderGcpSetupScript } from "./gcp/setup-scripts" + +export interface GcpConnector { + readonly id: GcpConnectorId + readonly scopeType: GcpScopeType + /** The project id, or the numeric folder / organization id. */ + readonly scopeId: GcpProjectId | GcpResourceNumber + /** The host project, where Maple's own resources live. Equals `scopeId` for a project scope. */ + readonly projectId: GcpProjectId + readonly logsEnabled: boolean + /** Covers metrics and resource collection. */ + readonly metricsEnabled: boolean + readonly createdAt: number + /** Last log push the ingest gateway accepted from this connector. Null until the first one. */ + readonly lastLogReceivedAt: number | null + readonly lastLogError: string | null + /** What the latest setup script run reported it set up in Google Cloud. Null until one reports. */ + readonly appliedLogsEnabled: boolean | null + readonly appliedMetricsEnabled: boolean | null + readonly setupReportedAt: number | null +} + +export type CreateGcpConnectorInput = Pick< + GcpConnector, + "scopeType" | "scopeId" | "projectId" | "logsEnabled" | "metricsEnabled" +> + +type GcpCapabilityError = GcpMetricsUnavailableError | IntegrationsValidationError + +export interface GcpConnectorServiceApi { + readonly status: (orgId: OrgId) => Effect.Effect< + { + /** False when this deployment has no Google identity to read metrics with. */ + readonly metricsAvailable: boolean + readonly connectors: ReadonlyArray + }, + IntegrationsPersistenceError + > + readonly create: ( + orgId: OrgId, + userId: UserId, + input: CreateGcpConnectorInput, + ) => Effect.Effect< + GcpConnector, + GcpScopeAlreadyConnectedError | GcpCapabilityError | IntegrationsPersistenceError + > + /** Omitted flags are unchanged. Google Cloud only follows once the setup script is re-run. */ + readonly update: ( + orgId: OrgId, + connectorId: GcpConnectorId, + patch: { readonly logsEnabled?: boolean | undefined; readonly metricsEnabled?: boolean | undefined }, + ) => Effect.Effect< + GcpConnector, + IntegrationsNotFoundError | GcpCapabilityError | IntegrationsPersistenceError + > + readonly scripts: ( + orgId: OrgId, + connectorId: GcpConnectorId, + options: { readonly logFilter: GcpLogFilter }, + ) => Effect.Effect< + { readonly setupScript: string; readonly cleanupScript: string }, + IntegrationsNotFoundError | IntegrationsPersistenceError + > + /** Deleting the row is the disconnect: the ingest gateway stops accepting the connector's pushes. */ + readonly delete: ( + orgId: OrgId, + connectorId: GcpConnectorId, + ) => Effect.Effect< + { readonly connector: GcpConnector; readonly cleanupScript: string }, + IntegrationsNotFoundError | IntegrationsPersistenceError + > +} + +const toPersistenceError = makePersistenceErrorMapper( + IntegrationsPersistenceError, + "Google Cloud connector database error", +) + +// Binds the ciphertext to its row, so a stored secret cannot be moved onto another connector. +const secretAad = (connectorId: string) => Buffer.from(`gcp_connectors:v1:${connectorId}`, "utf8") + +const toConnector = (row: GcpConnectorRow): GcpConnector => ({ + id: row.id, + scopeType: row.scopeType, + scopeId: row.scopeId, + projectId: row.projectId, + logsEnabled: row.logsEnabled, + metricsEnabled: row.metricsEnabled, + createdAt: row.createdAt, + lastLogReceivedAt: row.lastReceivedAt, + lastLogError: row.lastError, + appliedLogsEnabled: row.appliedLogsEnabled, + appliedMetricsEnabled: row.appliedMetricsEnabled, + setupReportedAt: row.setupReportedAt, +}) + +const notFound = () => new IntegrationsNotFoundError({ message: "No such Google Cloud connector." }) + +export class GcpConnectorService extends Context.Service()( + "@maple/api/services/GcpConnectorService", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const encryptionKey = yield* parseBase64Aes256GcmKey( + Redacted.value(env.MAPLE_INGEST_KEY_ENCRYPTION_KEY), + (message) => new IntegrationsPersistenceError({ message }), + ) + // The ingest gateway hashes a pushed secret with this key and looks the row up by it. + const lookupHmacKey = yield* parseIngestKeyLookupHmacKey( + Redacted.value(env.MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY), + (message) => new IntegrationsPersistenceError({ message }), + ) + const ingestBaseUrl = env.MAPLE_INGEST_PUBLIC_URL.replace(/\/+$/, "") + const mapleServiceAccountEmail = Option.getOrUndefined(env.MAPLE_GCP_SERVICE_ACCOUNT_EMAIL) + const mapleUrl = `${env.MAPLE_APP_BASE_URL.replace(/\/+$/, "")}/integrations?integration=gcp` + const dbExecute = makeDbExecute(database, "GcpConnectorService", toPersistenceError) + + const capabilityError = ( + flags: { readonly logsEnabled: boolean; readonly metricsEnabled: boolean }, + turningMetricsOn: boolean, + ): GcpCapabilityError | undefined => { + if (!flags.logsEnabled && !flags.metricsEnabled) { + return new IntegrationsValidationError({ + message: + "At least one of logs_enabled and metrics_enabled must be on. Delete the connector to disconnect.", + }) + } + if (turningMetricsOn && mapleServiceAccountEmail === undefined) { + return new GcpMetricsUnavailableError({ + message: "Metrics and resource collection is not available on this Maple deployment.", + }) + } + return undefined + } + + const selectRow = (orgId: OrgId, connectorId: GcpConnectorId) => + dbExecute((db) => + db.run( + PG.from(GcpConnectors) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectorId)]) + .limit(1), + ), + ).pipe( + Effect.flatMap(([row]) => + row === undefined ? Effect.fail(notFound()) : Effect.succeed(row), + ), + ) + + const status = Effect.fn("GcpConnectorService.status")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const rows = yield* dbExecute((db) => + db.run( + PG.from(GcpConnectors) + .select() + .where(($) => [$.orgId.eq(orgId)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]), + ), + ) + return { + metricsAvailable: mapleServiceAccountEmail !== undefined, + connectors: rows.map(toConnector), + } + }) + + const create = Effect.fn("GcpConnectorService.create")(function* ( + orgId: OrgId, + userId: UserId, + input: CreateGcpConnectorInput, + ) { + yield* Effect.annotateCurrentSpan({ + orgId, + "maple.gcp.scope_type": input.scopeType, + "maple.gcp.scope_id": input.scopeId, + }) + const invalid = capabilityError(input, input.metricsEnabled) + if (invalid !== undefined) return yield* Effect.fail(invalid) + const id = GcpConnectorId.make(randomUUID()) + const secret = `maple_gcp_${randomBytes(24).toString("base64url")}` + const encrypted = yield* encryptAes256Gcm( + secret, + encryptionKey, + (message) => new IntegrationsPersistenceError({ message }), + secretAad(id), + ) + const now = yield* Clock.currentTimeMillis + const [row] = yield* dbExecute((db) => + db.run( + PG.insertInto(GcpConnectors) + .values({ + id, + orgId, + ...input, + secretCiphertext: encrypted.ciphertext, + secretIv: encrypted.iv, + secretTag: encrypted.tag, + secretHash: hashIngestKey(secret, lookupHmacKey), + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: ["orgId", "scopeType", "scopeId"] }) + .returning(), + ), + ) + if (row === undefined) { + return yield* new GcpScopeAlreadyConnectedError({ + scopeType: input.scopeType, + scopeId: input.scopeId, + message: `The Google Cloud ${input.scopeType} ${input.scopeId} is already connected.`, + }) + } + return toConnector(row) + }) + + const update = Effect.fn("GcpConnectorService.update")(function* ( + orgId: OrgId, + connectorId: GcpConnectorId, + patch: { + readonly logsEnabled?: boolean | undefined + readonly metricsEnabled?: boolean | undefined + }, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.gcp.connector_id": connectorId }) + const updatedAt = yield* Clock.currentTimeMillis + const outcome = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + // The row lock keeps one capability on when two updates race. + const [current] = yield* db.run( + PG.from(GcpConnectors) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectorId)]) + .limit(1) + .forUpdate(), + ) + if (current === undefined) return undefined + const flags = { + logsEnabled: patch.logsEnabled ?? current.logsEnabled, + metricsEnabled: patch.metricsEnabled ?? current.metricsEnabled, + } + const invalid = capabilityError(flags, patch.metricsEnabled === true) + // Switched back on, a capability has nothing set up in Google Cloud until the + // script is re-run: what it last reported no longer describes the connection. + const changes = { + ...flags, + ...(flags.logsEnabled && !current.logsEnabled + ? { lastReceivedAt: null, lastError: null } + : undefined), + } + if (invalid === undefined) { + yield* db.run( + PG.update(GcpConnectors) + .set({ ...changes, updatedAt }) + .where(($) => [$.id.eq(connectorId)]), + ) + } + return { row: { ...current, ...changes }, invalid } + }), + ), + ) + if (outcome === undefined) return yield* notFound() + if (outcome.invalid !== undefined) return yield* Effect.fail(outcome.invalid) + return toConnector(outcome.row) + }) + + const scripts = Effect.fn("GcpConnectorService.scripts")(function* ( + orgId: OrgId, + connectorId: GcpConnectorId, + options: { readonly logFilter: GcpLogFilter }, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.gcp.connector_id": connectorId }) + const row = yield* selectRow(orgId, connectorId) + const secret = yield* decryptAes256Gcm( + { ciphertext: row.secretCiphertext, iv: row.secretIv, tag: row.secretTag }, + encryptionKey, + () => + new IntegrationsPersistenceError({ + message: "Failed to decrypt the Google Cloud connector secret", + }), + secretAad(row.id), + ) + const target = { + connectorId, + scopeType: row.scopeType, + scopeId: row.scopeId, + projectId: row.projectId, + mapleUrl, + pushEndpoint: `${ingestBaseUrl}/v1/logpush/gcp/${connectorId}?secret=${secret}`, + } + return { + setupScript: yield* renderGcpSetupScript({ + ...target, + mapleServiceAccountEmail, + logsEnabled: row.logsEnabled, + metricsEnabled: row.metricsEnabled, + logFilter: options.logFilter, + }), + cleanupScript: yield* renderGcpCleanupScript(target), + } + }) + + const deleteConnector = Effect.fn("GcpConnectorService.delete")(function* ( + orgId: OrgId, + connectorId: GcpConnectorId, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.gcp.connector_id": connectorId }) + const [row] = yield* dbExecute((db) => + db.run( + PG.deleteFrom(GcpConnectors) + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectorId)]) + .returning(), + ), + ) + if (row === undefined) return yield* notFound() + return { + connector: toConnector(row), + // The connector is gone: its script has nothing to report to and carries no secret. + cleanupScript: yield* renderGcpCleanupScript({ + connectorId, + scopeType: row.scopeType, + scopeId: row.scopeId, + projectId: row.projectId, + mapleUrl, + pushEndpoint: undefined, + }), + } + }) + + return { + status, + create, + update, + scripts, + delete: deleteConnector, + } satisfies GcpConnectorServiceApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts new file mode 100644 index 0000000000..caf12e11c7 --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts @@ -0,0 +1,1292 @@ +import { spawnSync } from "node:child_process" +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" +import { Effect, Schema } from "effect" +import { GcpConnectorId, GcpProjectId, GcpResourceNumber } from "@maple/domain/primitives" +import { + gcpLogFilter, + renderGcpCleanupScript, + renderGcpSetupScript, + type GcpScriptTarget, + type GcpSetupScriptInput, +} from "./setup-scripts" + +const connectorId = Schema.decodeUnknownSync(GcpConnectorId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") +const hostProject = Schema.decodeUnknownSync(GcpProjectId)("acme-host") +const resourceNumber = Schema.decodeUnknownSync(GcpResourceNumber)("123456789012") +const NAME = "maple-018f2b3c4d5e4f708192a3b4" +const ACCOUNT = `${NAME}@acme-host.iam.gserviceaccount.com` +const SECRET = "maple_gcp_s3cr3t-value" +const WRITER = "serviceAccount:service-1@gcp-sa-logging.iam.gserviceaccount.com" +const MAPLE_ACCOUNT = "collector@maple-prod.iam.gserviceaccount.com" +const PUSH_ENDPOINT = `https://ingest.test/v1/logpush/gcp/${connectorId}?secret=${SECRET}` +const MAPLE_URL = "https://app.maple.test/integrations?integration=gcp" + +const target = (scopeType: GcpScriptTarget["scopeType"]): GcpScriptTarget => ({ + connectorId, + scopeType, + scopeId: scopeType === "project" ? hostProject : resourceNumber, + projectId: hostProject, + mapleUrl: MAPLE_URL, +}) + +/** Each scope with the sink flag and IAM command the script must use for it. */ +const SCOPES = { + project: { + target: target("project"), + sink: "--project=acme-host", + sinkWrite: "--project=acme-host", + iam: "projects", + id: "acme-host", + open: [], + found: [], + }, + folder: { + target: target("folder"), + sink: "--folder=123456789012", + sinkWrite: "--folder=123456789012 --include-children", + iam: "resource-manager folders", + id: "123456789012", + open: ["resource-manager folders describe 123456789012 --format=value(displayName)"], + found: [" ✓ Folder 123456789012 found (acme.com)"], + }, + organization: { + target: target("organization"), + sink: "--organization=123456789012", + sinkWrite: "--organization=123456789012 --include-children", + iam: "organizations", + id: "123456789012", + open: ["organizations describe 123456789012 --format=value(displayName)"], + found: [" ✓ Organization 123456789012 found (acme.com)"], + }, +} as const + +type Scope = (typeof SCOPES)[keyof typeof SCOPES] +const scopeTypes = ["project", "folder", "organization"] as const + +const setupInput = ( + scope: keyof typeof SCOPES, + capabilities: { logs: boolean; metrics: boolean }, +): GcpSetupScriptInput => ({ + ...SCOPES[scope].target, + pushEndpoint: PUSH_ENDPOINT, + mapleServiceAccountEmail: MAPLE_ACCOUNT, + logsEnabled: capabilities.logs, + metricsEnabled: capabilities.metrics, + logFilter: "keep", +}) + +const setup = (input: GcpSetupScriptInput) => Effect.runSync(renderGcpSetupScript(input)) +const cleanup = (scope: Scope, pushEndpoint?: string) => + Effect.runSync(renderGcpCleanupScript({ ...scope.target, pushEndpoint })) + +// A `gcloud` that records its commands, and each argument on its own line so a value split by +// bad quoting shows. It talks on stderr like the real one, which a script must not pass on. +// `describe` answers as the test says: found, missing, or denied, and so does the service account +// list (a missing account is an empty list). `GCLOUD_FAIL` refuses the commands that contain it, +// in the words of `GCLOUD_ANSWER` when the test gives some. +const FAKE_GCLOUD = `#!/usr/bin/env bash +echo "$*" >> "$GCLOUD_LOG" +printf "%s\\n" "$@" >> "$GCLOUD_LOG.args" +denied() { + echo "\${GCLOUD_ANSWER:-ERROR: (gcloud) PERMISSION_DENIED: Permission denied on resource (or it may not exist).}" >&2 + exit 1 +} +# For a disabled API gcloud asks whether to enable it, unless prompts are off. +api_off() { + if [ "$CLOUDSDK_CORE_DISABLE_PROMPTS" != 1 ]; then + echo "API not enabled on project [1]. Would you like to enable and retry? (y/N)" >&2 + exit 1 + fi + echo "ERROR: (gcloud) API has not been used in project 1 before or it is disabled" >&2 + exit 1 +} +echo "$CLOUDSDK_CORE_PROJECT" >> "$GCLOUD_LOG.project" +echo "$CLOUDSDK_CORE_DISABLE_FILE_LOGGING" >> "$GCLOUD_LOG.filelogging" +if [ -n "$GCLOUD_FAIL" ] && [[ "$*" == *"$GCLOUD_FAIL"* ]]; then denied; fi +# GCLOUD_RACE: the first command that contains it loses a race with another policy change. +if [ -n "$GCLOUD_RACE" ] && [[ "$*" == *"$GCLOUD_RACE"* ]] && [ ! -e "$GCLOUD_LOG.raced" ]; then + touch "$GCLOUD_LOG.raced" + echo "ERROR: (gcloud) ABORTED: There were concurrent policy changes. Please retry the whole read-modify-write with exponential backoff." >&2 + exit 1 +fi +case "$*" in + "version"*) echo "$GCLOUD_VERSION" ;; + "config get-value account") echo "$GCLOUD_ACCOUNT" ;; + "config get-value auth/impersonate_service_account") echo "$GCLOUD_IMPERSONATE" ;; + "auth print-access-token") echo "ya29.token" ;; + "projects describe"*) echo "Acme Production" ;; + "organizations describe"* | "resource-manager folders describe"*) echo "acme.com" ;; + "billing projects describe"*) echo "$GCLOUD_BILLING" ;; + "services list"*) echo "$GCLOUD_APIS" ;; + *"sinks describe"*"--format"*) echo "WARNING: gcloud says something on stderr." >&2; echo "${WRITER}" ;; + *"service-accounts list"*) + case "$GCLOUD_DESCRIBE" in + found) echo "${ACCOUNT}" ;; + missing) echo "Listed 0 items." >&2 ;; + denied) denied ;; + api_off) api_off ;; + esac ;; + *"subscriptions describe"*"--format=value(topic)") + case "$GCLOUD_DESCRIBE" in + found) echo "\${GCLOUD_TOPIC:-projects/acme-host/topics/${NAME}}" ;; + missing) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; + denied) denied ;; + api_off) api_off ;; + esac ;; + *"subscriptions describe"*"--format=value(detached)") echo "\${GCLOUD_DETACHED:-False}" ;; + *" describe "*) + case "$GCLOUD_DESCRIBE" in + missing) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; + denied) denied ;; + api_off) api_off ;; + esac ;; + *"remove-iam-policy-binding"*) + case "$GCLOUD_UNBIND" in + absent) echo "ERROR: Policy binding with the specified principal, role, and condition not found!" >&2; exit 1 ;; + denied) denied ;; + esac ;; + *"sinks create"* | *"sinks update"*) + echo "Please remember to grant the writer the Pub/Sub Publisher role on the topic." >&2 ;; + *) echo "Operation \\"operations/acat.p2-1\\" finished successfully." >&2 ;; +esac +` + +// A service account an earlier run deleted: what Google answers for 30 days, even to an Owner. +const FAKE_GCLOUD_ACCOUNT_DELETED = `#!/usr/bin/env bash +echo "$*" >> "$GCLOUD_LOG" +case "$*" in + "config get-value account") echo jane@acme.com ;; + "auth print-access-token") echo ya29.token ;; + "projects describe"*) echo "Acme Production" ;; + "resource-manager folders describe"*) echo "acme.com" ;; + "billing projects describe"*) echo True ;; + *"sinks describe"*"--format"*) echo "${WRITER}" ;; + *"service-accounts describe"*) echo "ERROR: (gcloud) PERMISSION_DENIED: Permission 'iam.serviceAccounts.get' denied on resource (or it may not exist)." >&2; exit 1 ;; + *"service-accounts list"*) echo "Listed 0 items." >&2 ;; + *" describe "*) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; +esac +` + +// A `curl` for the two calls a script makes. The permission test answers with the permissions it +// was asked about, less \`CURL_LACKING\`; the report to Maple is recorded with its body. +const FAKE_CURL = `#!/usr/bin/env bash +url="\${!#}" +while [ $# -gt 0 ]; do + case "$1" in + --data) data="$2" ;; + @*) header="$(cat "\${1#@}")" ;; + esac + shift +done +case "$url" in + *testIamPermissions) + echo "$url $header" >> "$CURL_LOG" + # With -w, the status follows the body on a line of its own. + [ "$CURL_IAM" != unanswered ] || exit 7 + if [ "$CURL_IAM" = unseen ]; then printf '{"error":{"status":"PERMISSION_DENIED"}}\n403'; exit 0; fi + if [ "$CURL_IAM" = nothing ]; then printf '{}\n200'; exit 0; fi + lacking="\\"$CURL_LACKING\\"" + printf '%s\n200' "\${data//$lacking/}" ;; + *) + echo "$url $data" >> "$CURL_LOG" + [ "$CURL_REPORT" = ok ] || exit 22 ;; +esac +` + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +interface RunOptions { + describe?: "found" | "missing" | "denied" | "api_off" + unbind?: "ok" | "absent" | "denied" + gcloud?: string + /** A substring of the gcloud commands to refuse, and Google's words for it. */ + fail?: string + answer?: string + account?: string + billing?: "True" | "False" + /** The APIs that are on in the host project. None unless the test says so. */ + apis?: ReadonlyArray + /** + * A permission the account does not hold, `everything` for an account without any, + * `unseen` for a resource it can't see (Google answers 403), or `unanswered` when Google + * cannot be asked. + */ + lacking?: string + report?: "ok" | "unreachable" + /** Values the fake gcloud reads: GCLOUD_VERSION, GCLOUD_IMPERSONATE, GCLOUD_RACE, GCLOUD_TOPIC… */ + env?: Readonly> + /** Extra arguments for the shell the text is fed to, and text typed after it. */ + shell?: ReadonlyArray + then?: string +} + +/** Feed a rendered script to bash against the fakes; returns what it printed and issued. */ +const run = (script: string, options: RunOptions = {}) => { + const dir = mkdtempSync(join(tmpdir(), "maple-gcp-script-")) + tempDirs.push(dir) + const fakes = { gcloud: options.gcloud ?? FAKE_GCLOUD, curl: FAKE_CURL, sleep: "#!/usr/bin/env bash\n" } + for (const [name, source] of Object.entries(fakes)) { + writeFileSync(join(dir, name), source) + chmodSync(join(dir, name), 0o755) + } + const read = (file: string) => (existsSync(file) ? readFileSync(file, "utf8").trim().split("\n") : []) + const log = join(dir, "gcloud.log") + const result = spawnSync("bash", options.shell ?? [], { + input: script + (options.then ?? ""), + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + GCLOUD_LOG: log, + GCLOUD_DESCRIBE: options.describe ?? "missing", + GCLOUD_UNBIND: options.unbind ?? "ok", + GCLOUD_FAIL: options.fail ?? "", + GCLOUD_ANSWER: options.answer ?? "", + GCLOUD_ACCOUNT: options.account ?? "jane@acme.com", + GCLOUD_BILLING: options.billing ?? "True", + GCLOUD_APIS: (options.apis ?? []).join("\n"), + CURL_LOG: join(dir, "curl.log"), + CURL_IAM: + options.lacking === "unanswered" || options.lacking === "unseen" + ? options.lacking + : options.lacking === "everything" + ? "nothing" + : "ok", + CURL_LACKING: options.lacking ?? "", + CURL_REPORT: options.report ?? "ok", + ...options.env, + }, + }) + return { + dir, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + /** Every gcloud command but the access checks, which each test of them names itself. */ + commands: read(log).filter((command) => !isAccessCheck(command)), + access: read(log).filter(isAccessCheck), + args: read(`${log}.args`), + /** The project each gcloud call was counted against. */ + projects: read(`${log}.project`), + /** Whether each gcloud call ran with its file log off. */ + fileLogging: read(`${log}.filelogging`), + curl: read(join(dir, "curl.log")), + } +} + +const API_LOOKUP = "services list --enabled --project=acme-host --format=value(config.name)" +// Tried before anything is read, for a caller whose gcloud calls are counted against this project. +const RESOURCE_MANAGER_ON = "services enable cloudresourcemanager.googleapis.com --project=acme-host" + +const isAccessCheck = (command: string) => + command === API_LOOKUP || + command === RESOURCE_MANAGER_ON || + /^(version|config get-value|auth print-access-token|projects describe|organizations describe|resource-manager folders describe|billing projects describe|logging read)/.test( + command, + ) + +const REPORT = (members: string) => + new RegExp( + `^\\{"logName":"projects/acme-host/logs/maple-setup","timestamp":"\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ","jsonPayload":\\{${members}\\}\\}$`, + ) + +const publish = (members: string) => + expect.stringMatching( + new RegExp( + `^pubsub topics publish ${NAME} --project=acme-host --message=${REPORT(members).source.slice(1)}`, + ), + ) + +const logsSetupCommands = (scope: Scope) => [ + // The last access check: whether the sink exists decides whether the filter is tried out. + `logging sinks describe ${NAME} ${scope.sink}`, + "services enable pubsub.googleapis.com logging.googleapis.com --project=acme-host", + `pubsub topics describe ${NAME} --project=acme-host`, + `pubsub topics create ${NAME} --project=acme-host`, + `pubsub subscriptions describe ${NAME} --project=acme-host --format=value(topic)`, + `pubsub subscriptions create ${NAME} --topic=${NAME} --project=acme-host --push-endpoint=${PUSH_ENDPOINT} --push-no-wrapper --ack-deadline=30 --message-retention-duration=1d --expiration-period=never --min-retry-delay=10s --max-retry-delay=600s`, + `logging sinks create ${NAME} pubsub.googleapis.com/projects/acme-host/topics/${NAME} --log-filter=${gcpLogFilter(false)} ${scope.sinkWrite}`, + `logging sinks describe ${NAME} ${scope.sink} --format=value(writerIdentity)`, + `projects add-iam-policy-binding acme-host --member=${WRITER} --role=roles/logging.logWriter --condition=None`, + `pubsub topics add-iam-policy-binding ${NAME} --project=acme-host --member=${WRITER} --role=roles/pubsub.publisher`, + publish('"logs":true'), +] + +const metricsSetupCommands = (scope: Scope) => [ + "services enable monitoring.googleapis.com cloudasset.googleapis.com iam.googleapis.com iamcredentials.googleapis.com --project=acme-host", + `iam service-accounts describe ${ACCOUNT} --project=acme-host`, + `iam service-accounts create ${NAME} --project=acme-host --display-name=Maple metrics and resource reader`, + `${scope.iam} add-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/monitoring.viewer --condition=None`, + `${scope.iam} add-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/cloudasset.viewer --condition=None`, + `projects add-iam-policy-binding acme-host --member=serviceAccount:${ACCOUNT} --role=roles/serviceusage.serviceUsageConsumer --condition=None`, + `iam service-accounts add-iam-policy-binding ${ACCOUNT} --project=acme-host --member=serviceAccount:${MAPLE_ACCOUNT} --role=roles/iam.serviceAccountTokenCreator --condition=None`, +] + +const logsRemovalCommands = (scope: Scope) => [ + `logging sinks describe ${NAME} ${scope.sink}`, + `logging sinks delete ${NAME} ${scope.sink} --quiet`, + `pubsub subscriptions describe ${NAME} --project=acme-host`, + `pubsub subscriptions delete ${NAME} --project=acme-host --quiet`, + `pubsub topics describe ${NAME} --project=acme-host`, + `pubsub topics delete ${NAME} --project=acme-host --quiet`, +] + +const ACCOUNT_LOOKUP = `iam service-accounts list --project=acme-host --filter=email=${ACCOUNT} --format=value(email)` + +/** A command that only looks: a describe, or the service account list. */ +const isLookup = (command: string) => command.includes(" describe ") || command === ACCOUNT_LOOKUP + +const metricsRemovalCommands = (scope: Scope) => [ + ACCOUNT_LOOKUP, + `${scope.iam} remove-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/monitoring.viewer --all`, + `${scope.iam} remove-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/cloudasset.viewer --all`, + `projects remove-iam-policy-binding acme-host --member=serviceAccount:${ACCOUNT} --role=roles/serviceusage.serviceUsageConsumer --all`, + `iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`, +] + +const UNFINISHED = "\nSetup did not finish. Nothing needs undoing: fix this and paste the script again.\n" + +describe.each(scopeTypes)("renderGcpSetupScript for a %s", (scopeType) => { + const scope = SCOPES[scopeType] + const host = scopeType === "project" ? "Project" : "Host project" + + it("sets up logs and metrics from nothing, at the right scope, one line per step", () => { + const { status, stdout, stderr, commands, access } = run( + setup(setupInput(scopeType, { logs: true, metrics: true })), + ) + expect(status).toBe(0) + expect(stderr).toBe("") + expect(commands).toEqual([ + ...logsSetupCommands(scope), + ...metricsSetupCommands(scope), + publish('"metrics":true'), + ]) + expect(access).toEqual([ + 'version --format=value("Google Cloud SDK")', + "config get-value account", + "auth print-access-token", + "config get-value auth/impersonate_service_account", + API_LOOKUP, + RESOURCE_MANAGER_ON, + ...scope.open, + "projects describe acme-host --format=value(name)", + "billing projects describe acme-host --format=value(billingEnabled)", + `logging read ${gcpLogFilter(false)} --limit=1 --freshness=1h ${scope.sink}`, + ]) + expect(stdout).toBe(`Maple setup for ${scopeType} ${scope.id} + Log forwarding on + Metrics and resources on + +Checking access + ✓ Signed in as jane@acme.com +${[...scope.found, ` ✓ ${host} acme-host found (Acme Production)`].join("\n")} + ✓ Billing is enabled + ✓ jane@acme.com has the permissions this script needs + ✓ Log filter accepted + +Log forwarding + ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ Topic created + ✓ Push subscription created + ✓ Log sink created + ✓ Sink allowed to write to the project + ✓ Sink allowed to publish to the topic + ✓ Check message sent to Maple through the topic + +Metrics and resources + ✓ APIs enabled (Cloud Monitoring, Cloud Asset, IAM, IAM Credentials, Cloud Resource Manager) + ✓ Read-only service account created + ✓ Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer) + ✓ Maple allowed to read as that account + ✓ Maple notified + +Done. Google Cloud is set up for Maple. + Maple confirms it within a minute: ${MAPLE_URL} + Logs: a new sink can take about 10 minutes to start forwarding. What is logged before + it does is not forwarded later. + Metrics: the first read lands within about 10 minutes. +`) + }) + + it("says host project only where the scope is not the project itself", () => { + for (const enabled of [true, false]) { + const script = setup(setupInput(scopeType, { logs: enabled, metrics: enabled })) + expect(/host project/i.test(script)).toBe(scopeType !== "project") + } + }) + + it("with logs only, sets up logs and removes an earlier metrics setup", () => { + const script = setup(setupInput(scopeType, { logs: true, metrics: false })) + expect(script).not.toContain(MAPLE_ACCOUNT) + const { status, stdout, commands, access } = run(script, { describe: "found" }) + expect(status).toBe(0) + // Everything exists already, so the log pipeline is updated in place, filter untouched. + expect(commands.filter((command) => command.includes(" create "))).toEqual([]) + expect(commands).toContain( + `logging sinks update ${NAME} pubsub.googleapis.com/projects/acme-host/topics/${NAME} --no-disabled ${scope.sinkWrite}`, + ) + expect(commands).toContainEqual( + expect.stringMatching(/^pubsub subscriptions update .*--push-no-wrapper/), + ) + expect(commands.slice(-metricsRemovalCommands(scope).length - 1)).toEqual([ + ...metricsRemovalCommands(scope), + publish('"metrics":false'), + ]) + // Billing only matters to Cloud Monitoring. + expect(access).not.toContainEqual(expect.stringContaining("billing")) + expect(stdout).toContain(` +Metrics and resources + ✓ Read-only roles removed + ✓ Read-only service account deleted + ✓ Maple notified + +Done. Google Cloud matches your Maple switches. + Maple confirms it within a minute: ${MAPLE_URL} +`) + }) + + it("with metrics only, removes an earlier log pipeline and reports without a topic", () => { + const script = setup(setupInput(scopeType, { logs: false, metrics: true })) + expect(script).not.toContain("LOG_FILTER") + const { status, stdout, commands, curl } = run(script, { describe: "found" }) + expect(status).toBe(0) + expect(commands.slice(0, logsRemovalCommands(scope).length)).toEqual(logsRemovalCommands(scope)) + expect(commands).not.toContainEqual(expect.stringContaining("pubsub.googleapis.com logging")) + expect(commands).not.toContainEqual(expect.stringContaining("topics publish")) + expect(commands).toContain( + `${scope.iam} add-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/cloudasset.viewer --condition=None`, + ) + expect(curl.filter((call) => call.startsWith(PUSH_ENDPOINT))).toEqual([ + expect.stringMatching(REPORT('"logs":false').source.slice(1)), + expect.stringMatching(REPORT('"metrics":true').source.slice(1)), + ]) + expect(stdout).toContain(` Log forwarding off, removing + Metrics and resources on +`) + expect(stdout).toContain(` +Log forwarding + ✓ Log sink deleted + … waiting a minute for Google to stop routing to the topic + ✓ Push subscription deleted + ✓ Topic deleted + ✓ Maple notified +`) + }) + + it("cleans up everything and tells Maple, and touches nothing when nothing exists", () => { + const script = cleanup(scope, PUSH_ENDPOINT) + const existing = run(script, { describe: "found" }) + expect(existing.status).toBe(0) + expect(existing.commands).toEqual([...logsRemovalCommands(scope), ...metricsRemovalCommands(scope)]) + expect(existing.curl).toEqual([ + expect.stringMatching(REPORT('"logs":false,"metrics":false').source.slice(1)), + ]) + expect(existing.stdout).toBe(`Maple cleanup for ${scopeType} ${scope.id} + +Checking access + ✓ Signed in as jane@acme.com +${[...scope.found, ` ✓ ${host} acme-host found (Acme Production)`].join("\n")} + +Log forwarding + ✓ Log sink deleted + … waiting a minute for Google to stop routing to the topic + ✓ Push subscription deleted + ✓ Topic deleted + +Metrics and resources + ✓ Read-only roles removed + ✓ Read-only service account deleted + +Done. Everything the setup script created is gone. + Left in place: the APIs it switched on, and the Logs Writer role of Google's logging + service account on acme-host, which other sinks share. + Now disconnect in Maple: ${MAPLE_URL} +`) + + const fresh = run(script) + expect(fresh.status).toBe(0) + expect(fresh.commands.every(isLookup)).toBe(true) + expect(fresh.stdout.match(/ {2}✓ Nothing left to remove/g)).toHaveLength(2) + }) +}) + +describe("the text that is copied", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + + it("is the script as a here-document for a child bash", () => { + const lines = script.split("\n") + expect(lines.slice(0, 3)).toEqual([ + ` { [ -z "\${ZSH_VERSION-}" ] || case "\${HISTORY_IGNORE-}" in *"*MAPLE_SETUP_SCRIPT*"*) ;; *) HISTORY_IGNORE="(\${HISTORY_IGNORE:+$HISTORY_IGNORE|}*MAPLE_SETUP_SCRIPT*)" ;; esac; [ -z "\${BASH_VERSION-}" ] || case "$(history 1)" in *MAPLE_SETUP_SCRIPT*) history -d -1 ;; esac 2>/dev/null`, + "bash /dev/fd/3 3<<'MAPLE_SETUP_SCRIPT'", + "#!/usr/bin/env bash", + ]) + expect(script.endsWith("\nMAPLE_SETUP_SCRIPT\n}\n")).toBe(true) + // An interactive zsh runs a comment as a command. + expect( + lines + .slice(0, 2) + .concat(lines.slice(-3)) + .some((line) => line.startsWith("#")), + ).toBe(false) + // Plain ASCII survives a paste under any locale; the marks it prints are spelled as bytes. + expect([...script].every((character) => character.charCodeAt(0) < 128)).toBe(true) + + const cleanupLines = cleanup(SCOPES.project).split("\n") + expect(cleanupLines[1]).toBe("bash /dev/fd/3 3<<'MAPLE_CLEANUP_SCRIPT'") + expect(cleanupLines.slice(-3)).toEqual(["MAPLE_CLEANUP_SCRIPT", "}", ""]) + }) + + it("leaves an interactive shell alive, with its options and the script's exit status", () => { + const { stdout } = run(script, { + fail: "sinks create", + shell: ["--norc", "--noprofile", "-i"], + // The next command to fail must not close the shell either. + then: "echo STATUS=$? ALIVE; false; echo AFTER=$?; set -o | grep -E 'errexit|nounset'; history | grep -c 'MAPLE_SETUP_SCRIP[T]'\n", + }) + expect(stdout).toContain(UNFINISHED) + expect(stdout).toContain("STATUS=1 ALIVE\nAFTER=1\n") + expect(stdout).toMatch(/errexit\s+off\nnounset\s+off/) + // bash 5 can name the running command's history entry; older ones keep the paste. + const bashMajor = Number( + spawnSync("bash", ["-c", "echo $BASH_VERSINFO"], { encoding: "utf8" }).stdout, + ) + if (bashMajor >= 5) expect(stdout.trim().endsWith("\n0")).toBe(true) + }) + + it("is refused when a value would end the here-document early", () => { + const hostile = { ...setupInput("project", { logs: true, metrics: true }) } + const result = Effect.runSync( + Effect.result( + renderGcpSetupScript({ + ...hostile, + mapleServiceAccountEmail: "x\nMAPLE_SETUP_SCRIPT\ntouch pwned", + }), + ), + ) + expect(result._tag).toBe("Failure") + + // A terminal ends a pasted line at a CR, and a TAB is completion. + for (const hostileUrl of [ + "https://x\rMAPLE_SETUP_SCRIPT\rtouch pwned", + "https://x\n\tMAPLE_SETUP_SCRIPT", + ]) { + const pasted = Effect.runSync( + Effect.result(renderGcpSetupScript({ ...hostile, mapleUrl: hostileUrl })), + ) + expect(pasted._tag).toBe("Failure") + } + }) +}) + +describe("renderGcpSetupScript", () => { + it("reads a re-run as already in place and keeps the sink's filter", () => { + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: true, metrics: true })), + { + describe: "found", + }, + ) + expect(status).toBe(0) + expect(commands.filter((command) => command.includes(" create "))).toEqual([]) + expect(commands).not.toContainEqual(expect.stringContaining("--log-filter")) + expect(stdout).toContain(` +Log forwarding + ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ Topic already exists + ✓ Push subscription up to date + ✓ Log sink up to date (filter kept) + ✓ Sink allowed to write to the project + ✓ Sink allowed to publish to the topic + ✓ Check message sent to Maple through the topic + +Metrics and resources + ✓ APIs enabled (Cloud Monitoring, Cloud Asset, IAM, IAM Credentials, Cloud Resource Manager) + ✓ Read-only service account already exists + ✓ Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer) + ✓ Maple allowed to read as that account + ✓ Maple notified + +Done. Everything is in place. + Maple confirms it within a minute: ${MAPLE_URL} +`) + expect(stdout).not.toMatch(/Logs: {4}|Metrics: /) + }) + + it("replaces the sink's filter only when asked to", () => { + const filtered = (logFilter: GcpSetupScriptInput["logFilter"]) => + setup({ ...setupInput("project", { logs: true, metrics: false }), logFilter }) + expect(filtered("keep")).toContain(`LOG_FILTER_MODE='keep'\nLOG_FILTER='${gcpLogFilter(false)}'`) + expect(filtered("default")).toContain(`LOG_FILTER_MODE='set'\nLOG_FILTER='${gcpLogFilter(false)}'`) + expect(filtered("include_gke_container_logs")).toContain( + `LOG_FILTER_MODE='set'\nLOG_FILTER='${gcpLogFilter(true)}'`, + ) + // The comment above the filter says which way GKE container logs went, and where to read why. + expect(filtered("default")).toContain( + `# Log filter in Maple, or delete AND NOT resource.type="k8s_container" from LOG_FILTER and make\n`, + ) + expect(filtered("include_gke_container_logs")).toContain( + "# GKE container logs are included, as chosen in Maple.", + ) + expect(filtered("include_gke_container_logs")).toContain( + "https://maple.dev/docs/integrations/gcp-opentelemetry\n", + ) + + const { stdout, commands } = run(filtered("include_gke_container_logs"), { describe: "found" }) + expect(stdout).toContain(" ✓ Log filter accepted\n") + expect(stdout).toContain(" ✓ Log sink up to date (filter replaced)") + // The closing line says what this run changed. + expect(stdout).not.toContain("Done. Everything is in place.") + expect(commands).toContain( + `logging sinks update ${NAME} pubsub.googleapis.com/projects/acme-host/topics/${NAME} --no-disabled --log-filter=${gcpLogFilter(true)} --project=acme-host`, + ) + }) + + it("shows Google's answer and what to do when a step is refused, and stops there", () => { + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: true, metrics: true })), + { + fail: "sinks create", + }, + ) + expect(status).toBe(1) + expect(commands.at(-1)).toContain("logging sinks create") + expect( + stdout.endsWith(` +Log forwarding + ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ Topic created + ✓ Push subscription created + ✗ Couldn't create the log sink. + + ERROR: (gcloud) PERMISSION_DENIED: Permission denied on resource (or it may not exist). + + What to do: jane@acme.com needs Logs Configuration Writer on project acme-host. +${UNFINISHED}`), + ).toBe(true) + }) + + it("prints Google's error without its machine-readable details", () => { + const { stdout } = run(setup(setupInput("project", { logs: true, metrics: true })), { + fail: "topics create", + answer: [ + "WARNING: This command is using service account impersonation.", + "ERROR: (gcloud.pubsub.topics.create) Failed to create topic: User not authorized to perform this action.", + "- '@type': type.googleapis.com/google.rpc.ErrorInfo", + " metadata:", + " troubleshooter_url: https://console.cloud.google.com/iam-admin/troubleshooter", + ].join("\n"), + }) + expect(stdout).toContain( + "User not authorized to perform this action.\n\n What to do: jane@acme.com needs Owner on project acme-host.", + ) + expect(stdout).not.toContain("troubleshooter_url") + }) + + it("reads Google's other refusals for what they are, and never prints the secret back", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + const propagating = run(script, { + fail: "topics create", + answer: "ERROR: (gcloud) PERMISSION_DENIED: Cloud Pub/Sub API has not been used in project 1 before or it is disabled.", + }) + expect(propagating.stdout).toContain( + "What to do: Google is still switching an API on, or the API is off. Wait a minute and paste the script again.", + ) + + const quoted = run(script, { + fail: "subscriptions create", + answer: `ERROR: (gcloud) INVALID_ARGUMENT: Invalid push endpoint given (endpoint=${PUSH_ENDPOINT}).`, + }) + expect(quoted.stdout).toContain("?secret=HIDDEN).") + expect(quoted.stdout).not.toContain(SECRET) + // However Google spells the endpoint back, escaped or encoded. + for (const spelled of [`secret\\u003d${SECRET}`, `secret%3D${SECRET}`, `'secret': '${SECRET}'`]) { + const answer = run(script, { + fail: "subscriptions create", + answer: `ERROR: (gcloud) INVALID_ARGUMENT: ${spelled}`, + }) + expect(answer.stdout).not.toContain(SECRET) + } + expect(quoted.stdout).toContain( + "What to do: Read Google's answer above. If it is unclear, send this output to support@maple.dev.", + ) + }) + + it("names the role of the scope for a refused grant there", () => { + const { stdout } = run(setup(setupInput("organization", { logs: false, metrics: true })), { + fail: "organizations add-iam-policy-binding", + }) + expect(stdout).toContain(` ✗ Couldn't grant the read-only roles.`) + expect(stdout).toContain( + "What to do: jane@acme.com needs Organization Administrator on organization 123456789012.", + ) + }) + + it("changes nothing when the project cannot be opened", () => { + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: true, metrics: true })), + { + fail: "projects describe", + }, + ) + expect(status).toBe(1) + expect(commands).toEqual([]) + expect(stdout).toContain(` +Checking access + ✓ Signed in as jane@acme.com + ✗ Can't open project acme-host as jane@acme.com. + + ERROR: (gcloud) PERMISSION_DENIED: Permission denied on resource (or it may not exist). + + What to do: Check the ID in Maple (it is the project ID, not the name or number; list yours with: gcloud projects list). If it is wrong, remove the connection in Maple and connect the right ID: a connection's ID can't be changed. If it is right, this account has no access to the project. +${UNFINISHED}`) + }) + + it("needs the organization to open for metrics, and leaves that to the permissions for logs alone", () => { + const options = { fail: "organizations describe" } + const both = run(setup(setupInput("organization", { logs: true, metrics: true })), options) + expect(both.status).toBe(1) + expect(both.commands).toEqual([]) + expect(both.stdout).toContain(" ✗ Can't open organization 123456789012 as jane@acme.com.") + expect(both.stdout).toContain( + "What to do: Check the ID with: gcloud organizations list. If it is wrong, remove the connection in Maple and connect the right ID: a connection's ID can't be changed. If it is right, this account needs Organization Administrator there.", + ) + + // Logs Configuration Writer does not include reading the organization. + const logsOnly = run(setup(setupInput("organization", { logs: true, metrics: false })), options) + expect(logsOnly.status).toBe(0) + expect(logsOnly.stdout).not.toContain("Organization 123456789012 found") + }) + + it("switches on only the APIs that are off, so a caller who may not switch any on can still run it", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + const all = [ + "pubsub.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "cloudasset.googleapis.com", + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "cloudresourcemanager.googleapis.com", + ] + const allOn = run(script, { apis: all, fail: "services enable" }) + expect(allOn.status).toBe(0) + expect(allOn.commands).not.toContainEqual(expect.stringContaining("services enable")) + expect(allOn.access).not.toContain(RESOURCE_MANAGER_ON) + expect(allOn.stdout).toContain(" ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager)\n") + + // One is off: only that one is switched on, and a refusal names it and the role. + const oneOff = run(script, { apis: all.filter((api) => api !== "iam.googleapis.com") }) + expect(oneOff.commands.filter((command) => command.startsWith("services enable"))).toEqual([ + "services enable iam.googleapis.com --project=acme-host", + ]) + const refused = run(script, { + apis: all.filter((api) => api !== "pubsub.googleapis.com"), + fail: "services enable", + }) + expect(refused.status).toBe(1) + expect(refused.stdout).toContain(` ✗ Couldn't switch on: pubsub.googleapis.com.`) + expect(refused.stdout).toContain( + "What to do: jane@acme.com needs Service Usage Admin on project acme-host.", + ) + }) + + it("stops before the first change without billing, a sign-in, or gcloud", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + const unbilled = run(script, { billing: "False" }) + expect(unbilled.commands).toEqual([]) + expect(unbilled.stdout).toContain(` ✗ Project acme-host has no billing account. + + What to do: Cloud Monitoring only answers for projects with billing. Link one at https://console.cloud.google.com/billing/linkedaccount?project=acme-host +${UNFINISHED}`) + + const signedOut = run(script, { account: "" }) + expect(signedOut.commands).toEqual([]) + expect(signedOut.stdout).toContain(` ✗ You are not signed in to Google Cloud. + + What to do: In Cloud Shell, click Authorize when it asks. Elsewhere, run: gcloud auth login +`) + + // The shell the text is fed to still has to find bash. + const bash = spawnSync("bash", ["-c", "command -v bash"], { encoding: "utf8" }).stdout.trim() + const dir = mkdtempSync(join(tmpdir(), "maple-gcp-bin-")) + tempDirs.push(dir) + symlinkSync(bash, join(dir, "bash")) + const bare = spawnSync(bash, [], { input: script, encoding: "utf8", env: { PATH: dir } }) + expect(bare.status).toBe(1) + expect(bare.stdout).toContain(` ✗ gcloud is not installed here. + + What to do: Run this script in Cloud Shell: https://shell.cloud.google.com +`) + }) + + it("tries the log filter out before anything is created, when the script is going to set it", () => { + const unparseable = + "ERROR: (gcloud.logging.read) INVALID_ARGUMENT: Unparseable filter: syntax error at line 1, column 16, token ' '" + const script = setup(setupInput("project", { logs: true, metrics: false })) + const refused = run(script, { fail: "logging read", answer: unparseable }) + expect(refused.status).toBe(1) + expect(refused.commands).toEqual([`logging sinks describe ${NAME} --project=acme-host`]) + expect(refused.stdout).toContain(` ✗ Google does not accept the log filter. + + ${unparseable} + + What to do: Google does not accept the log filter. Correct the filter near the top of the script and paste it again. Filter syntax: https://cloud.google.com/logging/docs/view/logging-query-language +`) + + // Someone who may not read logs can still set the sink up. + const unreadable = run(script, { fail: "logging read" }) + expect(unreadable.status).toBe(0) + expect(unreadable.stdout).not.toContain("Log filter accepted") + + // An existing sink that keeps its filter has none to try out. + const kept = run(script, { describe: "found", fail: "logging read", answer: unparseable }) + expect(kept.status).toBe(0) + expect(kept.access).not.toContainEqual(expect.stringContaining("logging read")) + }) + + it("stops in the access check when an API is off and the account may not switch it on", () => { + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: true, metrics: true })), + { + apis: [ + "pubsub.googleapis.com", + "logging.googleapis.com", + "cloudresourcemanager.googleapis.com", + ], + lacking: "serviceusage.services.enable", + }, + ) + expect(status).toBe(1) + expect(commands).toEqual([]) + expect(stdout) + .toContain(` ✗ jane@acme.com is missing permissions on project acme-host: serviceusage.services.enable. + + What to do: This script has to switch on: monitoring.googleapis.com cloudasset.googleapis.com iam.googleapis.com iamcredentials.googleapis.com. Ask for Service Usage Admin there, or have an administrator switch them on. +`) + }) + + it("reads an account without any of the permissions as a wrong ID first", () => { + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: true, metrics: true })), + { lacking: "everything" }, + ) + expect(status).toBe(1) + expect(commands).toEqual([]) + expect(stdout) + .toContain(` ✗ jane@acme.com has none of the permissions this script needs on project acme-host. + + What to do: Check the ID in Maple. If it is wrong, remove the connection in Maple and connect the right ID: a connection's ID can't be changed. If it is right, this account has no rights there. Ask for Owner there, or have an administrator run this script. +`) + expect(stdout).not.toContain("pubsub.topics.create") + }) + + it("names a missing permission before the first change", () => { + const script = setup(setupInput("organization", { logs: true, metrics: true })) + const onHost = run(script, { lacking: "resourcemanager.projects.setIamPolicy" }) + expect(onHost.status).toBe(1) + expect(onHost.commands).toEqual([]) + expect(onHost.stdout) + .toContain(` ✗ jane@acme.com is missing permissions on project acme-host: resourcemanager.projects.setIamPolicy. + + What to do: Ask for Owner there, or have an administrator run this script. +`) + expect(onHost.curl).toEqual([ + "https://cloudresourcemanager.googleapis.com/v3/projects/acme-host:testIamPermissions Authorization: Bearer ya29.token", + ]) + + const onScope = run(script, { lacking: "logging.sinks.create" }) + expect(onScope.commands).toEqual([]) + expect(onScope.stdout) + .toContain(` ✗ jane@acme.com is missing permissions on organization 123456789012: logging.sinks.create. + + What to do: Ask for Logs Configuration Writer and Organization Administrator there, or have an administrator run this script. If the ID looks wrong, check it with: gcloud organizations list +`) + expect(onScope.curl.at(-1)).toContain("/v3/organizations/123456789012:testIamPermissions") + + // No answer is not a refusal: the steps report for themselves. + const unanswered = run(script, { lacking: "unanswered" }) + expect(unanswered.status).toBe(0) + expect(unanswered.stdout).not.toContain("has the permissions") + }) + + it("reads a folder this account can't see as a wrong ID, before the first change", () => { + const { status, stdout, commands } = run( + setup(setupInput("folder", { logs: true, metrics: false })), + { + lacking: "unseen", + }, + ) + expect(status).toBe(1) + expect(commands).toEqual([]) + expect(stdout).toContain(` ✗ jane@acme.com can't see project acme-host. + + What to do: Check the ID in Maple.`) + }) + + it("says what it could not check instead of passing over it", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + const { status, stdout } = run(script, { lacking: "unanswered", billing: "" as "True" }) + expect(status).toBe(0) + expect(stdout).toContain( + " • Couldn't tell whether billing is on. Cloud Monitoring only answers for projects with billing.\n", + ) + expect(stdout).toContain( + " • Couldn't ask Google which permissions jane@acme.com holds. A step it may not take says so.\n", + ) + }) + + it("doesn't call a resource gone when its API is off, in the cleanup", () => { + const hostOff = `#!/usr/bin/env bash +echo "$*" >> "$GCLOUD_LOG" +case "$*" in + "config get-value account") echo jane@acme.com ;; + "auth print-access-token") echo ya29.token ;; + "projects describe"*) echo "Acme Production" ;; + *"sinks describe"*) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; + *) echo "ERROR: (gcloud) API has not been used in project 1 before or it is disabled" >&2; exit 1 ;; +esac +` + const { stdout } = run(cleanup(SCOPES.project), { gcloud: hostOff }) + expect(stdout).toContain( + " • Couldn't look for the read-only service account: its API is off in project acme-host. If a setup run made one, switch the API on and paste this again.\n", + ) + expect(stdout).toContain("\nDone, but not all of it could be checked: see above.\n") + expect(stdout).not.toContain("Everything the setup script created is gone") + + // A setup run with the API off has made nothing there, so it has nothing to say. + const setupRun = run(setup(setupInput("project", { logs: true, metrics: false })), { + gcloud: hostOff, + }) + expect(setupRun.stdout).not.toContain("its API is off") + }) + + it("takes a resource that is gone by the time it is deleted for removed", () => { + const { status, stdout } = run(cleanup(SCOPES.project), { + describe: "found", + fail: "topics delete", + answer: "ERROR: (gcloud.pubsub.topics.delete) NOT_FOUND: Resource not found (resource=maple).", + }) + expect(status).toBe(0) + expect(stdout).toContain(" ✓ Topic deleted\n") + }) + + it("ends between two steps on Ctrl-C and says a re-run picks up from there", () => { + const script = setup(setupInput("project", { logs: true, metrics: true })) + // The signal lands where a step has just finished. + const marker = '\nsection "Log forwarding"\n' + expect(script).toContain(marker) + const { status, stdout } = run(script.replace(marker, () => `\nkill -INT $$${marker}`)) + expect(status).toBe(130) + expect(stdout).toContain( + "\n\nSetup interrupted. Nothing needs undoing: paste the script again to finish it.\n", + ) + expect(stdout).not.toContain("Topic created") + }) + + it("finishes when Maple cannot be reached, and does not claim a confirmation", () => { + const { status, stdout } = run(setup(setupInput("project", { logs: false, metrics: true })), { + report: "unreachable", + }) + expect(status).toBe(0) + expect(stdout).toContain( + " • Couldn't reach Maple to confirm. Maple shows the connection as pending until a later run reaches it.\n", + ) + expect(stdout).toContain("Done. Google Cloud is set up for Maple.") + expect(stdout).not.toContain("Maple confirms it") + + const unpublished = run(setup(setupInput("project", { logs: true, metrics: true })), { + fail: "topics publish", + }) + expect(unpublished.status).toBe(0) + expect(unpublished.stdout).not.toContain("Maple confirms it") + }) + + it("waits for a new service account only while Google says it does not exist", () => { + // The first two grants are answered as for an account IAM has not heard of yet. + const slow = `#!/usr/bin/env bash +echo "$*" >> "$GCLOUD_LOG" +case "$*" in + "config get-value account") echo jane@acme.com ;; + "auth print-access-token") echo ya29.token ;; + "projects describe"*) echo "Acme Production" ;; + "billing projects describe"*) echo True ;; + *" describe "*) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; + *"add-iam-policy-binding"*) + if [ "$(grep -c add-iam-policy-binding "$GCLOUD_LOG")" -le 2 ]; then + echo "ERROR: (gcloud) INVALID_ARGUMENT: Service account ${ACCOUNT} does not exist." >&2 + exit 1 + fi ;; +esac +` + const { status, stdout, commands } = run( + setup(setupInput("project", { logs: false, metrics: true })), + { gcloud: slow }, + ) + expect(status).toBe(0) + expect(stdout.match(/ {2}… waiting for Google to publish the new service account\n/g)).toHaveLength(1) + expect(commands.filter((command) => command.includes("roles/monitoring.viewer"))).toHaveLength(3) + }) + + it("leaves an enabled metrics setup alone when the deployment has lost its Google identity", () => { + const script = setup({ + ...setupInput("organization", { logs: true, metrics: true }), + mapleServiceAccountEmail: undefined, + }) + expect(script).toContain( + "# Metrics and resources: not available on this Maple deployment, left as is\n", + ) + const { status, stdout, commands } = run(script, { describe: "found" }) + expect(status).toBe(0) + expect(stdout).toContain( + " Metrics and resources not available on this Maple deployment, left as is\n", + ) + expect(stdout).not.toContain("\nMetrics and resources\n") + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(commands).not.toContainEqual(expect.stringContaining("remove-iam-policy-binding")) + }) + + it("still deletes the service account when one of its role bindings is already gone", () => { + const script = cleanup(SCOPES.folder) + const absent = run(script, { describe: "found", unbind: "absent" }) + expect(absent.commands.at(-1)).toBe( + `iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`, + ) + expect(absent.status).toBe(0) + + // A binding that could not be removed keeps the account, so a later run can still name it. + const denied = run(script, { describe: "found", unbind: "denied" }) + expect(denied.commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + expect(denied.status).toBe(1) + expect(denied.stdout).toContain(` ✗ Couldn't remove the read-only roles.`) + expect(denied.stdout).toContain( + "What to do: jane@acme.com needs Folder IAM Admin on folder 123456789012.", + ) + expect(denied.stdout).toContain( + "\nCleanup did not finish. Fix this and paste the script again: it continues where it stopped.\n", + ) + + // The host project's binding is the Owner's to remove, and only gcloud's own "not found" + // means a binding is gone. + const hostDenied = run(script, { describe: "found", fail: "projects remove-iam-policy-binding" }) + expect(hostDenied.stdout).toContain("What to do: jane@acme.com needs Owner on project acme-host.") + const unknown = run(script, { + describe: "found", + fail: "remove-iam-policy-binding", + answer: "ERROR: (gcloud) NOT_FOUND: Requested entity was not found.", + }) + expect(unknown.commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + expect(unknown.status).toBe(1) + }) + + it("keeps the topic when it cannot tell whether the sink is gone", () => { + const { status, stdout, commands } = run(cleanup(SCOPES.organization), { fail: "sinks describe" }) + expect(commands).not.toContainEqual(expect.stringContaining("pubsub")) + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(status).toBe(1) + expect(stdout).toContain(` ✗ Couldn't check whether the log sink still exists.`) + expect(stdout).toContain( + "What to do: jane@acme.com needs Logs Configuration Writer on organization 123456789012.", + ) + }) + + it("does not take a resource it may not look at for one that is gone", () => { + const removal = run(cleanup(SCOPES.organization), { describe: "denied" }) + expect(removal.commands).toEqual([`logging sinks describe ${NAME} --organization=123456789012`]) + expect(removal.stdout).toContain("PERMISSION_DENIED") + expect(removal.status).toBe(1) + + // Setting up still works: a failed look falls through to the create, which reports for itself. + const created = run(setup(setupInput("organization", { logs: true, metrics: true })), { + describe: "denied", + }) + expect(created.status).toBe(0) + }) + + it("reads a disabled API as nothing to remove in the host project, but not for the sink", () => { + const sinkUnknown = run(cleanup(SCOPES.organization), { describe: "api_off" }) + expect(sinkUnknown.commands).toEqual([`logging sinks describe ${NAME} --organization=123456789012`]) + expect(sinkUnknown.stdout).toContain("has not been used") + expect(sinkUnknown.status).toBe(1) + + // With the sink known to be gone, the host project's disabled APIs hold nothing. + const hostOff = `#!/usr/bin/env bash +echo "$*" >> "$GCLOUD_LOG" +case "$*" in + "config get-value account") echo jane@acme.com ;; + "auth print-access-token") echo ya29.token ;; + "projects describe"*) echo "Acme Production" ;; + *"sinks describe"*) echo "ERROR: (gcloud) NOT_FOUND: Resource not found" >&2; exit 1 ;; + *) echo "ERROR: (gcloud) API has not been used in project 1 before or it is disabled" >&2; exit 1 ;; +esac +` + const { status, stdout, commands } = run(cleanup(SCOPES.project), { gcloud: hostOff }) + expect(status).toBe(0) + expect(commands.every(isLookup)).toBe(true) + expect(stdout.match(/ {2}✓ Nothing left to remove/g)).toHaveLength(2) + }) + + it("finishes when the service account was deleted by an earlier run", () => { + const removal = run(cleanup(SCOPES.project), { gcloud: FAKE_GCLOUD_ACCOUNT_DELETED }) + expect(removal.stdout).toContain("Done. Everything the setup script created is gone.") + expect(removal.status).toBe(0) + expect(removal.commands.every(isLookup)).toBe(true) + + // So does a setup script with metrics off, run a second time. + const metricsOff = run(setup(setupInput("project", { logs: true, metrics: false })), { + gcloud: FAKE_GCLOUD_ACCOUNT_DELETED, + }) + expect(metricsOff.status).toBe(0) + expect(metricsOff.commands.slice(-2)).toEqual([ACCOUNT_LOOKUP, publish('"metrics":false')]) + }) + + it("creates the service account again after an earlier run deleted it", () => { + const { status, commands } = run(setup(setupInput("folder", { logs: false, metrics: true })), { + gcloud: FAKE_GCLOUD_ACCOUNT_DELETED, + }) + expect(status).toBe(0) + // The new account has none of the deleted one's roles: all four grants are made again. + expect(commands.slice(-metricsSetupCommands(SCOPES.folder).length)).toEqual( + metricsSetupCommands(SCOPES.folder), + ) + }) + + it("makes the push subscription anew when its topic was deleted or it was detached", () => { + const script = setup(setupInput("project", { logs: true, metrics: false })) + const recreated = [ + `pubsub subscriptions delete ${NAME} --project=acme-host --quiet`, + expect.stringMatching(`^pubsub subscriptions create ${NAME} --topic=${NAME} `), + ] + for (const env of [{ GCLOUD_TOPIC: "_deleted-topic_" }, { GCLOUD_DETACHED: "True" }]) { + const { status, stdout, commands } = run(script, { describe: "found", env }) + expect(status).toBe(0) + expect(commands.filter((command) => command.startsWith("pubsub subscriptions "))).toEqual([ + `pubsub subscriptions describe ${NAME} --project=acme-host --format=value(topic)`, + ...(env.GCLOUD_DETACHED === undefined + ? [] + : [`pubsub subscriptions describe ${NAME} --project=acme-host --format=value(detached)`]), + ...recreated, + ]) + expect(stdout).toContain( + " ✓ Push subscription cut off from the topic deleted\n ✓ Push subscription created\n", + ) + } + + // One still on the topic is updated in place. + const attached = run(script, { describe: "found" }) + expect(attached.commands).not.toContainEqual(expect.stringContaining("subscriptions delete")) + }) + + it("tries an IAM change again when it raced another change to the same policy", () => { + const granted = run(setup(setupInput("folder", { logs: true, metrics: true })), { + env: { GCLOUD_RACE: "add-iam-policy-binding" }, + }) + expect(granted.status).toBe(0) + expect( + granted.commands.filter((command) => command.includes("roles/logging.logWriter")), + ).toHaveLength(2) + expect(granted.stdout).not.toContain("waiting for Google") + + const removed = run(cleanup(SCOPES.folder), { + describe: "found", + env: { GCLOUD_RACE: "remove-iam-policy-binding" }, + }) + expect(removed.status).toBe(0) + expect( + removed.commands.filter((command) => command.includes("roles/monitoring.viewer")), + ).toHaveLength(2) + expect(removed.commands.at(-1)).toBe( + `iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`, + ) + }) + + it("stops before the first change on a gcloud whose sink update cannot re-run a folder setup", () => { + const script = setup(setupInput("folder", { logs: true, metrics: true })) + const old = run(script, { env: { GCLOUD_VERSION: "469.0.0" } }) + expect(old.status).toBe(1) + expect(old.commands).toEqual([]) + expect(old.stdout).toContain(" ✗ gcloud 469.0.0 is too old for this script.") + expect(run(script, { env: { GCLOUD_VERSION: "470.0.0" } }).status).toBe(0) + }) + + it("counts every gcloud call against the host project, whatever the caller's settings say", () => { + const { projects } = run(setup(setupInput("organization", { logs: true, metrics: true })), { + env: { CLOUDSDK_CORE_PROJECT: "unrelated-project" }, + }) + expect(new Set(projects)).toEqual(new Set(["acme-host"])) + }) + + it("keeps gcloud from writing its arguments, the secret among them, to its log files", () => { + for (const script of [ + setup(setupInput("project", { logs: true, metrics: true })), + cleanup(SCOPES.project, PUSH_ENDPOINT), + ]) { + const { fileLogging } = run(script, { describe: "found" }) + expect(fileLogging.length).toBeGreaterThan(0) + expect(new Set(fileLogging)).toEqual(new Set(["1"])) + } + }) + + it("names the impersonated service account as the one that needs the roles", () => { + const { stdout } = run(setup(setupInput("project", { logs: true, metrics: true })), { + fail: "sinks create", + env: { GCLOUD_IMPERSONATE: "deployer@acme-host.iam.gserviceaccount.com" }, + }) + expect(stdout).toContain(" ✓ Signed in as deployer@acme-host.iam.gserviceaccount.com\n") + expect(stdout).toContain( + "What to do: deployer@acme-host.iam.gserviceaccount.com needs Logs Configuration Writer on project acme-host.", + ) + }) + + it("carries no secret and reports nothing once the connector is gone", () => { + const script = cleanup(SCOPES.project) + expect(script).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{20,}/) + expect(script).not.toContain("/v1/logpush/") + expect(script).not.toContain("PUSH_ENDPOINT=") + const { status, stdout, curl } = run(script, { describe: "found" }) + expect(status).toBe(0) + expect(curl).toEqual([]) + expect(stdout).not.toContain("disconnect in Maple") + }) + + it("hands gcloud each value as one argument", () => { + const { args } = run(setup(setupInput("folder", { logs: true, metrics: true }))) + expect(args).toEqual( + expect.arrayContaining([ + `--log-filter=${gcpLogFilter(false)}`, + `--push-endpoint=${PUSH_ENDPOINT}`, + "--display-name=Maple metrics and resource reader", + "--format=value(writerIdentity)", + `--member=${WRITER}`, + expect.stringMatching(`^--message=${REPORT('"logs":true').source.slice(1)}`), + ]), + ) + }) + + it("leaves out high-volume noise and GKE container logs by default, and keeps the latter on request", () => { + expect(gcpLogFilter(true)).toBe( + 'NOT log_id("cloudaudit.googleapis.com/data_access") AND NOT httpRequest.userAgent:"GoogleHC" AND NOT protoPayload.methodName="io.k8s.coordination.v1.leases.update" AND NOT logName:"serialconsole.googleapis.com"', + ) + expect(gcpLogFilter(false)).toBe(`${gcpLogFilter(true)} AND NOT resource.type="k8s_container"`) + }) + + it("passes interpolated values to gcloud verbatim, whatever they contain", () => { + const hostile = `x'; touch pwned; echo '$(touch pwned)` + const { dir, status, commands } = run( + setup({ + ...setupInput("project", { logs: false, metrics: true }), + mapleServiceAccountEmail: hostile, + mapleUrl: hostile, + }), + ) + expect(status).toBe(0) + expect(commands.at(-1)).toContain(`--member=serviceAccount:${hostile} `) + expect(existsSync(join(dir, "pwned"))).toBe(false) + }) +}) diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts new file mode 100644 index 0000000000..97baeffba4 --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -0,0 +1,894 @@ +import { gcpConnectorResourceNames } from "@maple/domain/gcp" +import { IntegrationsPersistenceError } from "@maple/domain/http" +import type { + GcpConnectorId, + GcpLogFilter, + GcpProjectId, + GcpResourceNumber, + GcpScopeType, +} from "@maple/domain/primitives" +import { Effect } from "effect" + +/** + * Single-quote a value for bash. Every value a script uses goes through here; the scope named in + * its header comment is an id the API has pattern-checked. + */ +const sh = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'` + +const DOCS_URL = "https://maple.dev/docs/integrations/gcp" +const OTEL_DOCS_URL = "https://maple.dev/docs/integrations/gcp-opentelemetry" + +// A connection's IDs are fixed once it exists, so a wrong one is fixed by connecting again. +const WRONG_ID = + "If it is wrong, remove the connection in Maple and connect the right ID: a connection's ID can't be changed." + +// High volume that says little about a workload. Data Access audit logs record every API read. +// Load balancer health-check probes hit each backend every few seconds. A GKE cluster renews +// its leader-election leases all day, about 570 audit entries a minute for one idle node. A VM +// writes its serial console at boot, thousands of lines of raw terminal output. +const NOISE = [ + 'NOT log_id("cloudaudit.googleapis.com/data_access")', + 'NOT httpRequest.userAgent:"GoogleHC"', + 'NOT protoPayload.methodName="io.k8s.coordination.v1.leases.update"', + 'NOT logName:"serialconsole.googleapis.com"', +] + +// Left out unless asked for: a workload that sends its logs to Maple over OpenTelemetry would +// have every container line stored twice, and Google's copy carries no trace link. +const GKE_CONTAINER_LOGS = 'NOT resource.type="k8s_container"' + +export const gcpLogFilter = (includeGkeContainerLogs: boolean): string => + [...NOISE, ...(includeGkeContainerLogs ? [] : [GKE_CONTAINER_LOGS])].join(" AND ") + +/** What a connector covers and where Maple's own resources live. */ +export interface GcpScriptTarget { + readonly connectorId: GcpConnectorId + readonly scopeType: GcpScopeType + readonly scopeId: GcpProjectId | GcpResourceNumber + /** The host project: topic, subscription and service account are created here. */ + readonly projectId: GcpProjectId + /** The Google Cloud integration page, where the script sends its reader back to. */ + readonly mapleUrl: string +} + +// How each scope is addressed: the flag that places a log sink, the command family that reads it +// and edits its IAM policy, the role that may edit that policy, and how to look its ID up. +const SCOPES = { + project: { + sink: '--project="$SCOPE_ID"', + gcloud: "gcloud projects", + iamRole: "Owner", + list: "gcloud projects list", + }, + folder: { + sink: '--folder="$SCOPE_ID"', + gcloud: "gcloud resource-manager folders", + iamRole: "Folder IAM Admin", + list: "gcloud resource-manager folders list --organization=ORGANIZATION_ID", + }, + organization: { + sink: '--organization="$SCOPE_ID"', + gcloud: "gcloud organizations", + iamRole: "Organization Administrator", + list: "gcloud organizations list", + }, +} as const + +// A project is its own host project, so its script says "the project". +const hostProject = (scopeType: GcpScopeType): string => + scopeType === "project" ? "the project" : "the host project" + +// A folder or organization sink also routes the logs of every project underneath. +const includeChildren = (scopeType: GcpScopeType): string => + scopeType === "project" ? "" : " --include-children" + +const variables = (target: GcpScriptTarget, more: ReadonlyArray): string => { + const names = gcpConnectorResourceNames(target.connectorId) + return ` +# ---- This connection ---- +# PROJECT_ID ${target.scopeType === "project" ? "" : "is the host project: it "}holds Maple's Pub/Sub topic, subscription and service account. +PROJECT_ID=${sh(target.projectId)} +# gcloud counts a person's calls against its current project, in Cloud Shell whichever one the +# console has open. They belong to this one, whatever the caller's settings say. +export CLOUDSDK_CORE_PROJECT="$PROJECT_ID" +SCOPE_ID=${sh(target.scopeId)} +TOPIC=${sh(names.topic)} +SUBSCRIPTION=${sh(names.subscription)} +SINK=${sh(names.sink)} +SERVICE_ACCOUNT=${sh(names.serviceAccountId)} +SERVICE_ACCOUNT_EMAIL="$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com" +${more.map((line) => `${line}\n`).join("")}MAPLE_URL=${sh(target.mapleUrl)} +SCOPE="${target.scopeType} $SCOPE_ID" +OWNER="Owner on project $PROJECT_ID" +SINK_ROLE="Logs Configuration Writer on $SCOPE" +IAM_ROLE="${SCOPES[target.scopeType].iamRole} on $SCOPE" +` +} + +// Google's own output is shown only for a step that fails, under what failed and above what to +// do about it. `stop` ends the script: every step can be run again, so nothing is rolled back. +const output = (unfinished: string, interrupted: string) => ` +# ---- What this script prints ---- +# Its marks are spelled as bytes: the text you paste is plain ASCII, which every terminal and +# locale passes on unchanged. +section() { printf '\\n%s\\n' "$1"; } +ok() { printf ' \\342\\234\\223 %s\\n' "$1"; } +note() { printf ' \\342\\200\\242 %s\\n' "$1"; } +# Ctrl-C ends the script between two steps, which a re-run picks up from. +interrupted() { printf '\\n\\n%s\\n' ${sh(interrupted)}; exit 130; } +trap interrupted INT + +# stop [Google's answer] +stop() { + printf ' \\342\\234\\227 %s\\n' "$1" + # Google can quote the push endpoint back, and this output may be sent to support. The error + # line says what was refused; the machine-readable details that follow it are left out. + if [ -n "\${3-}" ]; then + printf '\\n' + printf '%s\\n' "$3" | sed -e "/^- '@type':/,\\$d" -e 's/secret=[^ )&"]*/secret=HIDDEN/g' -e 's/maple_gcp_[A-Za-z0-9_-]*/maple_gcp_HIDDEN/g' -e 's/^/ /' + fi + printf '\\n What to do: %s\\n' "$2" + printf '\\n%s\\n' ${sh(unfinished)} + exit 1 +} + +# fix : what to do about a refused step. +fix() { + case "$1" in + *"requires billing"* | *BILLING_DISABLED* | *"Billing must be enabled"* | *BILLING_NOT_FOUND*) + printf '%s' "Project $PROJECT_ID has no active billing account. Link one at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID" ;; + *allowedPolicyMember* | *"permitted customer"*) + printf '%s' "An organization policy (domain restricted sharing) blocks granting Maple's service account \${MAPLE_SERVICE_ACCOUNT-} on project $PROJECT_ID. Ask an Organization Policy Administrator to allow that one service account, for this project only (iam.managed.allowedPolicyMembers). Don't turn the policy off. See ${DOCS_URL}#domain-restricted-sharing" ;; + # Google words these two as a denial too, so they are read before the missing role. + *"has not been used"* | *"is disabled"* | *SERVICE_DISABLED*) + printf '%s' "Google is still switching an API on, or the API is off. Wait a minute and paste the script again." ;; + *"ervice account"*"does not exist"* | *"Unknown service account"*) + printf '%s' "Google has not published the new service account yet. Wait a minute and paste the script again." ;; + # Pub/Sub words a refusal as "User not authorized to perform this action". + *PERMISSION_DENIED* | *"does not have permission"* | *"denied on resource"* | *"not authorized"*) + printf '%s' "$ACCOUNT needs $2." ;; + *"Unparseable filter"*) + printf '%s' "Google does not accept the log filter. Correct the filter near the top of the script and paste it again. Filter syntax: https://cloud.google.com/logging/docs/view/logging-query-language" ;; + *) printf '%s' "Read Google's answer above. If it is unclear, send this output to support@maple.dev." ;; + esac +} + +# try +try() { + local failed="$1" role="$2" output + shift 2 + output="$("$@" 2>&1)" || stop "$failed" "$(fix "$output" "$role")" "$output" +} + +# run +run() { + local worked="$1" + shift + try "$@" + ok "$worked" +} + +# conflict : an IAM change raced another change to the same policy, which gcloud +# does not try again. Trying again is safe: each change is read afresh. +conflict() { case "$1" in *"concurrent policy changes"* | *ABORTED*) return 0 ;; *) return 1 ;; esac; } + +# report : the message that tells Maple what a run did. It has +# the form of a log entry; Maple reads it and does not store it. +report() { + printf '{"logName":"projects/%s/logs/maple-setup","timestamp":"%s","jsonPayload":{%s}}' \\ + "$PROJECT_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" +} +` + +const POST_REPORT = `curl -fsS --max-time 15 -o /dev/null -X POST -H 'Content-Type: application/json'` + +// Through the topic while there is one: the message arriving proves topic, subscription and +// Maple are wired up. Maple not hearing of a run must never fail it. +const notify = (throughTopic: boolean) => ` +# notify +CONFIRMED=1 +notify() { + if ${ + throughTopic + ? `gcloud pubsub topics publish "$TOPIC" --project="$PROJECT_ID" --message="$(report "$2")"` + : `${POST_REPORT} --data "$(report "$2")" "$PUSH_ENDPOINT"` + } >/dev/null 2>&1; then + ok "$1" + elif [ "$CONFIRMED" = 1 ]; then + note "Couldn't reach Maple to confirm. Maple shows the connection as pending until a later run reaches it." + CONFIRMED=0 + fi +} +` + +// Removal must not take "could not look" for "not there": a sink the caller may not see would +// otherwise be left routing into a topic the next step deletes. +const REMOVAL_HELPERS = ` +# remains : whether a resource is still +# there. NOT_FOUND means it is not, and so does a disabled API for the topic, the subscription +# and the service account: setup switches their APIs on before it creates them (a sink can exist +# without). Any other answer stops the script: the resource may still be there, and nothing that +# it depends on is removed before it. +remains() { + local what="$1" role="$2" output + shift 2 + if output="$("$@" 2>&1)"; then return 0; fi + case "$2:$output" in + *NOT_FOUND*) return 1 ;; + logging:*) ;; + *"has not been used"* | *"is disabled"*) api_off "$what"; return 1 ;; + esac + stop "Couldn't check whether $what still exists." "$(fix "$output" "$role")" "$output" +} + +# api_off : an API switched off after a setup run would hide what it made. The cleanup +# says so instead of calling it gone; a setup run with the API off has made nothing there. +UNCHECKED=0 +api_off() { + [ "\${ANY_API_OFF_MATTERS-0}" = 1 ] || return 0 + note "Couldn't look for $1: its API is off in project $PROJECT_ID. If a setup run made one, switch the API on and paste this again." + UNCHECKED=1 +} + +# remove : +# one that is gone already, a moment after it was seen, is fine. +remove() { + local worked="$1" failed="$2" role="$3" output + shift 3 + if ! output="$("$@" 2>&1)"; then + case "$output" in *NOT_FOUND*) ;; *) stop "$failed" "$(fix "$output" "$role")" "$output" ;; esac + fi + ok "$worked" + REMOVED=1 +} + +# unbind : a binding that is +# already gone is fine. +unbind() { + local role="$1" output tries=0 + shift + until output="$("$@" 2>&1)"; do + case "$output" in *"Policy binding"*"not found"*) return 0 ;; esac + tries=$((tries + 1)) + if ! conflict "$output" || [ "$tries" -ge 20 ]; then + stop "Couldn't remove the read-only roles." "$(fix "$output" "$role")" "$output" + fi + sleep 3 + done +} +` + +// APIs in the host project. Cloud Resource Manager is what gcloud reads projects and grants +// roles through. +const LOG_APIS = ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"] +const METRIC_APIS = [ + "monitoring.googleapis.com", + "cloudasset.googleapis.com", + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "cloudresourcemanager.googleapis.com", +] + +// gcloud's calls are counted against the host project (CLOUDSDK_CORE_PROJECT), or for a service +// account against its own project. With the Cloud Resource Manager API off there, every describe +// and role grant fails. +const apisOff = ( + scopeType: GcpScopeType, + apis: ReadonlyArray, +) => `# Which of the APIs this script needs are off in ${hostProject(scopeType)}, asked once. +APIS_OFF="$(off ${apis.join(" ")})" +# gcloud needs the Cloud Resource Manager API of the project its calls are counted against: this +# one, or a service account's own project, usually this one too. So it is switched on before the +# first read. Not everyone may switch an API on: a refusal here is reported further down. +case "$APIS_OFF" in *cloudresourcemanager*) + if gcloud services enable cloudresourcemanager.googleapis.com --project="$PROJECT_ID" >/dev/null 2>&1; then + APIS_OFF="\${APIS_OFF/ cloudresourcemanager.googleapis.com/}" + fi ;; +esac +` + +const sinkAndFilter = (sink: string) => ` +# A filter Google would refuse is found here, before the topic and subscription exist: reading +# one entry makes Google parse it. Not being allowed to read logs is not an error. +SINK_EXISTS=0 +if exists gcloud logging sinks describe "$SINK" ${sink}; then SINK_EXISTS=1; fi +if [ "$SINK_EXISTS" = 0 ] || [ "$LOG_FILTER_MODE" = set ]; then + if FILTER_ANSWER="$(gcloud logging read "$LOG_FILTER" --limit=1 --freshness=1h ${sink} 2>&1 >/dev/null)"; then + ok "Log filter accepted" + else + case "$FILTER_ANSWER" in *"Unparseable filter"*) + stop "Google does not accept the log filter." "$(fix "$FILTER_ANSWER" "")" "$FILTER_ANSWER" ;; + esac + fi +fi +` + +interface AccessChecks { + /** Setup only: the APIs the enabled sections need in the host project. */ + readonly apis: ReadonlyArray + /** Setup with log forwarding on: the flag that places the sink. */ + readonly sink: string | undefined + /** The scope's name must be readable: true when a role the run needs includes reading it. */ + readonly scopeMustOpen: boolean + readonly billing: boolean + /** `testIamPermissions` calls: resource path, where in words, what to ask for, permissions. */ + readonly permissions: ReadonlyArray]> +} + +const openScope = (scopeType: Exclude, mustOpen: boolean): string => { + const scope = SCOPES[scopeType] + const describe = `${scope.gcloud} describe "$SCOPE_ID"` + const label = scopeType === "folder" ? "Folder" : "Organization" + return mustOpen + ? `if ! SCOPE_NAME="$(${describe} --format='value(displayName)' 2>/dev/null)"; then + stop "Can't open $SCOPE as $ACCOUNT." "Check the ID with: ${scope.list}. ${WRONG_ID} If it is right, this account needs ${scope.iamRole} there." "$(${describe} 2>&1 || true)" +fi +ok "${label} $SCOPE_ID found ($SCOPE_NAME)" +` + : `# Not every role that can do this work may read the ${scopeType}'s name (Logs Configuration +# Writer may not), so a look that fails is not an error. +if SCOPE_NAME="$(${describe} --format='value(displayName)' 2>/dev/null)"; then + ok "${label} $SCOPE_ID found ($SCOPE_NAME)" +fi +` +} + +const PERMISSION_CHECK = ` +# allowed : asks Google which of +# the permissions this account holds, before anything is changed. No answer (no curl, no network) +# is not a refusal: the steps below then report for themselves. +CHECKED=1 +allowed() { + local resource="$1" where="$2" ask="$3" answer permission lacking="" held=0 nl=$'\\n' + shift 3 + if ! answer="$(curl -sS --max-time 15 -w '\\n%{http_code}' -X POST -H 'Content-Type: application/json' \\ + -H @<(printf 'Authorization: Bearer %s\\n' "$TOKEN") \\ + --data "{\\"permissions\\":[$(printf '"%s",' "$@" | sed 's/,$//')]}" \\ + "https://cloudresourcemanager.googleapis.com/v3/$resource:testIamPermissions" 2>/dev/null)"; then + CHECKED=0 + return 0 + fi + # Google refuses to answer for a folder or organization that this account can't see, or that + # does not exist: as good a sign of a wrong ID as having none of the permissions. + case "\${answer##*$nl}" in + 200) answer="\${answer%$nl*}" ;; + 403 | 404) + case "$answer" in *SERVICE_DISABLED* | *"has not been used"*) CHECKED=0; return 0 ;; esac + stop "$ACCOUNT can't see $where." "Check the ID in Maple. ${WRONG_ID} If it is right, this account has no rights there. $ask" ;; + *) CHECKED=0; return 0 ;; + esac + for permission in "$@"; do + case "$answer" in *"\\"$permission\\""*) held=1 ;; *) lacking="$lacking, $permission" ;; esac + done + if [ -z "$lacking" ]; then return 0; fi + # None of several held: a wrong ID is likelier than a list of missing rights. + if [ "$held" = 0 ] && [ "$#" -gt 1 ]; then + stop "$ACCOUNT has none of the permissions this script needs on $where." "Check the ID in Maple. ${WRONG_ID} If it is right, this account has no rights there. $ask" + fi + stop "$ACCOUNT is missing permissions on $where: \${lacking#, }." "$ask" +} +` + +const access = (scopeType: GcpScopeType, checks: AccessChecks): string => ` +# ---- Checking access: nothing is created or removed before all of it passes ---- +section "Checking access" +command -v gcloud >/dev/null 2>&1 || + stop "gcloud is not installed here." "Run this script in Cloud Shell: https://shell.cloud.google.com" +# Before 470, \`gcloud logging sinks update\` has no --include-children and every re-run fails. +GCLOUD_VERSION="$(gcloud version --format='value("Google Cloud SDK")' 2>/dev/null || true)" +case "$GCLOUD_VERSION" in [0-9].* | [0-9][0-9].* | [0-3][0-9][0-9].* | 4[0-6][0-9].*) + stop "gcloud $GCLOUD_VERSION is too old for this script." "Update it (gcloud components update, or through the package manager that installed it), or run the script in Cloud Shell: https://shell.cloud.google.com" ;; +esac +ACCOUNT="$(gcloud config get-value account 2>/dev/null || true)" +TOKEN="$(gcloud auth print-access-token 2>/dev/null || true)" +if [ -z "$ACCOUNT" ] || [ -z "$TOKEN" ]; then + stop "You are not signed in to Google Cloud." "In Cloud Shell, click Authorize when it asks. Elsewhere, run: gcloud auth login" +fi +# With impersonation set, gcloud acts as that service account: it is the one that needs the roles. +IMPERSONATED="$(gcloud config get-value auth/impersonate_service_account 2>/dev/null || true)" +[ -z "$IMPERSONATED" ] || ACCOUNT="$IMPERSONATED" +ok "Signed in as $ACCOUNT" +${checks.apis.length === 0 ? "" : apisOff(scopeType, checks.apis)}${scopeType === "project" ? "" : openScope(scopeType, checks.scopeMustOpen)}if ! PROJECT_NAME="$(gcloud projects describe "$PROJECT_ID" --format='value(name)' 2>/dev/null)"; then + stop "Can't open project $PROJECT_ID as $ACCOUNT." "Check the ID in Maple (it is the project ID, not the name or number; list yours with: gcloud projects list). ${WRONG_ID} If it is right, this account has no access to the project." "$(gcloud projects describe "$PROJECT_ID" 2>&1 || true)" +fi +ok "${scopeType === "project" ? "Project" : "Host project"} $PROJECT_ID found ($PROJECT_NAME)" +${ + checks.billing + ? `case "$(gcloud billing projects describe "$PROJECT_ID" --format='value(billingEnabled)' 2>/dev/null || true)" in + True) ok "Billing is enabled" ;; + False) stop "Project $PROJECT_ID has no billing account." "Cloud Monitoring only answers for projects with billing. Link one at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID" ;; + *) note "Couldn't tell whether billing is on. Cloud Monitoring only answers for projects with billing." ;; +esac +` + : "" +}${ + checks.permissions.length === 0 + ? "" + : `${PERMISSION_CHECK}${checks.permissions + .map( + ([resource, where, ask, permissions]) => + `allowed "${resource}" "${where}" "${ask}" \\\n ${permissions.join(" ")}\n`, + ) + .join("")}# Switching an API on takes a right of its own, asked about only when one is off. +[ -z "$APIS_OFF" ] || allowed "projects/$PROJECT_ID" "project $PROJECT_ID" "This script has to switch on:$APIS_OFF. Ask for Service Usage Admin there, or have an administrator switch them on." \\ + serviceusage.services.enable +if [ "$CHECKED" = 1 ]; then + ok "$ACCOUNT has the permissions this script needs" +else + note "Couldn't ask Google which permissions $ACCOUNT holds. A step it may not take says so." +fi +` +}${checks.sink === undefined ? "" : sinkAndFilter(checks.sink)}` + +/** What the enabled sections need, asked of Google before the first change. */ +const permissionChecks = ( + scopeType: GcpScopeType, + logs: boolean, + metrics: boolean, +): AccessChecks["permissions"] => { + if (!logs && !metrics) return [] + const scope = SCOPES[scopeType] + const administrator = "or have an administrator run this script." + const host = [ + "resourcemanager.projects.getIamPolicy", + "resourcemanager.projects.setIamPolicy", + // The gets: a resource the caller may not look at is taken for missing and created again, + // which Google refuses as a duplicate. + ...(logs + ? [ + "pubsub.topics.get", + "pubsub.topics.create", + "pubsub.topics.attachSubscription", + "pubsub.topics.setIamPolicy", + "pubsub.topics.publish", + "pubsub.subscriptions.get", + "pubsub.subscriptions.create", + "pubsub.subscriptions.update", + ] + : []), + ...(metrics + ? ["iam.serviceAccounts.get", "iam.serviceAccounts.create", "iam.serviceAccounts.setIamPolicy"] + : []), + ] + const sink = logs ? ["logging.sinks.get", "logging.sinks.create", "logging.sinks.update"] : [] + const askHost = `Ask for Owner there, ${administrator}` + if (scopeType === "project") { + return [["projects/$PROJECT_ID", "project $PROJECT_ID", askHost, [...host, ...sink]]] + } + const roles = [...(logs ? ["Logs Configuration Writer"] : []), ...(metrics ? [scope.iamRole] : [])] + return [ + ["projects/$PROJECT_ID", "project $PROJECT_ID", askHost, host], + [ + `${scopeType}s/$SCOPE_ID`, + "$SCOPE", + `Ask for ${roles.join(" and ")} there, ${administrator} If the ID looks wrong, check it with: ${scope.list}`, + [...sink, ...(metrics ? [`resourcemanager.${scopeType}s.setIamPolicy`] : [])], + ], + ] +} + +// Setup only: granting to a service account Google may not have published yet, its own for the +// log sink or the reader this script creates. +const GRANT = ` +# grant +# Waited out, for a minute at most: a change that raced another one, and a service account Google +# has not published yet. Until then it answers that the account does not exist, or for the policy +# of a reader this run created (gcloud iam service-accounts ...), that the caller may not change it. +WAITED=0 +grant() { + local failed="$1" role="$2" output tries=0 + shift 2 + while ! output="$("$@" 2>&1)"; do + tries=$((tries + 1)) + conflict "$output" || case "$output" in + *"does not exist"* | *NOT_FOUND*) ;; + *PERMISSION_DENIED*) { [ "$ACCOUNT_CREATED" = 1 ] && [ "$3" = service-accounts ]; } || tries=20 ;; + *) tries=20 ;; + esac + if [ "$tries" -ge 20 ]; then stop "$failed" "$(fix "$output" "$role")" "$output"; fi + if [ "$WAITED" = 0 ] && ! conflict "$output"; then + printf ' \\342\\200\\246 waiting for Google to publish the new service account\\n' + WAITED=1 + fi + sleep 3 + done +} +` + +const logsSetup = (scopeType: GcpScopeType): string => ` +# ---- Log forwarding: on ---- +section "Log forwarding" +DESTINATION="pubsub.googleapis.com/projects/$PROJECT_ID/topics/$TOPIC" + +# APIs in ${hostProject(scopeType)}: Pub/Sub carries the log entries, Cloud Logging routes them, +# and Cloud Resource Manager is what gcloud grants the roles below through. +apis "Pub/Sub, Cloud Logging, Cloud Resource Manager" ${LOG_APIS.join(" ")} + +if exists gcloud pubsub topics describe "$TOPIC" --project="$PROJECT_ID"; then + ok "Topic already exists" +else + run "Topic created" "Couldn't create the Pub/Sub topic." "$OWNER" \\ + gcloud pubsub topics create "$TOPIC" --project="$PROJECT_ID" +fi + +# One LogEntry per request, unwrapped. A day of retention bounds what is replayed after an +# outage, and the subscription never expires while it is idle. +SUBSCRIPTION_FLAGS=( + --project="$PROJECT_ID" + --push-endpoint="$PUSH_ENDPOINT" --push-no-wrapper + --ack-deadline=30 --message-retention-duration=1d --expiration-period=never + --min-retry-delay=10s --max-retry-delay=600s +) +# A subscription outlives a deleted topic, and one that was detached stays detached: neither gets +# a message again, and its topic can't be changed. Such a subscription is made anew. +subscribed() { + gcloud pubsub subscriptions describe "$SUBSCRIPTION" --project="$PROJECT_ID" --format="value($1)" 2>/dev/null || true +} +SUBSCRIBED="$(subscribed topic)" +if [ -n "$SUBSCRIBED" ] && + { [ "$SUBSCRIBED" != "projects/$PROJECT_ID/topics/$TOPIC" ] || [ "$(subscribed detached)" = True ]; }; then + run "Push subscription cut off from the topic deleted" "Couldn't delete the push subscription." "$OWNER" \\ + gcloud pubsub subscriptions delete "$SUBSCRIPTION" --project="$PROJECT_ID" --quiet + SUBSCRIBED="" +fi +if [ -n "$SUBSCRIBED" ]; then + run "Push subscription up to date" "Couldn't update the push subscription." "$OWNER" \\ + gcloud pubsub subscriptions update "$SUBSCRIPTION" "\${SUBSCRIPTION_FLAGS[@]}" +else + run "Push subscription created" "Couldn't create the push subscription." "$OWNER" \\ + gcloud pubsub subscriptions create "$SUBSCRIPTION" --topic="$TOPIC" "\${SUBSCRIPTION_FLAGS[@]}" +fi + +# --no-disabled: a sink someone paused forwards again, as log forwarding is on in Maple. +if [ "$SINK_EXISTS" = 1 ]; then + if [ "$LOG_FILTER_MODE" = set ]; then + run "Log sink up to date (filter replaced)" "Couldn't update the log sink." "$SINK_ROLE" \\ + gcloud logging sinks update "$SINK" "$DESTINATION" --no-disabled --log-filter="$LOG_FILTER" ${SCOPES[scopeType].sink}${includeChildren(scopeType)} + FILTER_REPLACED=1 + else + run "Log sink up to date (filter kept)" "Couldn't update the log sink." "$SINK_ROLE" \\ + gcloud logging sinks update "$SINK" "$DESTINATION" --no-disabled ${SCOPES[scopeType].sink}${includeChildren(scopeType)} + fi +else + run "Log sink created" "Couldn't create the log sink." "$SINK_ROLE" \\ + gcloud logging sinks create "$SINK" "$DESTINATION" --log-filter="$LOG_FILTER" ${SCOPES[scopeType].sink}${includeChildren(scopeType)} + SINK_CREATED=1 +fi + +# The sink writes as a Google-managed identity. Google requires Logs Writer for it on the project +# that holds the destination, and it needs Pub/Sub Publisher on this one topic. +if ! WRITER_IDENTITY="$(gcloud logging sinks describe "$SINK" ${SCOPES[scopeType].sink} --format='value(writerIdentity)' 2>/dev/null)"; then + WRITER_IDENTITY="$(gcloud logging sinks describe "$SINK" ${SCOPES[scopeType].sink} 2>&1 || true)" + stop "Couldn't read the log sink back." "$(fix "$WRITER_IDENTITY" "$SINK_ROLE")" "$WRITER_IDENTITY" +fi +[ -n "$WRITER_IDENTITY" ] || + stop "Google named no identity for the log sink to write as." "Wait a minute and paste the script again." +grant "Couldn't grant the sink Logs Writer." "$OWNER" \\ + gcloud projects add-iam-policy-binding "$PROJECT_ID" \\ + --member="$WRITER_IDENTITY" --role=roles/logging.logWriter --condition=None +ok "Sink allowed to write to the project" +grant "Couldn't grant the sink Pub/Sub Publisher." "$OWNER" \\ + gcloud pubsub topics add-iam-policy-binding "$TOPIC" --project="$PROJECT_ID" \\ + --member="$WRITER_IDENTITY" --role=roles/pubsub.publisher +ok "Sink allowed to publish to the topic" +notify "Check message sent to Maple through the topic" '"logs":true' +` + +const logsRemoval = (scopeType: GcpScopeType, notifies: boolean): string => ` +# ---- Log forwarding: off. Remove what an earlier run created. ---- +# The sink goes first, so nothing routes into a topic that is gone. Google keeps routing for a +# while after a sink is deleted, and with the topic gone by then it logs an error in this project +# and may notify its contacts, so the topic waits a minute. The Logs Writer grant on +# ${hostProject(scopeType)} stays: the sink wrote as the logging service agent of the ${scopeType}, +# an identity every other sink there shares. +section "Log forwarding" +if remains "the log sink" "$SINK_ROLE" gcloud logging sinks describe "$SINK" ${SCOPES[scopeType].sink}; then + remove "Log sink deleted" "Couldn't delete the log sink." "$SINK_ROLE" \\ + gcloud logging sinks delete "$SINK" ${SCOPES[scopeType].sink} --quiet + printf ' \\342\\200\\246 waiting a minute for Google to stop routing to the topic\\n' + sleep 60 +fi +if remains "the push subscription" "$OWNER" gcloud pubsub subscriptions describe "$SUBSCRIPTION" --project="$PROJECT_ID"; then + remove "Push subscription deleted" "Couldn't delete the push subscription." "$OWNER" \\ + gcloud pubsub subscriptions delete "$SUBSCRIPTION" --project="$PROJECT_ID" --quiet +fi +if remains "the topic" "$OWNER" gcloud pubsub topics describe "$TOPIC" --project="$PROJECT_ID"; then + remove "Topic deleted" "Couldn't delete the Pub/Sub topic." "$OWNER" \\ + gcloud pubsub topics delete "$TOPIC" --project="$PROJECT_ID" --quiet +fi +[ "$REMOVED" = 1 ] || ok "Nothing left to remove" +${notifies ? `notify "Maple notified" '"logs":false'\n` : ""}` + +const serviceAccountMember = '--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL"' + +const metricsSetup = (scopeType: GcpScopeType): string => ` +# ---- Metrics and resources: on ---- +section "Metrics and resources" + +# APIs in ${scopeType === "project" ? "the project" : "the host project, the project Maple's reads are made through"}: +# monitoring.googleapis.com Cloud Monitoring, to read metrics +# cloudasset.googleapis.com Cloud Asset Inventory, to list resources +# iam.googleapis.com to create the service account below +# iamcredentials.googleapis.com short-lived tokens for that account; no key is ever created +# cloudresourcemanager.googleapis.com what gcloud grants the roles below through +apis "Cloud Monitoring, Cloud Asset, IAM, IAM Credentials, Cloud Resource Manager" \\ + ${METRIC_APIS.join(" ")} + +# A service account that an earlier run deleted (metrics switched off) fails this describe as +# well and is created again. Google makes that a new account under the same name, with none of +# the old one's roles, so every grant below is made for it. +if exists gcloud iam service-accounts describe "$SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID"; then + ok "Read-only service account already exists" +else + run "Read-only service account created" "Couldn't create the service account." "$OWNER" \\ + gcloud iam service-accounts create "$SERVICE_ACCOUNT" --project="$PROJECT_ID" \\ + --display-name="Maple metrics and resource reader" + ACCOUNT_CREATED=1 +fi + +# Read-only roles on the ${scopeType}${scopeType === "project" ? "" : ", inherited by every project under it"}: +# roles/monitoring.viewer read metrics (monitoring.timeSeries.list) +# roles/cloudasset.viewer list resources (cloudasset.assets.searchAllResources) +# These are the narrowest predefined roles for the two calls. Both are read-only, and broader +# than the calls: Cloud Asset Viewer can also read resource metadata and IAM policies. +for ROLE in roles/monitoring.viewer roles/cloudasset.viewer; do + grant "Couldn't grant the read-only roles." "$IAM_ROLE" \\ + ${SCOPES[scopeType].gcloud} add-iam-policy-binding "$SCOPE_ID" \\ + ${serviceAccountMember} --role="$ROLE" --condition=None +done +# Maple's reads name this project as the one their quota is counted against, which takes +# serviceusage.services.use on it. +grant "Couldn't grant the read-only roles." "$OWNER" \\ + gcloud projects add-iam-policy-binding "$PROJECT_ID" \\ + ${serviceAccountMember} --role=roles/serviceusage.serviceUsageConsumer --condition=None +ok "Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer)" + +# Maple's own account may mint short-lived tokens for this one service account, nothing else. +grant "Couldn't let Maple use the service account." "$OWNER" \\ + gcloud iam service-accounts add-iam-policy-binding "$SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID" \\ + --member="serviceAccount:$MAPLE_SERVICE_ACCOUNT" --role=roles/iam.serviceAccountTokenCreator \\ + --condition=None +ok "Maple allowed to read as that account" +notify "Maple notified" '"metrics":true' +` + +const metricsRemoval = (scopeType: GcpScopeType, notifies: boolean): string => ` +# ---- Metrics and resources: off. Remove what an earlier run created. ---- +section "Metrics and resources" +# Looked up in the list: for 30 days after a service account is deleted, describing it answers +# PERMISSION_DENIED even to an Owner, which would read as "may still be there" on every re-run. +# A list that fails is read like a failed describe. +account_remains() { + local output + if output="$("$@" 2>/dev/null)"; then + [ -n "$output" ] + return + fi + output="$("$@" 2>&1 || true)" + case "$output" in *"has not been used"* | *"is disabled"*) api_off "the read-only service account"; return 1 ;; esac + stop "Couldn't check whether the read-only service account still exists." "$(fix "$output" "$OWNER")" "$output" +} +if account_remains gcloud iam service-accounts list --project="$PROJECT_ID" \\ + --filter="email=$SERVICE_ACCOUNT_EMAIL" --format='value(email)'; then + # Role bindings first: once the account is deleted they can no longer be removed by name. + for ROLE in roles/monitoring.viewer roles/cloudasset.viewer; do + unbind "$IAM_ROLE" ${SCOPES[scopeType].gcloud} remove-iam-policy-binding "$SCOPE_ID" \\ + ${serviceAccountMember} --role="$ROLE" --all + done + unbind "$OWNER" gcloud projects remove-iam-policy-binding "$PROJECT_ID" \\ + ${serviceAccountMember} --role=roles/serviceusage.serviceUsageConsumer --all + ok "Read-only roles removed" + remove "Read-only service account deleted" "Couldn't delete the service account." "$OWNER" \\ + gcloud iam service-accounts delete "$SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID" --quiet +else + ok "Nothing left to remove" +fi +${notifies ? `notify "Maple notified" '"metrics":false'\n` : ""}` + +/** + * The text that is copied: the script as a here-document for a child bash. Pasted at a prompt, + * nothing runs before the closing brace is read, a failing step ends the child and not the login + * shell, and the terminal stays the script's stdin. The first line keeps the paste, secret and + * all, out of the history file of zsh 5.3+ (which writes it after the command ran) and drops it + * from the history of bash 5; its leading space does the same under `HISTCONTROL=ignorespace` + * and `HIST_IGNORE_SPACE`. bash 3.2 keeps only the first line, which has no secret. Nothing + * outside the here-document may be a comment: an interactive zsh runs `#` as a command. + */ +// A terminal, not the bytes, decides where a pasted line ends: a CR or a TAB (completion) in a +// value could end the here-document early and hand the rest to the login shell. +const PASTE_SAFE = /^[\x20-\x7e\n]*$/u + +const forPaste = (marker: string, body: string): Effect.Effect => + !PASTE_SAFE.test(body) || body.split("\n").includes(marker) + ? Effect.fail( + new IntegrationsPersistenceError({ + message: "A Google Cloud script value would end the script's here-document early", + }), + ) + : Effect.succeed( + ` { [ -z "\${ZSH_VERSION-}" ] || case "\${HISTORY_IGNORE-}" in *"*${marker}*"*) ;; *) HISTORY_IGNORE="(\${HISTORY_IGNORE:+$HISTORY_IGNORE|}*${marker}*)" ;; esac; [ -z "\${BASH_VERSION-}" ] || case "$(history 1)" in *${marker}*) history -d -1 ;; esac 2>/dev/null +bash /dev/fd/3 3<<'${marker}' +${body}${marker} +} +`, + ) + +export interface GcpSetupScriptInput extends GcpScriptTarget { + /** The ingest gateway's receiver URL for this connector, including its secret. */ + readonly pushEndpoint: string + /** Maple's own Google service account. Undefined: the metrics setup is left untouched. */ + readonly mapleServiceAccountEmail: string | undefined + readonly logsEnabled: boolean + readonly metricsEnabled: boolean + readonly logFilter: GcpLogFilter +} + +/** + * The script an administrator pastes into Cloud Shell. Logs: a Log Router sink on the scope into + * a Pub/Sub topic whose push subscription delivers each LogEntry to Maple. Metrics and resources: + * a read-only service account Maple may impersonate. The script converges: what the connector has + * enabled is created or updated, what it has not is removed, so a re-run applies an opt-out. Each + * section ends by telling Maple what it applied. + */ +export const renderGcpSetupScript = (input: GcpSetupScriptInput) => { + const { scopeType, logsEnabled: logs } = input + // On for the connector while this deployment has lost its Google identity: nothing is set up + // and, above all, nothing a customer set up earlier is removed. + const metrics = !input.metricsEnabled + ? "off" + : input.mapleServiceAccountEmail === undefined + ? "lost" + : "on" + const state = (on: boolean) => (on ? "on" : "off, removing") + const metricsState = + metrics === "lost" ? "not available on this Maple deployment, left as is" : state(metrics === "on") + return forPaste( + "MAPLE_SETUP_SCRIPT", + `#!/usr/bin/env bash +# Maple setup for Google Cloud ${scopeType} ${input.scopeId} +# Log forwarding: ${logs ? "on" : "off"} +# Metrics and resources: ${metrics === "lost" ? metricsState : metrics} +# Safe to run again: it sets up what is on and removes what is off. +# Keep it private: PUSH_ENDPOINT contains this connection's secret. +set -euo pipefail +# gcloud would log every argument, the secret too, and the access token to ~/.config/gcloud/logs. +export CLOUDSDK_CORE_DISABLE_PROMPTS=1 CLOUDSDK_CORE_DISABLE_FILE_LOGGING=1 +${ + logs + ? ` +# ---- Which logs are forwarded ---- +# LOG_FILTER leaves out what is high volume and says little about a workload: Data Access audit +# logs, load balancer health checks, Kubernetes lease renewals and VM serial console output. +${ + input.logFilter === "include_gke_container_logs" + ? `# GKE container logs are included, as chosen in Maple. Logs from workloads that also send them +# over OpenTelemetry are then stored twice: ${OTEL_DOCS_URL}` + : `# It also leaves out GKE container logs: logs from workloads that also send them over +# OpenTelemetry would be stored twice. To include them, choose "Include GKE container logs" under +# Log filter in Maple, or delete AND ${GKE_CONTAINER_LOGS} from LOG_FILTER and make +# LOG_FILTER_MODE 'set'. Details: ${OTEL_DOCS_URL}` +} +# LOG_FILTER_MODE keep: an existing sink keeps its filter, a new sink gets LOG_FILTER. +# LOG_FILTER_MODE set: LOG_FILTER replaces the sink's filter. +# Filter syntax: https://cloud.google.com/logging/docs/view/logging-query-language +LOG_FILTER_MODE=${sh(input.logFilter === "keep" ? "keep" : "set")} +LOG_FILTER=${sh(gcpLogFilter(input.logFilter === "include_gke_container_logs"))} +` + : "" +}${variables(input, [ + `PUSH_ENDPOINT=${sh(input.pushEndpoint)}`, + ...(input.mapleServiceAccountEmail === undefined || metrics !== "on" + ? [] + : [`MAPLE_SERVICE_ACCOUNT=${sh(input.mapleServiceAccountEmail)}`]), + ])}SINK_CREATED=0 +FILTER_REPLACED=0 +ACCOUNT_CREATED=0 +REMOVED=0 +${output( + "Setup did not finish. Nothing needs undoing: fix this and paste the script again.", + "Setup interrupted. Nothing needs undoing: paste the script again to finish it.", +)}${notify(logs)} +# exists : a look that fails for any reason falls through to the create, +# which reports for itself. +exists() { "$@" >/dev/null 2>&1; } + +# off : the APIs of the list that are not switched on in ${hostProject(scopeType)}. All of +# them when the list of enabled APIs cannot be read. Asking first lets someone who may not switch +# APIs on run a script whose APIs are on already. +off() { + local on api nl=$'\\n' + on="$(gcloud services list --enabled --project="$PROJECT_ID" --format='value(config.name)' 2>/dev/null || true)" + for api in "$@"; do + case "$nl$on$nl" in *"$nl$api$nl"*) ;; *) printf ' %s' "$api" ;; esac + done +} + +# apis : switches on the ones the access check found off. +apis() { + local names="$1" api missing="" + shift + for api in "$@"; do + case " $APIS_OFF " in *" $api "*) missing="$missing $api" ;; esac + done + if [ -n "$missing" ]; then + # Unquoted on purpose: one argument per API. + try "Couldn't switch on:$missing." "Service Usage Admin on project $PROJECT_ID" \\ + gcloud services enable $missing --project="$PROJECT_ID" + fi + ok "APIs enabled ($names)" +} +${logs || metrics === "on" ? GRANT : ""}${logs && metrics === "on" ? "" : REMOVAL_HELPERS} +printf 'Maple setup for %s\\n' "$SCOPE" +printf ' Log forwarding %s\\n Metrics and resources %s\\n' ${sh(state(logs))} ${sh(metricsState)} +${access(scopeType, { + apis: [...new Set([...(logs ? LOG_APIS : []), ...(metrics === "on" ? METRIC_APIS : [])])], + sink: logs ? SCOPES[scopeType].sink : undefined, + scopeMustOpen: metrics === "on", + billing: metrics === "on", + permissions: permissionChecks(scopeType, logs, metrics === "on"), +})}${logs ? logsSetup(scopeType) : logsRemoval(scopeType, true)}${ + metrics === "on" + ? metricsSetup(scopeType) + : metrics === "off" + ? metricsRemoval(scopeType, true) + : "" + } +if [ "$SINK_CREATED$ACCOUNT_CREATED$REMOVED$FILTER_REPLACED" = 0000 ]; then + printf '\\nDone. Everything is in place.\\n' +elif [ "$SINK_CREATED$ACCOUNT_CREATED$REMOVED" = 000 ]; then + printf '\\nDone. The log sink has the filter of this script. Everything else is in place.\\n' +elif [ "$REMOVED" = 1 ]; then + printf '\\nDone. Google Cloud matches your Maple switches.\\n' +else + printf '\\nDone. Google Cloud is set up for Maple.\\n' +fi +if [ "$CONFIRMED" = 1 ]; then printf ' Maple confirms it within a minute: %s\\n' "$MAPLE_URL"; fi +if [ "$SINK_CREATED" = 1 ]; then + printf ' Logs: a new sink can take about 10 minutes to start forwarding. What is logged before\\n' + printf ' it does is not forwarded later.\\n' +fi +if [ "$ACCOUNT_CREATED" = 1 ]; then printf ' Metrics: the first read lands within about 10 minutes.\\n'; fi +`, + ) +} + +export interface GcpCleanupScriptInput extends GcpScriptTarget { + /** Set while the connector exists: the script then tells Maple that it ran. */ + readonly pushEndpoint: string | undefined +} + +/** Removes what the setup script created. Without a push endpoint it carries no secret. */ +export const renderGcpCleanupScript = (input: GcpCleanupScriptInput) => + forPaste( + "MAPLE_CLEANUP_SCRIPT", + `#!/usr/bin/env bash +# Maple cleanup for Google Cloud ${input.scopeType} ${input.scopeId} +# Removes what the Maple setup script created. The APIs it switched on stay on. +# Safe to run again.${input.pushEndpoint === undefined ? "" : "\n# Keep it private: PUSH_ENDPOINT contains this connection's secret."} +set -euo pipefail +# gcloud would log every argument, the secret too, and the access token to ~/.config/gcloud/logs. +export CLOUDSDK_CORE_DISABLE_PROMPTS=1 CLOUDSDK_CORE_DISABLE_FILE_LOGGING=1 +${variables(input, input.pushEndpoint === undefined ? [] : [`PUSH_ENDPOINT=${sh(input.pushEndpoint)}`])}REMOVED=0 +ANY_API_OFF_MATTERS=1 +${output( + "Cleanup did not finish. Fix this and paste the script again: it continues where it stopped.", + "Cleanup interrupted. Paste the script again: it continues where it stopped.", +)}${REMOVAL_HELPERS} +printf 'Maple cleanup for %s\\n' "$SCOPE" +${access(input.scopeType, { apis: [], sink: undefined, scopeMustOpen: false, billing: false, permissions: [] })}${logsRemoval(input.scopeType, false)}${metricsRemoval(input.scopeType, false)}${ + input.pushEndpoint === undefined + ? "" + : ` +# Tells Maple the cleanup ran. Maple refuses this once the connection is deleted, which is fine. +${POST_REPORT} --data "$(report '"logs":false,"metrics":false')" "$PUSH_ENDPOINT" >/dev/null 2>&1 || true +` + } +if [ "$UNCHECKED" = 1 ]; then + printf '\\nDone, but not all of it could be checked: see above.\\n' +else + printf '\\nDone. Everything the setup script created is gone.\\n' +fi +printf ' Left in place: the APIs it switched on, and the Logs Writer role of Google'"'"'s logging\\n' +printf ' service account on %s, which other sinks share.\\n' "$PROJECT_ID" +${input.pushEndpoint === undefined ? "" : `printf ' Now disconnect in Maple: %s\\n' "$MAPLE_URL"\n`}`, + ) diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index cef8e0aa0e..a0fd252807 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -33,6 +33,7 @@ import { ChatWorkspaces, CliDeviceAuthorizations, CloudflareLogpushConnectors, + GcpConnectors, Dashboards, DashboardShares, DashboardVersions, @@ -124,6 +125,8 @@ const ORG_SCOPED_TABLES: ReadonlyArray = [ ChatIdentities, DigestSubscriptions, CloudflareLogpushConnectors, + // A surviving push secret would keep the ingest gateway accepting logs for a deleted org. + GcpConnectors, ErrorIssueEvents, ErrorIssueStates, ErrorIncidents, diff --git a/packages/db/drizzle/20261015132217_gcp_connectors/migration.sql b/packages/db/drizzle/20261015132217_gcp_connectors/migration.sql new file mode 100644 index 0000000000..97718066f9 --- /dev/null +++ b/packages/db/drizzle/20261015132217_gcp_connectors/migration.sql @@ -0,0 +1,29 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "gcp_connectors" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "scope_type" text NOT NULL, + "scope_id" text NOT NULL, + "project_id" text NOT NULL, + "logs_enabled" boolean NOT NULL DEFAULT true, + "metrics_enabled" boolean NOT NULL DEFAULT false, + "secret_ciphertext" text NOT NULL, + "secret_iv" text NOT NULL, + "secret_tag" text NOT NULL, + "secret_hash" text NOT NULL, + "last_received_at" timestamp with time zone, + "last_error" text, + "applied_logs_enabled" boolean, + "applied_metrics_enabled" boolean, + "setup_reported_at" timestamp with time zone, + "created_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "gcp_connectors_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE UNIQUE INDEX IF NOT EXISTS "gcp_connectors_org_scope_idx" ON "gcp_connectors" USING btree ("org_id", "scope_type", "scope_id"); +--> statement-breakpoint +-- metadata +CREATE UNIQUE INDEX IF NOT EXISTS "gcp_connectors_secret_hash_unique" ON "gcp_connectors" USING btree ("secret_hash"); diff --git a/packages/db/drizzle/20261015132217_gcp_connectors/snapshot.json b/packages/db/drizzle/20261015132217_gcp_connectors/snapshot.json new file mode 100644 index 0000000000..c1ae4c8ed1 --- /dev/null +++ b/packages/db/drizzle/20261015132217_gcp_connectors/snapshot.json @@ -0,0 +1,12876 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "07cb1858dcef99be1aaa58d1cd8109f0ed5e6d6f979a00467ef0a2c42926b57b", + "prevIds": [ + "825059598ee5dc6c0dcb9a96c757752ad3f35c4bd83401351d640f65239930b6" + ], + "entities": [ + { + "kind": "table", + "name": "actors", + "primaryKey": { + "name": "actors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "agent_feedback", + "primaryKey": { + "name": "agent_feedback_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "ai_triage_settings", + "primaryKey": { + "name": "ai_triage_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "alert_delivery_events", + "primaryKey": { + "name": "alert_delivery_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_destinations", + "primaryKey": { + "name": "alert_destinations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incidents", + "primaryKey": { + "name": "alert_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_claims", + "primaryKey": { + "name": "alert_rule_claims_pkey", + "columns": [ + "rule_id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_states", + "primaryKey": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + { + "kind": "table", + "name": "alert_rules", + "primaryKey": { + "name": "alert_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "gcp_connectors", + "primaryKey": { + "name": "gcp_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "investigations", + "primaryKey": { + "name": "investigations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalation_policies", + "primaryKey": { + "name": "issue_escalation_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalations", + "primaryKey": { + "name": "issue_escalations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "live_activities", + "primaryKey": { + "name": "live_activities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_authorizations", + "primaryKey": { + "name": "mcp_oauth_authorizations_pkey", + "columns": [ + "request_id_hash" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_clients", + "primaryKey": { + "name": "mcp_oauth_clients_pkey", + "columns": [ + "client_id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_refresh_tokens", + "primaryKey": { + "name": "mcp_oauth_refresh_tokens_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mobile_devices", + "primaryKey": { + "name": "mobile_devices_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oauth_auth_states", + "primaryKey": { + "name": "oauth_auth_states_pkey", + "columns": [ + "state" + ] + } + }, + { + "kind": "table", + "name": "oauth_connections", + "primaryKey": { + "name": "oauth_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_schema_apply_runs", + "primaryKey": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_settings", + "primaryKey": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_attribute_mappings", + "primaryKey": { + "name": "org_ingest_attribute_mappings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_keys", + "primaryKey": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_sampling_policies", + "primaryKey": { + "name": "org_ingest_sampling_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_onboarding_state", + "primaryKey": { + "name": "org_onboarding_state_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_openrouter_keys", + "primaryKey": { + "name": "org_openrouter_keys_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_recommendation_issues", + "primaryKey": { + "name": "org_recommendation_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_support_channels", + "primaryKey": { + "name": "org_support_channels_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_connections", + "primaryKey": { + "name": "planetscale_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_databases", + "primaryKey": { + "name": "planetscale_databases_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_events", + "primaryKey": { + "name": "planetscale_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_issue_receipts", + "primaryKey": { + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "columns": [ + "org_id", + "event_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_poll_state", + "primaryKey": { + "name": "planetscale_poll_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_edits", + "primaryKey": { + "name": "pr_review_edits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_finding_embeddings", + "primaryKey": { + "name": "pr_review_finding_embeddings_pkey", + "columns": [ + "finding_id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_findings", + "primaryKey": { + "name": "pr_review_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_merge_steps", + "primaryKey": { + "name": "pr_review_merge_steps_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_replies", + "primaryKey": { + "name": "pr_review_replies_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_settings", + "primaryKey": { + "name": "pr_review_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "pr_reviews", + "primaryKey": { + "name": "pr_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_connections", + "primaryKey": { + "name": "railway_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_environments", + "primaryKey": { + "name": "railway_environments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_target_checks", + "primaryKey": { + "name": "scrape_target_checks_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_targets", + "primaryKey": { + "name": "scrape_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_commits", + "primaryKey": { + "name": "vcs_commits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_installations", + "primaryKey": { + "name": "vcs_installations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repositories", + "primaryKey": { + "name": "vcs_repositories_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repository_branches", + "primaryKey": { + "name": "vcs_repository_branches_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "column", + "table": "actors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "agent_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "model", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "capabilities_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "last_active_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "impact", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "summary", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "reason", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "details", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "related_to", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_type", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_model", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_version", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_runs_per_day", + "position": 2, + "type": "integer", + "notNull": true, + "default": "20", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_passes_per_day", + "position": 3, + "type": "integer", + "notNull": true, + "default": "90", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_by", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "rule_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "destination_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "delivery_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "scheduled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claim_expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_by", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempted_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_message", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_reference", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "response_code", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "error_message", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "incident_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_key", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "signal_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "severity", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "comparator", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold_upper", + "position": 11, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "first_triggered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_triggered_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_observed_value", + "position": 15, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_sample_count", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_evaluated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "dedupe_key", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_delivered_event_type", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_notified_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "hold_reason", + "position": 21, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "held_since", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 23, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "rule_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "last_scheduled_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "group_key", + "position": 2, + "type": "text", + "notNull": true, + "default": "'__total__'", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_breaches", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_healthy", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_status", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_value", + "position": 6, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_sample_count", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_evaluated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notes", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notification_template_json", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "severity", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "service_names_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "exclude_service_names_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "environments_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 14, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 15, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 16, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 17, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 18, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 19, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 20, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 21, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 23, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 24, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 25, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 26, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 28, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 30, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 32, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 33, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 5, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 10, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 16, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 8, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "project_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "logs_enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_received_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_logs_enabled", + "position": 13, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_metrics_enabled", + "position": 14, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "setup_reported_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_by", + "position": 16, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'investigating'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "seeded_by", + "position": 3, + "type": "text", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "subject_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_kind", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "issue_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "report_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "progress_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "severity", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "confidence", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "input_tokens", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "output_tokens", + "position": 15, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "started_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "autonomous_turns", + "position": 18, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "diagnosed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "updated_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "rules_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "severity", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "source", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "reason", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "run_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "investigation_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "payload_json", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "delivery_results_json", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "attempts", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "device_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "incident_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "activity_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "push_token", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_reason", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "request_id_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "redirect_uri", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "state", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "resource", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "scopes", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "code_challenge", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "authorization_code_hash", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_org_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_roles", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_email", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "denied_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "used_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_name", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "redirect_uris", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_uri", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "token_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "client_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "resource", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "scopes", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "org_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "roles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_email", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "access_key_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "replaced_by_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "revoked_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "platform", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "token", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "bundle_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "app_version", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "live_activity_start_token", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "device_name", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "preferences", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_reason", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_pushed_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_seen_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "state", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "initiated_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "redirect_uri", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "return_to", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "code_verifier", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "expires_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_email", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_account_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "granted_accounts_json", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "connected_by_user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "scope", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_ciphertext", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_iv", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_tag", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_ciphertext", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_iv", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_tag", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "revoked_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "workflow_instance_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "phase", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "current_migration", + "position": 4, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_total", + "position": 5, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_done", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "applied_versions", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "skipped", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "error_message", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "started_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "finished_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_url", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_user", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_ciphertext", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_iv", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_tag", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_database", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "sync_status", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "schema_version", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_context", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "target_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "operation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key_hash", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_hash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_rotated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_rotated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "trace_sample_ratio", + "position": 1, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_error_spans", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_slow_spans_ms", + "position": 3, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "user_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "email", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "role", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "demo_data_requested", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "onboarding_completed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "checklist_dismissed_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "first_data_received_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "welcome_email_sent_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "connect_nudge_email_sent_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "stalled_email_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "activation_email_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_reserved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_ciphertext", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_iv", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_tag", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_hint", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "number", + "position": 2, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "recommendation_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "source_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "canonical_key", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "usage_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "opened_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "resolved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_name", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reservation_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reserved_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_by_user_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "ps_organization", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "connected_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "scrape_target_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_ciphertext", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_iv", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_tag", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "detected_permissions_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": "'mysql'", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "region", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "plan", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "branches_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "deleted_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "branch_name", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "category", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "external_id", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "title", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "source", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "actor_login", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "payload_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "occurred_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "event_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "processed_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "dataset", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "database_id", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "watermark_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_success_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "reply_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "seq", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "path", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "old_text", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "new_text", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "finding_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "embedding", + "position": 4, + "type": "real[]", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "review_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "handle", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "path", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "line", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "category", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "title", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "comment_id", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "resolved_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_up", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_down", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "title", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "source", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "path", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "reminded_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "first_review_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "last_review_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "comment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "surface", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "thread_root_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "author_login", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "command", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "head_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "reply_url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "commit_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "model", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "defaults", + "position": 2, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "head_sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "base_sha", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "url", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "title", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "skip_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_attempt", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "report_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "score", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "check_run_url", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_url", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "review_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "input_tokens", + "position": 21, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "output_tokens", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "started_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "finished_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merged_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "telemetry_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merge_commit_sha", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_status", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_after", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "created_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "updated_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_ciphertext", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_iv", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_tag", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "workspace_names", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "connected_by_user_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "auth_failed_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "discovered_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "services_json", + "position": 7, + "type": "text", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "enabled", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "watermark_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "id", + "position": 0, + "type": "integer", + "notNull": true, + "default": null, + "identity": "by default" + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "target_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "sub_target_key", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "error", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "duration_ms", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_scraped", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_post_relabel", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "url", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "target_type", + "position": 5, + "type": "text", + "notNull": true, + "default": "'prometheus'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "discovery_config_json", + "position": 6, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "scrape_interval_seconds", + "position": 7, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "labels_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_type", + "position": 9, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "managed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_ciphertext", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_iv", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_tag", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "enabled", + "position": 14, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "message", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_email", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_avatar_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "authored_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "committed_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "html_url", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_login", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_type", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_account_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_avatar_url", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_selection", + "position": 8, + "type": "text", + "notNull": true, + "default": "'all'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "suspended_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "ci_traces_enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "owner", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "full_name", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "default_branch", + "position": 8, + "type": "text", + "notNull": true, + "default": "'main'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "tracked_branch", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "html_url", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_private", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_archived", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "sync_status", + "position": 14, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_synced_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_sync_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_config", + "position": 18, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "is_default", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "head_sha", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_agent_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"agent_name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"claim_expires_at\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_delivery_attempt_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"attempt_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_incident_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_open_group_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"", + "\"group_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_rule_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_claims", + "name": "alert_rule_claims_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_states", + "name": "alert_rule_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_org_scope_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scope_type\"", + "\"scope_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_kind\"", + "\"incident_id\"" + ], + "where": "\"incident_id\" is not null" + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_device_incident_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"device_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"authorization_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_family_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"family_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_platform_token_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"platform\"", + "\"token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_auth_states", + "name": "oauth_auth_states_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_provider_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_attribute_mappings", + "name": "org_ingest_attribute_mappings_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_private_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"private_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"recommendation_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"event_type\"", + "\"external_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_db_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_issue_receipts", + "name": "planetscale_issue_receipts_processed_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"processed_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_dataset_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dataset\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_edits", + "name": "pr_review_edits_reply_seq_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"reply_id\"", + "\"seq\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_finding_embeddings", + "name": "pr_review_finding_embeddings_org_model_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"model\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_pr_handle_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"handle\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_pr_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_repo_comment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"comment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_post_merge_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"post_merge_after\"" + ], + "where": "\"post_merge_status\" = 'waiting'" + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_head_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"head_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_org_env_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"environment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_org_sha_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_repo_sha_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_provider_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"provider\"", + "\"external_installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_installation_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_repo_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"external_repo_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_repo_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "foreign_key", + "table": "dashboard_shares", + "name": "dashboard_shares_dashboard_fk", + "columns": [ + "org_id", + "dashboard_id" + ], + "foreignTable": "dashboards", + "foreignColumns": [ + "org_id", + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "columns": [ + "target_id" + ], + "foreignTable": "scrape_targets", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/src/tables/gcp-connectors.ts b/packages/db/src/tables/gcp-connectors.ts new file mode 100644 index 0000000000..c7645f9438 --- /dev/null +++ b/packages/db/src/tables/gcp-connectors.ts @@ -0,0 +1,51 @@ +import * as PG from "@maple-dev/effect-orm/postgres" +import { + GcpConnectorId, + GcpProjectId, + GcpResourceNumber, + GcpScopeType, + OrgId, +} from "@maple/domain/primitives" +import { Schema } from "effect" + +// One row per connected Google Cloud scope: a project, or everything under a folder or +// organization. `project_id` is the host project, where Maple's own resources live (Pub/Sub topic, +// subscription, service account); for a project scope it equals `scope_id`. The ingest gateway's +// `/v1/logpush/gcp/...` receiver authenticates a push by `id` + `secret_hash` and writes +// `last_received_at` / `last_error` and the setup report. `metrics_enabled` covers metrics and +// resource collection. +export const GcpConnectors = PG.table("gcp_connectors", { + columns: { + id: PG.brand(PG.text, GcpConnectorId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + scopeType: PG.column(PG.brand(PG.text, GcpScopeType), { name: "scope_type" }), + scopeId: PG.column(PG.brand(PG.text, Schema.Union([GcpProjectId, GcpResourceNumber])), { + name: "scope_id", + }), + projectId: PG.column(PG.brand(PG.text, GcpProjectId), { name: "project_id" }), + logsEnabled: PG.column(PG.bool, { name: "logs_enabled", default: true }), + metricsEnabled: PG.column(PG.bool, { name: "metrics_enabled", default: false }), + secretCiphertext: PG.column(PG.text, { name: "secret_ciphertext" }), + secretIv: PG.column(PG.text, { name: "secret_iv" }), + secretTag: PG.column(PG.text, { name: "secret_tag" }), + secretHash: PG.column(PG.text, { name: "secret_hash" }), + lastReceivedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "last_received_at" }), + lastError: PG.column(PG.nullable(PG.text), { name: "last_error" }), + // What the latest setup script run reported it set up in Google Cloud, written by the + // receiver. Null until a run reports: the switches above say what should be there. + appliedLogsEnabled: PG.column(PG.nullable(PG.bool), { name: "applied_logs_enabled" }), + appliedMetricsEnabled: PG.column(PG.nullable(PG.bool), { name: "applied_metrics_enabled" }), + setupReportedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "setup_reported_at" }), + createdBy: PG.column(PG.text, { name: "created_by" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), + }, + primaryKey: ["id"], + indexes: [ + PG.uniqueIndex("gcp_connectors_org_scope_idx", ["orgId", "scopeType", "scopeId"]), + PG.uniqueIndex("gcp_connectors_secret_hash_unique", ["secretHash"]), + ], + tenantColumn: "orgId", +}) + +export type GcpConnectorRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index 25f5086958..a03322c99c 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -35,6 +35,7 @@ import { ErrorIssueVerifications, } from "./errors" import { IssueEscalationPolicies, IssueEscalations } from "./escalations" +import { GcpConnectors } from "./gcp-connectors" import { Investigations } from "./investigations" import { LiveActivities } from "./live-activities" import { McpOAuthClients, McpOAuthAuthorizations, McpOAuthRefreshTokens } from "./mcp-oauth" @@ -89,6 +90,7 @@ export * from "./dashboards" export * from "./digest" export * from "./errors" export * from "./escalations" +export * from "./gcp-connectors" export * from "./investigations" export * from "./live-activities" export * from "./mcp-oauth" @@ -147,6 +149,7 @@ export const allTables: ReadonlyArray> ErrorIssueVerifications, IssueEscalationPolicies, IssueEscalations, + GcpConnectors, Investigations, LiveActivities, McpOAuthClients, diff --git a/packages/domain/package.json b/packages/domain/package.json index 347844e735..bf49eecb08 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -11,6 +11,7 @@ "./billing": "./src/billing.ts", "./billing-tax-ids": "./src/billing-tax-ids.ts", "./format": "./src/format.ts", + "./gcp": "./src/gcp.ts", "./gen-ai": "./src/gen-ai.ts", "./glob": "./src/glob.ts", "./http": "./src/http/index.ts", diff --git a/packages/domain/src/gcp.test.ts b/packages/domain/src/gcp.test.ts new file mode 100644 index 0000000000..502ea6aa0f --- /dev/null +++ b/packages/domain/src/gcp.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { gcpConnectorResourceNames } from "./gcp" +import { GcpConnectorId } from "./primitives" + +describe("gcpConnectorResourceNames", () => { + it("derives a service account id inside Google's limits from the connector id alone", () => { + const names = gcpConnectorResourceNames( + Schema.decodeUnknownSync(GcpConnectorId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8"), + ) + expect(names).toEqual({ + serviceAccountId: "maple-018f2b3c4d5e4f708192a3b4", + topic: "maple-018f2b3c4d5e4f708192a3b4", + subscription: "maple-018f2b3c4d5e4f708192a3b4", + sink: "maple-018f2b3c4d5e4f708192a3b4", + }) + // Google: 6-30 characters, [a-z]([-a-z0-9]*[a-z0-9]). + expect(names.serviceAccountId).toMatch(/^[a-z][-a-z0-9]{4,28}[a-z0-9]$/) + }) +}) diff --git a/packages/domain/src/gcp.ts b/packages/domain/src/gcp.ts new file mode 100644 index 0000000000..0eee9d3a69 --- /dev/null +++ b/packages/domain/src/gcp.ts @@ -0,0 +1,12 @@ +import type { GcpConnectorId } from "./primitives" + +/** + * Names of what a connector's setup script creates in the customer's Google Cloud project. + * Derived from the connector id alone, so a caller can never pick them: one connector's metrics + * account is not the one another organization's connector impersonates. 24 hex characters keep + * the service account id at Google's 30-character limit. + */ +export const gcpConnectorResourceNames = (connectorId: GcpConnectorId) => { + const name = `maple-${connectorId.replaceAll("-", "").slice(0, 24)}` + return { serviceAccountId: name, topic: name, subscription: name, sink: name } +} diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 94c3a43151..e2e88a762c 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -47,6 +47,8 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/ErrorIssuePullRequestNotFoundError", "@maple/http/errors/ErrorIssueTransitionError", "@maple/http/errors/ErrorValidationError", + "@maple/http/errors/GcpMetricsUnavailableError", + "@maple/http/errors/GcpScopeAlreadyConnectedError", "@maple/http/errors/IncidentTriageUnauthorizedError", "@maple/http/errors/IngestAttributeMappingForbiddenError", "@maple/http/errors/IngestAttributeMappingNotFoundError", diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 8a5e87fa22..58e7b5ddf8 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -1,6 +1,13 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" -import { ExternalUserId, ScrapeTargetId, UserId } from "../primitives" +import { + ExternalUserId, + GcpProjectId, + GcpResourceNumber, + GcpScopeType, + ScrapeTargetId, + UserId, +} from "../primitives" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { PrReviewOrgSettings, PrReviewRepositoryConfig } from "./pr-review" @@ -1042,6 +1049,42 @@ export class IntegrationsNotFoundError extends HttpTaggedError()( + "@maple/http/errors/GcpScopeAlreadyConnectedError", + { + scopeType: GcpScopeType, + scopeId: Schema.Union([GcpProjectId, GcpResourceNumber]), + message: Schema.String, + }, + { + status: 409, + code: "gcp_scope_already_connected", + title: "Already connected", + param: "scope_id", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + +/** Metrics were requested on a deployment that has no Google identity to collect them with. */ +export class GcpMetricsUnavailableError extends HttpTaggedError()( + "@maple/http/errors/GcpMetricsUnavailableError", + { + message: Schema.String, + }, + { + status: 409, + code: "gcp_metrics_unavailable", + title: "Metrics collection is not available", + param: "metrics_enabled", + retry: "never", + recovery: "none", + exposure: "public_message", + }, +) {} + export type IntegrationHttpError = | IntegrationsForbiddenError | IntegrationsConfigurationError diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 87862542ab..dec690376b 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -10,6 +10,7 @@ import { V2AuditLogApiGroup } from "./audit-log" import { V2DashboardsApiGroup } from "./dashboards" import { V2IngestKeysApiGroup } from "./ingest-keys" import { V2ChatIntegrationsApiGroup } from "./integrations-chat" +import { V2GcpIntegrationsApiGroup } from "./integrations-gcp" import { V2PlanetScaleIntegrationsApiGroup } from "./integrations-planetscale" import { V2ErrorIssuesApiGroup } from "./error-issues" import { V2InvestigationsApiGroup } from "./investigations" @@ -97,6 +98,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2IngestKeysApiGroup) .add(V2ChatIntegrationsApiGroup) .add(V2PlanetScaleIntegrationsApiGroup) + .add(V2GcpIntegrationsApiGroup) .add(V2ErrorIssuesApiGroup) .add(V2AttributeMappingsApiGroup) .add(V2AuditLogApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 1ddbd9d5f9..11f7b80266 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -14,6 +14,7 @@ export * from "./errors" export * from "./error-issues" export * from "./ingest-keys" export * from "./integrations-chat" +export * from "./integrations-gcp" export * from "./integrations-planetscale" export * from "./investigations" export * from "./agent-feedback" diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts new file mode 100644 index 0000000000..818cbc5110 --- /dev/null +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -0,0 +1,368 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Schema } from "effect" +import { GcpConnectorId, GcpLogFilter, GcpProjectId, GcpResourceNumber, GcpScopeType } from "../../primitives" +import { + GcpMetricsUnavailableError, + GcpScopeAlreadyConnectedError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, + IntegrationsValidationError, +} from "../integrations" +import { AuthorizationV2 } from "./auth" +import { wireExample, Timestamp } from "./envelopes" +import { V2InsufficientPermissions } from "./errors" +import { publicErrors } from "./public-error" +import { PublicId, PublicIdPrefixes } from "./public-id" + +// Google Cloud integration. No OAuth: an org registers a connector for a project, a folder or a +// whole organization, and an administrator runs the generated `gcloud` script. The script routes +// logs through Pub/Sub to the ingest gateway and lets Maple read metrics and the resource +// inventory, each only if the connector opted in. Public v2 from the start so scripted setups can +// use a scoped API key. + +/** `gcpc_…` public ID ⇄ internal `GcpConnectorId` (raw UUID). */ +export const GcpConnectorPublicId = PublicId(PublicIdPrefixes.gcpConnector, GcpConnectorId) + +const connectorExample = { + id: "gcpc_YofPTrK9782DWwcnXhpcCw", + object: "gcp_connector", + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-observability", + logs_enabled: true, + metrics_enabled: true, + created_at: "2026-10-01T12:00:00.000Z", + last_log_received_at: "2026-10-08T09:12:00.000Z", + last_log_error: null, + applied_logs_enabled: true, + applied_metrics_enabled: true, + setup_reported_at: "2026-10-01T12:05:00.000Z", +} as const + +export const V2GcpConnector = Schema.Struct({ + id: GcpConnectorPublicId, + object: Schema.Literal("gcp_connector").annotate({ + description: 'The object type — always `"gcp_connector"`.', + }), + scope_type: GcpScopeType.annotate({ + description: + "What the connector covers: one `project`, or every project under a `folder` or `organization`, including projects created later.", + examples: ["organization"], + }), + scope_id: Schema.String.annotate({ + description: "The project ID, or the numeric folder or organization ID, named by `scope_type`.", + examples: ["123456789012"], + }), + project_id: Schema.String.annotate({ + description: + "The host project: where the setup script creates Maple's own resources (Pub/Sub topic, subscription, service account). Equal to `scope_id` for a `project` scope.", + examples: ["acme-observability"], + }), + logs_enabled: Schema.Boolean.annotate({ + description: "Whether the setup script forwards logs to Maple.", + examples: [true], + }), + metrics_enabled: Schema.Boolean.annotate({ + description: + "Whether the setup script lets Maple read Cloud Monitoring metrics and the Cloud Asset resource inventory.", + examples: [true], + }), + created_at: Timestamp.annotate({ description: "When the connector was created." }), + last_log_received_at: Schema.NullOr(Timestamp).annotate({ + description: + "When Maple last accepted a log push from this connector, or `null` before the first one. Stays `null` until the setup script has run.", + }), + last_log_error: Schema.NullOr(Schema.String).annotate({ + description: "Why the most recent log push was rejected, or `null`.", + examples: [null], + }), + applied_logs_enabled: Schema.NullOr(Schema.Boolean).annotate({ + description: + "Whether log forwarding is set up in Google Cloud, as the latest setup script run reported it. `null` until a run reports. The setup script has to run while `logs_enabled` is `true` and this is not, or `logs_enabled` is `false` and this is `true`.", + examples: [true], + }), + applied_metrics_enabled: Schema.NullOr(Schema.Boolean).annotate({ + description: + "Whether metrics and resource access is set up in Google Cloud, as the latest setup script run reported it. `null` until a run reports. The setup script has to run while `metrics_enabled` is `true` and this is not, or `metrics_enabled` is `false` and this is `true`.", + examples: [true], + }), + setup_reported_at: Schema.NullOr(Timestamp).annotate({ + description: + "When a setup or cleanup script run last reported to Maple, or `null`. A run reports within seconds of finishing, before its first log or metric arrives.", + }), +}).annotate({ + identifier: "GcpConnector", + title: "Google Cloud connector", + description: "One connected Google Cloud project, folder or organization.", + examples: [wireExample(connectorExample)], +}) +export type V2GcpConnector = Schema.Schema.Type + +export const V2GcpIntegration = Schema.Struct({ + object: Schema.Literal("gcp_integration").annotate({ + description: 'The object type — always `"gcp_integration"`.', + }), + metrics_available: Schema.Boolean.annotate({ + description: + "Whether this Maple deployment can collect metrics and resources from Google Cloud. When `false`, `metrics_enabled` cannot be turned on.", + examples: [true], + }), + connectors: Schema.Array(V2GcpConnector).annotate({ + description: "Every connector of the organization, oldest first.", + }), +}).annotate({ + identifier: "GcpIntegration", + title: "Google Cloud integration status", + description: + 'The Google Cloud integration state for the authenticated organization. Note: `connectors` is **not** the standard `{ object: "list", data, has_more, next_cursor }` envelope: an organization has a handful of connectors and they are returned whole next to `metrics_available`.', + examples: [ + wireExample({ object: "gcp_integration", metrics_available: true, connectors: [connectorExample] }), + ], +}) +export type V2GcpIntegration = Schema.Schema.Type + +const logsEnabledField = Schema.optionalKey(Schema.Boolean).annotate({ + description: "Forward logs to Maple.", + examples: [true], +}) +const metricsEnabledField = Schema.optionalKey(Schema.Boolean).annotate({ + description: + "Let Maple read metrics and the resource inventory. Rejected when the status reports `metrics_available: false`.", + examples: [false], +}) + +export const V2GcpCreateConnectorRequest = Schema.Union([ + Schema.Struct({ + scope_type: Schema.Literal("project"), + scope_id: GcpProjectId.annotate({ description: "The project ID (not its name or number)." }), + project_id: Schema.optionalKey(GcpProjectId).annotate({ + description: "A project is its own host project: omit this, or repeat `scope_id`.", + }), + logs_enabled: logsEnabledField, + metrics_enabled: metricsEnabledField, + }) + .check( + // Returns the message: a filter's `description` is not what surfaces on failure. + Schema.makeFilter( + (value) => + value.project_id === undefined || + value.project_id === value.scope_id || + "project_id must be omitted or equal scope_id when scope_type is project", + ), + ) + .annotate({ identifier: "GcpCreateProjectConnector", title: "Project connector" }), + Schema.Struct({ + scope_type: Schema.Literals(["folder", "organization"]), + scope_id: GcpResourceNumber.annotate({ description: "The numeric folder or organization ID." }), + project_id: GcpProjectId.annotate({ + description: + "The host project for Maple's own resources (Pub/Sub topic, subscription, service account): a project ID, usually a project inside the folder or organization.", + }), + logs_enabled: logsEnabledField, + metrics_enabled: metricsEnabledField, + }).annotate({ identifier: "GcpCreateAggregatedConnector", title: "Folder or organization connector" }), +]).annotate({ + identifier: "GcpCreateConnectorRequest", + title: "Google Cloud connector create request", + description: + "What to connect, discriminated on `scope_type`. `logs_enabled` defaults to `true` and `metrics_enabled` to `false`; at least one must be on.", + examples: [ + wireExample({ scope_type: "project", scope_id: "acme-prod" }), + wireExample({ + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-observability", + metrics_enabled: true, + }), + ], +}) +export type V2GcpCreateConnectorRequest = Schema.Schema.Type + +export const V2GcpUpdateConnectorRequest = Schema.Struct({ + logs_enabled: logsEnabledField, + metrics_enabled: metricsEnabledField, +}).annotate({ + identifier: "GcpUpdateConnectorRequest", + title: "Google Cloud connector update request", + description: + "What the connector collects; omitted fields are unchanged. At least one must stay on: delete the connector to disconnect.", + examples: [wireExample({ metrics_enabled: true })], +}) +export type V2GcpUpdateConnectorRequest = Schema.Schema.Type + +export const V2GcpSetupScriptsRequest = Schema.Struct({ + log_filter: Schema.optionalKey(GcpLogFilter).annotate({ + description: + "The log filter the setup script carries. `keep` (the default): a sink that already exists keeps its filter, and a new one gets Maple's default. `default`: the script sets Maple's default, which leaves out Data Access audit logs, load balancer health checks, Kubernetes lease renewals, VM serial console output and GKE container logs. `include_gke_container_logs`: the script sets the default with GKE container logs included. Logs from workloads that also send them to Maple over OpenTelemetry are then stored twice.", + examples: ["keep"], + }), +}).annotate({ + identifier: "GcpSetupScriptsRequest", + title: "Google Cloud setup scripts request", + description: "Options for rendering a connector's scripts.", + examples: [wireExample({ log_filter: "default" })], +}) +export type V2GcpSetupScriptsRequest = Schema.Schema.Type + +// What a script field holds: the script as a here-document for a bash process of its own. +const SCRIPT_EXAMPLE = + " { …\nbash /dev/fd/3 3<<'MAPLE_SETUP_SCRIPT'\n#!/usr/bin/env bash\n…\nMAPLE_SETUP_SCRIPT\n}\n" + +export const V2GcpSetupScripts = Schema.Struct({ + object: Schema.Literal("gcp_connector.setup_scripts").annotate({ + description: 'The object type — always `"gcp_connector.setup_scripts"`.', + }), + setup_script: Schema.String.annotate({ + description: + "Text to paste into Cloud Shell: a bash script, wrapped so that it runs in a bash process of its own. It sets up what the connector has enabled, removes what it has not, and tells Maple what it applied, so run it again after changing `logs_enabled` or `metrics_enabled`. It embeds the connector's push secret: treat it as a credential.", + examples: [SCRIPT_EXAMPLE], + }), + cleanup_script: Schema.String.annotate({ + description: + "Text to paste into Cloud Shell that removes everything the setup script created and tells Maple that it ran. It embeds the connector's push secret: treat it as a credential.", + examples: [SCRIPT_EXAMPLE], + }), +}).annotate({ + identifier: "GcpSetupScripts", + title: "Google Cloud setup scripts", + description: "The scripts that connect and disconnect one Google Cloud project, folder or organization.", + examples: [ + wireExample({ + object: "gcp_connector.setup_scripts", + setup_script: SCRIPT_EXAMPLE, + cleanup_script: SCRIPT_EXAMPLE, + }), + ], +}) +export type V2GcpSetupScripts = Schema.Schema.Type + +export const V2GcpConnectorDeleteResponse = Schema.Struct({ + id: GcpConnectorPublicId, + object: Schema.Literal("gcp_connector").annotate({ + description: 'The object type — always `"gcp_connector"`.', + }), + deleted: Schema.Literal(true).annotate({ + description: "Always `true` — the connector no longer exists and Maple rejects its log pushes.", + }), + cleanup_script: Schema.String.annotate({ + description: + "Text to paste into Cloud Shell that removes the resources the setup script created. Maple has no write access to Google Cloud, so an administrator runs it. The connector is gone, so this copy carries no secret.", + examples: [SCRIPT_EXAMPLE], + }), +}).annotate({ + identifier: "GcpConnectorDeleteResponse", + title: "Google Cloud connector delete response", + description: "Confirmation that a connector was deleted, with the script that cleans up Google Cloud.", + examples: [ + wireExample({ + id: connectorExample.id, + object: "gcp_connector", + deleted: true, + cleanup_script: SCRIPT_EXAMPLE, + }), + ], +}) +export type V2GcpConnectorDeleteResponse = Schema.Schema.Type + +const [scopeAlreadyConnected, metricsUnavailable, validation, connectorNotFound, integrationPersistence] = + publicErrors( + GcpScopeAlreadyConnectedError, + GcpMetricsUnavailableError, + IntegrationsValidationError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, + ) + +export class V2GcpIntegrationsApiGroup extends HttpApiGroup.make("gcpIntegration") + .add( + HttpApiEndpoint.get("status", "/", { + success: V2GcpIntegration, + error: [integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getGcpIntegration", + summary: "Retrieve Google Cloud integration status", + description: + "Returns the organization's Google Cloud connectors with what their setup script last reported and the time and outcome of their latest log push, and whether metrics collection is available. Requires the `integrations:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("createConnector", "/connectors", { + payload: V2GcpCreateConnectorRequest, + success: V2GcpConnector, + error: [ + V2InsufficientPermissions.schema, + scopeAlreadyConnected, + metricsUnavailable, + validation, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createGcpConnector", + summary: "Connect a Google Cloud project, folder or organization", + description: + "Registers a connector for one project, or for every project under a folder or organization. Nothing reaches Maple until an administrator runs the script from `setup_scripts`. A project, folder or organization can be connected once per Maple organization. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("updateConnector", "/connectors/:id", { + params: { id: GcpConnectorPublicId }, + payload: V2GcpUpdateConnectorRequest, + success: V2GcpConnector, + error: [ + V2InsufficientPermissions.schema, + connectorNotFound, + metricsUnavailable, + validation, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateGcpConnector", + summary: "Change what a connector collects", + description: + "Turns log forwarding or metrics and resource collection on or off. Maple cannot change Google Cloud itself: re-run the script from `setup_scripts` afterwards, which sets up what is now enabled and removes what is not. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("setupScripts", "/connectors/:id/setup_scripts", { + params: { id: GcpConnectorPublicId }, + payload: V2GcpSetupScriptsRequest, + success: V2GcpSetupScripts, + error: [V2InsufficientPermissions.schema, connectorNotFound, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "renderGcpConnectorSetupScripts", + summary: "Render a connector's setup and cleanup scripts", + description: + "Returns the `gcloud` scripts for one connector. Rendering changes nothing, but both scripts carry the connector's push secret, so this requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("deleteConnector", "/connectors/:id", { + params: { id: GcpConnectorPublicId }, + success: V2GcpConnectorDeleteResponse, + error: [V2InsufficientPermissions.schema, connectorNotFound, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteGcpConnector", + summary: "Disconnect a Google Cloud project, folder or organization", + description: + "Deletes the connector, after which Maple rejects its log pushes and stops reading its metrics. Already-ingested data is unaffected. The response carries the cleanup script to run in Google Cloud. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .prefix("/v2/integrations/gcp") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Google Cloud Integration", + description: + "Connect Google Cloud projects, folders or organizations: register a connector, choose whether it forwards logs and whether Maple may read metrics and resources, fetch the `gcloud` scripts that set it up, and see whether logs are arriving.", + }), + ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 6bc6446094..24031e89b4 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -85,6 +85,7 @@ describe("MapleApiV2 OpenAPI", () => { "DELETE /v2/dashboards/{id}/widgets/{widget_id}/share", "DELETE /v2/integrations/chat_connectors/{connector}/identity", "DELETE /v2/integrations/chat_workspaces/{id}", + "DELETE /v2/integrations/gcp/connectors/{id}", "DELETE /v2/integrations/planetscale", "DELETE /v2/mobile_devices/{token}", "DELETE /v2/mobile_devices/{token}/live_activities/{incident_id}", @@ -128,6 +129,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/instrumentation/signals", "GET /v2/integrations/chat_connectors", "GET /v2/integrations/chat_workspaces/{id}/destinations", + "GET /v2/integrations/gcp", "GET /v2/integrations/planetscale", "GET /v2/integrations/planetscale/databases", "GET /v2/integrations/planetscale/organizations", @@ -160,6 +162,7 @@ describe("MapleApiV2 OpenAPI", () => { "PATCH /v2/attribute_mappings/{id}", "PATCH /v2/dashboards/{id}", "PATCH /v2/integrations/chat_workspaces/{id}", + "PATCH /v2/integrations/gcp/connectors/{id}", "PATCH /v2/scrape_targets/{id}", "POST /v2/agent_feedback", "POST /v2/alerts/destinations", @@ -185,6 +188,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/instrumentation/recommendations/{id}/reopen", "POST /v2/integrations/chat_connectors/{connector}/identity/link", "POST /v2/integrations/chat_connectors/{connector}/install", + "POST /v2/integrations/gcp/connectors", + "POST /v2/integrations/gcp/connectors/{id}/setup_scripts", "POST /v2/integrations/planetscale/connect", "POST /v2/integrations/planetscale/events", "POST /v2/integrations/planetscale/metrics_token", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 06f31ff373..3640b119ef 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -41,6 +41,7 @@ export const PublicIdPrefixes = { mobileDevice: "mdev", chatWorkspace: "chatw", agentFeedback: "afb", + gcpConnector: "gcpc", /** Synthetic identity for logs, which have no native OTel record id. */ log: "log", /** Reserved for the future events/webhooks system. */ diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index f29791c0a9..4828090d0d 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -101,6 +101,34 @@ export type PostgresTransactionId = Schema.Schema.Type +export const GcpConnectorId = MapleUuidId("@maple/GcpConnectorId", "GCP Connector ID") +export type GcpConnectorId = Schema.Schema.Type + +// Google's project id rules. The id is interpolated into a shell script, so nothing looser. +export const GcpProjectId = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/u)).pipe( + Schema.brand("@maple/GcpProjectId"), + Schema.annotate({ identifier: "@maple/GcpProjectId", title: "Google Cloud Project ID" }), +) +export type GcpProjectId = Schema.Schema.Type + +/** The numeric id of a Google Cloud folder or organization. */ +export const GcpResourceNumber = Schema.String.check(Schema.isPattern(/^[0-9]{1,20}$/u)).pipe( + Schema.brand("@maple/GcpResourceNumber"), + Schema.annotate({ identifier: "@maple/GcpResourceNumber", title: "Google Cloud Resource Number" }), +) +export type GcpResourceNumber = Schema.Schema.Type + +/** What a Google Cloud connector covers: one project, or everything under a folder or organization. */ +export const GcpScopeType = Schema.Literals(["project", "folder", "organization"]) +export type GcpScopeType = Schema.Schema.Type + +/** + * The log filter a rendered setup script carries. `keep` leaves an existing sink's filter alone, + * the others replace it. `default` leaves out GKE container logs. + */ +export const GcpLogFilter = Schema.Literals(["keep", "default", "include_gke_container_logs"]) +export type GcpLogFilter = Schema.Schema.Type + export const AlertDestinationId = MapleUuidId("@maple/AlertDestinationId", "Alert Destination ID") export type AlertDestinationId = Schema.Schema.Type