From 32ffe235cac93bf67c161996da12997ddd569893 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:09:37 +0200 Subject: [PATCH 01/10] feat(gcp): read-only access for every connection, metrics switch controls collection only The setup script creates the read-only service account for every connection whenever the deployment has a Google identity, and only the cleanup script removes it. metrics_enabled now switches the key-metrics poller and resource sync alone, in Maple, and a connection may have both flags off for live queries only. --- .../routes/v2/integrations-gcp.http.test.ts | 12 +- .../integrations/GcpConnectorService.test.ts | 18 +- .../integrations/GcpConnectorService.ts | 43 ++-- .../integrations/gcp/setup-scripts.test.ts | 197 ++++++++---------- .../integrations/gcp/setup-scripts.ts | 105 +++++----- .../domain/src/http/v2/integrations-gcp.ts | 31 +-- 6 files changed, 200 insertions(+), 206 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index e7ca5b0e6..28dc0d25d 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -253,10 +253,20 @@ describe("v2 gcp integration over HTTP", () => { 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") + + // With both off the connection serves live queries, and the script keeps its reader. + const liveOnly = await harness.request("PATCH", path, admin.secret, { metrics_enabled: false }) + expect(liveOnly.status).toBe(200) + expect(liveOnly.body).toMatchObject({ logs_enabled: false, metrics_enabled: false }) + const reader = await harness.request("POST", `${path}/setup_scripts`, admin.secret, {}) + expect(reader.body.setup_script).toContain( + "MAPLE_SERVICE_ACCOUNT='collector@maple-prod.iam.gserviceaccount.com'", + ) + expect(reader.body.setup_script).not.toContain("service-accounts delete") await harness.dispose() }) - it("keeps one capability on, and refuses metrics on a deployment without a Google identity", async () => { + it("refuses key metrics, and a connection without log forwarding, on a deployment without a Google identity", async () => { const harness = makeHarness({ metricsAvailable: false }) const admin = await harness.adminKey() diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index 2c73f2c34..0f2f1e1d1 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -129,7 +129,7 @@ describe("GcpConnectorService", () => { }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) }) - it.effect("requires one capability to stay on and refuses metrics without a Google identity", () => { + it.effect("without a Google identity, refuses key metrics and a connection that forwards no logs", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const gcp = yield* GcpConnectorService @@ -154,7 +154,7 @@ describe("GcpConnectorService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) - it.effect("switches capabilities, and the scripts follow the stored flags", () => { + it.effect("switches capabilities; the setup script follows log forwarding and always sets up the reader", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const gcp = yield* GcpConnectorService @@ -183,6 +183,20 @@ describe("GcpConnectorService", () => { }) assert.deepStrictEqual(logsOnly, { ...connector, metricsEnabled: false }) assert.deepStrictEqual((yield* gcp.status(orgId)).connectors, [logsOnly]) + // The key-metrics switch is Maple's alone: the reader stays for live queries. + const keyMetricsOff = yield* gcp.scripts(orgId, connector.id, scriptOptions) + assert.include(keyMetricsOff.setupScript, `MAPLE_SERVICE_ACCOUNT='${MAPLE_ACCOUNT}'`) + assert.notInclude(keyMetricsOff.setupScript, "service-accounts delete") + + // Both off is a connection for live queries only. + const liveOnly = yield* gcp.update(orgId, connector.id, { logsEnabled: false }) + assert.deepStrictEqual(liveOnly, { ...connector, logsEnabled: false, metricsEnabled: false }) + const created = yield* gcp.create( + orgId, + userId, + projectScope("acme-prod", { logsEnabled: false, metricsEnabled: false }), + ) + assert.isFalse(created.logsEnabled || created.metricsEnabled) }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) }) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index a4a0b324b..d0caaab94 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -36,7 +36,7 @@ export interface GcpConnector { /** 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. */ + /** The 5-minute key-metrics poller and the hourly resource sync. Live queries don't depend on it. */ readonly metricsEnabled: boolean readonly createdAt: number /** Last log push the ingest gateway accepted from this connector. Null until the first one. */ @@ -44,6 +44,7 @@ export interface GcpConnector { 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 + /** Whether the read-only service account that live queries and the poller sign in as is in place. */ readonly appliedMetricsEnabled: boolean | null readonly setupReportedAt: number | null /** Last poll that read the scope's metrics. Null until the first one. */ @@ -82,7 +83,7 @@ export interface GcpResourceInventory { export interface GcpConnectorServiceApi { readonly status: (orgId: OrgId) => Effect.Effect< { - /** False when this deployment has no Google identity to read metrics with. */ + /** False when this deployment has no Google identity: no live queries, no poller. */ readonly metricsAvailable: boolean readonly connectors: ReadonlyArray }, @@ -96,7 +97,10 @@ export interface GcpConnectorServiceApi { GcpConnector, GcpScopeAlreadyConnectedError | GcpCapabilityError | IntegrationsPersistenceError > - /** Omitted flags are unchanged. Google Cloud only follows once the setup script is re-run. */ + /** + * Omitted flags are unchanged. The metrics switch acts in Maple at once; Google Cloud follows a + * log forwarding change once the setup script is re-run. + */ readonly update: ( orgId: OrgId, connectorId: GcpConnectorId, @@ -177,22 +181,24 @@ export class GcpConnectorService extends Context.Service { - 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) { + if (mapleServiceAccountEmail !== undefined) return undefined + if (turningMetricsOn) { return new GcpMetricsUnavailableError({ - message: "Metrics and resource collection is not available on this Maple deployment.", + message: "Key-metrics collection is not available on this Maple deployment.", }) } - return undefined + return logsEnabled + ? undefined + : new IntegrationsValidationError({ + message: + "This Maple deployment can't read Google Cloud metrics, so logs_enabled must stay on. Delete the connector to disconnect.", + }) } const selectRow = (orgId: OrgId, connectorId: GcpConnectorId) => @@ -354,7 +360,7 @@ export class GcpConnectorService extends Context.Service db.transaction( Effect.gen(function* () { - // The row lock keeps one capability on when two updates race. + // The row lock keeps two racing updates from passing the check on stale flags. const [current] = yield* db.run( PG.from(GcpConnectors) .select() @@ -420,9 +426,9 @@ export class GcpConnectorService extends Context.Service ({ ...SCOPES[scope].target, pushEndpoint: PUSH_ENDPOINT, mapleServiceAccountEmail: MAPLE_ACCOUNT, - logsEnabled: capabilities.logs, - metricsEnabled: capabilities.metrics, + logsEnabled, applicationLogs: null, }) @@ -320,7 +319,7 @@ const logsSetupCommands = (scope: Scope) => [ publish('"logs":true'), ] -const metricsSetupCommands = (scope: Scope) => [ +const readerSetupCommands = (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`, @@ -344,7 +343,7 @@ const ACCOUNT_LOOKUP = `iam service-accounts list --project=acme-host --filter=e /** 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) => [ +const readerRemovalCommands = (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`, @@ -358,15 +357,15 @@ 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", () => { + it("sets up logs and read-only access from nothing, at the right scope, one line per step", () => { const { status, stdout, stderr, commands, access } = run( - setup(setupInput(scopeType, { logs: true, metrics: true })), + setup(setupInput(scopeType, true)), ) expect(status).toBe(0) expect(stderr).toBe("") expect(commands).toEqual([ ...logsSetupCommands(scope), - ...metricsSetupCommands(scope), + ...readerSetupCommands(scope), publish('"metrics":true'), ]) expect(access).toEqual([ @@ -382,8 +381,8 @@ describe.each(scopeTypes)("renderGcpSetupScript for a %s", (scopeType) => { `logging read ${DEFAULT_LOG_FILTER} --limit=1 --freshness=1h ${scope.sink}`, ]) expect(stdout).toBe(`Maple setup for ${scopeType} ${scope.id} - Log forwarding on - Metrics and resources on + Log forwarding on + Read-only access on Checking access ✓ Signed in as jane@acme.com @@ -401,7 +400,7 @@ Log forwarding ✓ Sink allowed to publish to the topic ✓ Check message sent to Maple through the topic -Metrics and resources +Read-only access ✓ 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) @@ -412,49 +411,30 @@ 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. + Metrics: Maple can query them within a few 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 })) + const script = setup(setupInput(scopeType, 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("never removes the read-only service account on a re-run, with log forwarding on or off", () => { + for (const logs of [true, false]) { + const { status, commands } = run(setup(setupInput(scopeType, logs)), { describe: "found" }) + expect(status).toBe(0) + expect(commands).not.toContainEqual(expect.stringContaining("remove-iam-policy-binding")) + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + // Every grant is made again, so a run also restores one that was removed. + expect(commands).toEqual(expect.arrayContaining(readerSetupCommands(scope).slice(3))) + } }) - it("with metrics only, removes an earlier log pipeline and reports without a topic", () => { - const script = setup(setupInput(scopeType, { logs: false, metrics: true })) + it("with log forwarding off, removes an earlier log pipeline and reports without a topic", () => { + const script = setup(setupInput(scopeType, false)) expect(script).not.toContain("LOG_FILTER") const { status, stdout, commands, curl } = run(script, { describe: "found" }) expect(status).toBe(0) @@ -468,8 +448,8 @@ Done. Google Cloud matches your Maple switches. 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 off, removing + Read-only access on `) expect(stdout).toContain(` Log forwarding @@ -485,7 +465,7 @@ Log forwarding 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.commands).toEqual([...logsRemovalCommands(scope), ...readerRemovalCommands(scope)]) expect(existing.curl).toEqual([ expect.stringMatching(REPORT('"logs":false,"metrics":false').source.slice(1)), ]) @@ -501,7 +481,7 @@ Log forwarding ✓ Push subscription deleted ✓ Topic deleted -Metrics and resources +Read-only access ✓ Read-only roles removed ✓ Read-only service account deleted @@ -519,7 +499,7 @@ Done. Everything the setup script created is gone. }) describe("the text that is copied", () => { - const script = setup(setupInput("project", { logs: true, metrics: true })) + const script = setup(setupInput("project", true)) it("is the script as a here-document for a child bash", () => { const lines = script.split("\n") @@ -562,7 +542,7 @@ describe("the text that is copied", () => { }) it("is refused when a value would end the here-document early", () => { - const hostile = { ...setupInput("project", { logs: true, metrics: true }) } + const hostile = { ...setupInput("project", true) } const result = Effect.runSync( Effect.result( renderGcpSetupScript({ @@ -589,7 +569,7 @@ describe("the text that is copied", () => { 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 })), + setup(setupInput("project", true)), { describe: "found", }, @@ -607,7 +587,7 @@ Log forwarding ✓ Sink allowed to publish to the topic ✓ Check message sent to Maple through the topic -Metrics and resources +Read-only access ✓ 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) @@ -622,7 +602,7 @@ Done. Everything is in place. it("replaces the sink's filter only when given the runtimes to forward", () => { const filtered = (applicationLogs: GcpSetupScriptInput["applicationLogs"]) => - setup({ ...setupInput("project", { logs: true, metrics: false }), applicationLogs }) + setup({ ...setupInput("project", true), applicationLogs }) const all = GcpLogRuntime.literals expect(filtered(null)).toContain(`LOG_FILTER_MODE='keep'\nLOG_FILTER='${DEFAULT_LOG_FILTER}'`) expect(filtered(["cloud_run"])).toContain( @@ -677,7 +657,7 @@ Done. Everything is in place. 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 })), + setup(setupInput("project", true)), { fail: "sinks create", }, @@ -700,7 +680,7 @@ ${UNFINISHED}`), }) it("prints Google's error without its machine-readable details", () => { - const { stdout } = run(setup(setupInput("project", { logs: true, metrics: true })), { + const { stdout } = run(setup(setupInput("project", true)), { fail: "topics create", answer: [ "WARNING: This command is using service account impersonation.", @@ -717,7 +697,7 @@ ${UNFINISHED}`), }) 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 script = setup(setupInput("project", 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.", @@ -746,7 +726,7 @@ ${UNFINISHED}`), }) it("names the role of the scope for a refused grant there", () => { - const { stdout } = run(setup(setupInput("organization", { logs: false, metrics: true })), { + const { stdout } = run(setup(setupInput("organization", false)), { fail: "organizations add-iam-policy-binding", }) expect(stdout).toContain(` ✗ Couldn't grant the read-only roles.`) @@ -757,7 +737,7 @@ ${UNFINISHED}`), it("changes nothing when the project cannot be opened", () => { const { status, stdout, commands } = run( - setup(setupInput("project", { logs: true, metrics: true })), + setup(setupInput("project", true)), { fail: "projects describe", }, @@ -775,9 +755,9 @@ Checking access ${UNFINISHED}`) }) - it("needs the organization to open for metrics, and leaves that to the permissions for logs alone", () => { + it("needs the organization to open for the read-only roles, 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) + const both = run(setup(setupInput("organization", true)), options) expect(both.status).toBe(1) expect(both.commands).toEqual([]) expect(both.stdout).toContain(" ✗ Can't open organization 123456789012 as jane@acme.com.") @@ -785,14 +765,18 @@ ${UNFINISHED}`) "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) + // Logs Configuration Writer does not include reading the organization. Without a Google + // identity the script sets up log forwarding alone. + const logsOnly = run( + setup({ ...setupInput("organization", true), mapleServiceAccountEmail: undefined }), + 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 script = setup(setupInput("project", true)) const all = [ "pubsub.googleapis.com", "logging.googleapis.com", @@ -825,7 +809,7 @@ ${UNFINISHED}`) }) it("stops before the first change without billing, a sign-in, or gcloud", () => { - const script = setup(setupInput("project", { logs: true, metrics: true })) + const script = setup(setupInput("project", true)) const unbilled = run(script, { billing: "False" }) expect(unbilled.commands).toEqual([]) expect(unbilled.stdout).toContain(` ✗ Project acme-host has no billing account. @@ -856,7 +840,7 @@ ${UNFINISHED}`) 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 script = setup(setupInput("project", true)) 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`]) @@ -880,7 +864,7 @@ ${UNFINISHED}`) 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 })), + setup(setupInput("project", true)), { apis: [ "pubsub.googleapis.com", @@ -901,7 +885,7 @@ ${UNFINISHED}`) 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 })), + setup(setupInput("project", true)), { lacking: "everything" }, ) expect(status).toBe(1) @@ -915,7 +899,7 @@ ${UNFINISHED}`) }) it("names a missing permission before the first change", () => { - const script = setup(setupInput("organization", { logs: true, metrics: true })) + const script = setup(setupInput("organization", true)) const onHost = run(script, { lacking: "resourcemanager.projects.setIamPolicy" }) expect(onHost.status).toBe(1) expect(onHost.commands).toEqual([]) @@ -945,7 +929,7 @@ ${UNFINISHED}`) 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 })), + setup(setupInput("folder", true)), { lacking: "unseen", }, @@ -958,7 +942,7 @@ ${UNFINISHED}`) }) it("says what it could not check instead of passing over it", () => { - const script = setup(setupInput("project", { logs: true, metrics: true })) + const script = setup(setupInput("project", true)) const { status, stdout } = run(script, { lacking: "unanswered", billing: "" as "True" }) expect(status).toBe(0) expect(stdout).toContain( @@ -988,9 +972,7 @@ esac 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, - }) + const setupRun = run(setup(setupInput("project", false)), { gcloud: hostOff }) expect(setupRun.stdout).not.toContain("its API is off") }) @@ -1005,7 +987,7 @@ esac }) 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 })) + const script = setup(setupInput("project", true)) // The signal lands where a step has just finished. const marker = '\nsection "Log forwarding"\n' expect(script).toContain(marker) @@ -1018,7 +1000,7 @@ esac }) it("finishes when Maple cannot be reached, and does not claim a confirmation", () => { - const { status, stdout } = run(setup(setupInput("project", { logs: false, metrics: true })), { + const { status, stdout } = run(setup(setupInput("project", false)), { report: "unreachable", }) expect(status).toBe(0) @@ -1028,7 +1010,7 @@ esac 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 })), { + const unpublished = run(setup(setupInput("project", true)), { fail: "topics publish", }) expect(unpublished.status).toBe(0) @@ -1053,7 +1035,7 @@ case "$*" in esac ` const { status, stdout, commands } = run( - setup(setupInput("project", { logs: false, metrics: true })), + setup(setupInput("project", false)), { gcloud: slow }, ) expect(status).toBe(0) @@ -1061,22 +1043,22 @@ esac 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("leaves the read-only access alone when the deployment has lost its Google identity", () => { + for (const logs of [true, false]) { + const script = setup({ + ...setupInput("organization", logs), + mapleServiceAccountEmail: undefined, + }) + expect(script).toContain( + "# Read-only access: 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(" Read-only access not available on this Maple deployment, left as is\n") + expect(stdout).not.toContain("\nRead-only access\n") + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(commands).not.toContainEqual(expect.stringContaining("iam-policy-binding --member=serviceAccount")) + } }) it("still deletes the service account when one of its role bindings is already gone", () => { @@ -1130,7 +1112,7 @@ esac 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 })), { + const created = run(setup(setupInput("organization", true)), { describe: "denied", }) expect(created.status).toBe(0) @@ -1164,28 +1146,21 @@ esac 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 })), { + const { status, commands } = run(setup(setupInput("folder", false)), { 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), + expect(commands.slice(-readerSetupCommands(SCOPES.folder).length)).toEqual( + readerSetupCommands(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 script = setup(setupInput("project", true)) const recreated = [ `pubsub subscriptions delete ${NAME} --project=acme-host --quiet`, expect.stringMatching(`^pubsub subscriptions create ${NAME} --topic=${NAME} `), @@ -1211,7 +1186,7 @@ esac }) 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 })), { + const granted = run(setup(setupInput("folder", true)), { env: { GCLOUD_RACE: "add-iam-policy-binding" }, }) expect(granted.status).toBe(0) @@ -1234,7 +1209,7 @@ esac }) 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 script = setup(setupInput("folder", true)) const old = run(script, { env: { GCLOUD_VERSION: "469.0.0" } }) expect(old.status).toBe(1) expect(old.commands).toEqual([]) @@ -1243,7 +1218,7 @@ esac }) 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 })), { + const { projects } = run(setup(setupInput("organization", true)), { env: { CLOUDSDK_CORE_PROJECT: "unrelated-project" }, }) expect(new Set(projects)).toEqual(new Set(["acme-host"])) @@ -1251,7 +1226,7 @@ esac 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 })), + setup(setupInput("project", true)), cleanup(SCOPES.project, PUSH_ENDPOINT), ]) { const { fileLogging } = run(script, { describe: "found" }) @@ -1261,7 +1236,7 @@ esac }) it("names the impersonated service account as the one that needs the roles", () => { - const { stdout } = run(setup(setupInput("project", { logs: true, metrics: true })), { + const { stdout } = run(setup(setupInput("project", true)), { fail: "sinks create", env: { GCLOUD_IMPERSONATE: "deployer@acme-host.iam.gserviceaccount.com" }, }) @@ -1283,7 +1258,7 @@ esac }) it("hands gcloud each value as one argument", () => { - const { args } = run(setup(setupInput("folder", { logs: true, metrics: true }))) + const { args } = run(setup(setupInput("folder", true))) expect(args).toEqual( expect.arrayContaining([ `--log-filter=${DEFAULT_LOG_FILTER}`, @@ -1325,7 +1300,7 @@ esac const hostile = `x'; touch pwned; echo '$(touch pwned)` const { dir, status, commands } = run( setup({ - ...setupInput("project", { logs: false, metrics: true }), + ...setupInput("project", false), mapleServiceAccountEmail: hostile, mapleUrl: hostile, }), diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts index 5a6a5f4d0..9b78c9cf8 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -282,7 +282,7 @@ unbind() { // 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 = [ +const READER_APIS = [ "monitoring.googleapis.com", "cloudasset.googleapis.com", "iam.googleapis.com", @@ -441,13 +441,13 @@ fi ` }${checks.sink === undefined ? "" : sinkAndFilter(checks.sink)}` -/** What the enabled sections need, asked of Google before the first change. */ +/** What the sections that set something up need, asked of Google before the first change. */ const permissionChecks = ( scopeType: GcpScopeType, logs: boolean, - metrics: boolean, + reader: boolean, ): AccessChecks["permissions"] => { - if (!logs && !metrics) return [] + if (!logs && !reader) return [] const scope = SCOPES[scopeType] const administrator = "or have an administrator run this script." const host = [ @@ -467,7 +467,7 @@ const permissionChecks = ( "pubsub.subscriptions.update", ] : []), - ...(metrics + ...(reader ? ["iam.serviceAccounts.get", "iam.serviceAccounts.create", "iam.serviceAccounts.setIamPolicy"] : []), ] @@ -476,14 +476,14 @@ const permissionChecks = ( if (scopeType === "project") { return [["projects/$PROJECT_ID", "project $PROJECT_ID", askHost, [...host, ...sink]]] } - const roles = [...(logs ? ["Logs Configuration Writer"] : []), ...(metrics ? [scope.iamRole] : [])] + const roles = [...(logs ? ["Logs Configuration Writer"] : []), ...(reader ? [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`] : [])], + [...sink, ...(reader ? [`resourcemanager.${scopeType}s.setIamPolicy`] : [])], ], ] } @@ -622,9 +622,12 @@ ${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" +const readerSetup = (scopeType: GcpScopeType): string => ` +# ---- Read-only access ---- +# Every connection has it. Maple queries the scope's metrics live as this account and, while key +# metrics are on in Maple, reads them every 5 minutes and lists resources every hour. A re-run +# never removes it: only the cleanup script does. +section "Read-only access" # 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 @@ -633,11 +636,11 @@ section "Metrics and resources" # 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(" ")} + ${READER_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. +# A service account that was deleted (by a cleanup run, or by a setup run from when key metrics +# were 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 @@ -648,8 +651,8 @@ else 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) +# roles/monitoring.viewer read metrics (monitoring.timeSeries.list), live or every 5 minutes +# roles/cloudasset.viewer list resources and the scope's projects (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 @@ -670,12 +673,13 @@ grant "Couldn't let Maple use the service account." "$OWNER" \\ --member="serviceAccount:$MAPLE_SERVICE_ACCOUNT" --role=roles/iam.serviceAccountTokenCreator \\ --condition=None ok "Maple allowed to read as that account" +# "metrics" in a report is this account: in place (true) or removed (false). 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" +const readerRemoval = (scopeType: GcpScopeType): string => ` +# ---- Read-only access: remove what a setup run created. ---- +section "Read-only access" # 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. @@ -704,7 +708,7 @@ if account_remains gcloud iam service-accounts list --project="$PROJECT_ID" \\ 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, @@ -737,42 +741,35 @@ ${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. */ + /** Maple's own Google service account. Undefined: the read-only access is left as it is. */ readonly mapleServiceAccountEmail: string | undefined readonly logsEnabled: boolean - readonly metricsEnabled: boolean /** Null: an existing sink keeps its filter, a new one forwards no application output. */ readonly applicationLogs: ReadonlyArray | null } /** * 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. + * a Pub/Sub topic whose push subscription delivers each LogEntry to Maple; the script converges, + * so a re-run removes the sink once log forwarding is off. Every connection: a read-only service + * account Maple may impersonate, for live metric queries and, while `metrics_enabled`, the poller. + * Each section ends by telling Maple what it applied. */ export const renderGcpSetupScript = (input: GcpSetupScriptInput) => { - const { scopeType, logsEnabled: logs } = input + const { scopeType, logsEnabled: logs, mapleServiceAccountEmail: maple } = input const applicationLogs = input.applicationLogs ?? [] const leftOut = leftOutRuntimes(applicationLogs).map((runtime) => APPLICATION_LOGS[runtime].name) - // 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") + // Without a Google identity Maple can't read as the account: nothing is set up and, above all, + // nothing a customer set up earlier is removed. + const reader = maple !== undefined + const readerState = reader ? "on" : "not available on this Maple deployment, left as is" 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. +# Log forwarding: ${logs ? "on" : "off"} +# Read-only access: ${readerState} +# Safe to run again: it sets up what is on and removes log forwarding when it 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. @@ -803,9 +800,7 @@ LOG_FILTER=${sh(gcpLogFilter(applicationLogs))} : "" }${variables(input, [ `PUSH_ENDPOINT=${sh(input.pushEndpoint)}`, - ...(input.mapleServiceAccountEmail === undefined || metrics !== "on" - ? [] - : [`MAPLE_SERVICE_ACCOUNT=${sh(input.mapleServiceAccountEmail)}`]), + ...(maple === undefined ? [] : [`MAPLE_SERVICE_ACCOUNT=${sh(maple)}`]), ])}SINK_CREATED=0 FILTER_REPLACED=0 ACCOUNT_CREATED=0 @@ -843,22 +838,16 @@ apis() { fi ok "APIs enabled ($names)" } -${logs || metrics === "on" ? GRANT : ""}${logs && metrics === "on" ? "" : REMOVAL_HELPERS} +${logs || reader ? GRANT : ""}${logs ? "" : REMOVAL_HELPERS} printf 'Maple setup for %s\\n' "$SCOPE" -printf ' Log forwarding %s\\n Metrics and resources %s\\n' ${sh(state(logs))} ${sh(metricsState)} +printf ' Log forwarding %s\\n Read-only access %s\\n' ${sh(logs ? "on" : "off, removing")} ${sh(readerState)} ${access(scopeType, { - apis: [...new Set([...(logs ? LOG_APIS : []), ...(metrics === "on" ? METRIC_APIS : [])])], + apis: [...new Set([...(logs ? LOG_APIS : []), ...(reader ? READER_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) - : "" - } + scopeMustOpen: reader, + billing: reader, + permissions: permissionChecks(scopeType, logs, reader), +})}${logs ? logsSetup(scopeType) : logsRemoval(scopeType, true)}${reader ? readerSetup(scopeType) : ""} 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 @@ -873,7 +862,7 @@ 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 +if [ "$ACCOUNT_CREATED" = 1 ]; then printf ' Metrics: Maple can query them within a few minutes.\\n'; fi `, ) } @@ -901,7 +890,7 @@ ${output( "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)}${ +${access(input.scopeType, { apis: [], sink: undefined, scopeMustOpen: false, billing: false, permissions: [] })}${logsRemoval(input.scopeType, false)}${readerRemoval(input.scopeType)}${ input.pushEndpoint === undefined ? "" : ` diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts index 8e40069b3..4e51e4cfc 100644 --- a/packages/domain/src/http/v2/integrations-gcp.ts +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -22,9 +22,9 @@ 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. +// logs through Pub/Sub to the ingest gateway if the connector opted in, and lets Maple read +// metrics and the resource inventory for every connector. 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) @@ -74,7 +74,7 @@ export const V2GcpConnector = Schema.Struct({ }), metrics_enabled: Schema.Boolean.annotate({ description: - "Whether the setup script lets Maple read Cloud Monitoring metrics and the Cloud Asset resource inventory.", + "Whether Maple collects the scope's key Cloud Monitoring metrics every 5 minutes (for alert rules, the Infrastructure page and the Google Cloud dashboard template) and syncs its Cloud Asset resource inventory every hour. A change takes effect in Maple at once and needs no script run. Live metric queries work on every connector either way.", examples: [true], }), created_at: Timestamp.annotate({ description: "When the connector was created." }), @@ -93,7 +93,7 @@ export const V2GcpConnector = Schema.Struct({ }), 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`.", + "Whether the read-only service account Maple reads metrics and resources as is in place in Google Cloud, as the latest setup or cleanup script run reported it. `null` until a run reports. Live queries and, with `metrics_enabled`, collection need it. The setup script creates it for every connector while `metrics_available` is `true`: run it while this is not `true`.", examples: [true], }), setup_reported_at: Schema.NullOr(Timestamp).annotate({ @@ -102,7 +102,7 @@ export const V2GcpConnector = Schema.Struct({ }), last_metrics_received_at: Schema.NullOr(Timestamp).annotate({ description: - "When Maple last read the scope's metrics, or `null` before the first successful read. Stays `null` until the setup script has run with `metrics_enabled`.", + "When Maple last collected the scope's key metrics, or `null` before the first successful read. Only connectors with `metrics_enabled` are collected.", }), last_metrics_error: Schema.NullOr(Schema.String).annotate({ description: @@ -132,7 +132,7 @@ export const V2GcpIntegration = Schema.Struct({ }), 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.", + "Whether this Maple deployment has a Google identity to read Google Cloud metrics with: live queries on every connector, and key-metrics collection. When `false`, `metrics_enabled` cannot be turned on and `logs_enabled` must stay `true`.", examples: [true], }), connectors: Schema.Array(V2GcpConnector).annotate({ @@ -150,12 +150,13 @@ export const V2GcpIntegration = Schema.Struct({ export type V2GcpIntegration = Schema.Schema.Type const logsEnabledField = Schema.optionalKey(Schema.Boolean).annotate({ - description: "Forward logs to Maple.", + description: + "Forward logs to Maple. Google Cloud follows once the setup script has run. Must stay `true` when the status reports `metrics_available: false`.", 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`.", + "Collect key metrics every 5 minutes and the resource inventory every hour. Takes effect at once, with no script run. Rejected when the status reports `metrics_available: false`.", examples: [false], }) @@ -193,7 +194,7 @@ export const V2GcpCreateConnectorRequest = Schema.Union([ 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.", + "What to connect, discriminated on `scope_type`. `logs_enabled` defaults to `true` and `metrics_enabled` to `false`. Both may be off: the connector then serves live metric queries only.", examples: [ wireExample({ scope_type: "project", scope_id: "acme-prod" }), wireExample({ @@ -213,7 +214,7 @@ export const V2GcpUpdateConnectorRequest = Schema.Struct({ 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.", + "What the connector collects; omitted fields are unchanged. Delete the connector to disconnect.", examples: [wireExample({ metrics_enabled: true })], }) export type V2GcpUpdateConnectorRequest = Schema.Schema.Type @@ -242,7 +243,7 @@ export const V2GcpSetupScripts = Schema.Struct({ }), 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.", + "Text to paste into Cloud Shell: a bash script, wrapped so that it runs in a bash process of its own. It sets up log forwarding while `logs_enabled` is `true` and removes it while it is not, creates the read-only service account Maple reads metrics as, and tells Maple what it applied, so run it again after changing `logs_enabled`. `metrics_enabled` does not change it. It embeds the connector's push secret: treat it as a credential.", examples: [SCRIPT_EXAMPLE], }), cleanup_script: Schema.String.annotate({ @@ -311,7 +312,7 @@ export class V2GcpIntegrationsApiGroup extends HttpApiGroup.make("gcpIntegration 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.", + "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 this deployment can read Google Cloud metrics. Requires the `integrations:read` scope.", }), ), ) @@ -352,7 +353,7 @@ export class V2GcpIntegrationsApiGroup extends HttpApiGroup.make("gcpIntegration 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.", + "Turns log forwarding or key-metrics collection on or off. Key-metrics collection changes in Maple at once. Maple cannot change Google Cloud itself: after changing `logs_enabled`, re-run the script from `setup_scripts`, which sets up or removes the log sink. Requires an org-admin role and the `integrations:write` scope.", }), ), ) @@ -391,6 +392,6 @@ export class V2GcpIntegrationsApiGroup extends HttpApiGroup.make("gcpIntegration 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.", + "Connect Google Cloud projects, folders or organizations: register a connector, choose whether it forwards logs and whether Maple collects key metrics and resources, fetch the `gcloud` scripts that set it up, and see whether logs are arriving.", }), ) {} From 67376cb03f30814d4f0cb286ea0ee378f2e67bd4 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:17:51 +0200 Subject: [PATCH 02/10] feat(gcp): card, configure dialog and docs follow the key-metrics switch The key-metrics switch acts in Maple at once and skips the apply stage; the card asks for the setup script only for log forwarding and a missing read-only service account. Copy says live queries work on every connection. --- .../src/content/docs/integrations/gcp.md | 115 +++++++-------- .../components/integrations/gcp-configure.tsx | 105 ++++++++------ .../integrations/gcp-connector-state.test.ts | 134 +++++++++++------- .../integrations/gcp-connector-state.ts | 119 ++++++++++------ .../integrations/gcp-integration-card.tsx | 31 ++-- .../integrations/integration-catalog.tsx | 20 +-- apps/web/src/routes/infra/gcp/index.tsx | 9 +- .../dashboard-templates/infrastructure/gcp.ts | 6 +- .../integrations/GcpConnectorService.test.ts | 2 +- .../integrations/gcp/setup-scripts.test.ts | 85 ++++------- packages/db/src/tables/gcp-connectors.ts | 4 +- 11 files changed, 351 insertions(+), 279 deletions(-) diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 801688a8a..8ec0e24e4 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -1,16 +1,18 @@ --- title: "Google Cloud" -description: "Connect a Google Cloud organization, folder or project by running one script in Cloud Shell. Maple receives Cloud Logging entries through Pub/Sub and reads Cloud Monitoring metrics every 5 minutes." +description: "Connect a Google Cloud organization, folder or project by running one script in Cloud Shell. Maple receives Cloud Logging entries through Pub/Sub, queries Cloud Monitoring metrics live and collects key metrics every 5 minutes." group: "Integrations" order: 7 --- The Google Cloud integration connects an organization, a folder or a single project to Maple. You choose what each connection collects: -| What to collect | Collects | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Log forwarding** | A Log Router sink sends Cloud Logging entries through Pub/Sub to Maple. They appear in [Logs](/docs/explore/logs) under the service that wrote them. | -| **Metrics and resources** | Maple reads Cloud Monitoring metrics for Cloud Run, Cloud Functions, GKE, Compute Engine, Cloud SQL, Pub/Sub and HTTP(S) load balancers every 5 minutes, and lists the resources behind them every hour. The metrics are named `gcp.*` and work in dashboards and alert rules. | +| What to collect | Collects | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Log forwarding** | A Log Router sink sends Cloud Logging entries through Pub/Sub to Maple. They appear in [Logs](/docs/explore/logs) under the service that wrote them. | +| **Key metrics** | Maple reads key Cloud Monitoring metrics for Cloud Run, Cloud Functions, GKE, Compute Engine, Cloud SQL, Pub/Sub and HTTP(S) load balancers every 5 minutes, and lists the resources behind them every hour. The metrics are named `gcp.*` and work in dashboards and alert rules. | + +Every connection also lets Maple query any Cloud Monitoring metric of the connected scope live, with **Key metrics** on or off. There is no OAuth step and no service account key. You run a generated `gcloud` script in Cloud Shell, and Maple gets no write access to Google Cloud. @@ -31,7 +33,7 @@ If you can't get Owner, see [Roles for running the script](#roles-for-running-th The host project holds Maple's Pub/Sub topic, subscription and read-only service account. A project is its own host project. For a folder or organization, name a shared operations project inside it that won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to the host project. -**Metrics and resources** needs billing enabled on the host project: Cloud Monitoring only answers for projects that have a billing account. The script checks this before it changes anything. +The host project needs billing enabled: Cloud Monitoring only answers for projects that have a billing account. The script checks this before it changes anything. ### Find your IDs @@ -46,7 +48,7 @@ The host project holds Maple's Pub/Sub topic, subscription and read-only service 1. Open **Integrations → Google Cloud** in Maple and click **Connect Google Cloud**. When the page already has a connection, click **Add connection** above the list. The dialog **Connect Google Cloud** opens on its first stage, **Connect**. 2. Under **What to connect**, choose **Organization**, **Folder** or **Project** and enter its ID. An organization or folder covers every project in it, including new ones. 3. For an organization or folder, enter the **Host project ID**. -4. Under **Collect**, leave **Log forwarding** and **Metrics and resources** ticked, or untick one. To change which logs are forwarded, choose a [log filter](#log-filter) under **Log filter**. +4. Under **Collect**, leave **Log forwarding** and **Key metrics** ticked, or untick either. With both unticked, the connection serves live metric queries only. To change which logs are forwarded, choose a [log filter](#log-filter) under **Log filter**. 5. Click **Create and continue**. Maple creates the connection and the dialog moves to its second stage, **Apply in Google Cloud**. Nothing changes in Google Cloud until you run the script. 6. In step 1, click **Open Cloud Shell** and sign in with the roles above. Click **Authorize** if Cloud Shell asks. 7. In step 2, click **Copy script**, paste it into Cloud Shell and press Enter. The script takes about a minute. **Read the script** shows it in the dialog. @@ -58,8 +60,8 @@ A first run for a project prints: ```text Maple setup for project acme-prod - Log forwarding on - Metrics and resources on + Log forwarding on + Read-only access on Checking access ✓ Signed in as jane@acme.com @@ -77,7 +79,7 @@ Log forwarding ✓ Sink allowed to publish to the topic ✓ Check message sent to Maple through the topic -Metrics and resources +Read-only access ✓ 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) @@ -88,7 +90,7 @@ Done. Google Cloud is set up for Maple. Maple confirms it within a minute: https://app.maple.dev/integrations?integration=gcp 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. + Metrics: Maple can query them within a few minutes. ``` If a step fails, the script stops and prints what to do. Fix it and paste the script again: it continues where it stopped. Running the script again is safe, and its last line says what the run did: @@ -98,14 +100,14 @@ If a step fails, the script stops and prints what to do. Fix it and paste the sc | "Done. Google Cloud is set up for Maple." | Created the log sink or the read-only service account. | | "Done. Everything is in place." | Created and removed nothing. It makes every grant again, so it also restores one that was removed. | | "Done. The log sink has the filter of this script. Everything else is in place." | Replaced the sink's filter and created nothing. | -| "Done. Google Cloud matches your Maple switches." | Removed what **Log forwarding** or **Metrics and resources**, now off, had set up. | +| "Done. Google Cloud matches your Maple switches." | Removed the log sink, topic and subscription: **Log forwarding** is off. | -After Maple confirms the run, **Log forwarding** and **Metrics and resources** each wait for their first data: +After Maple confirms the run, **Log forwarding** and **Key metrics** each wait for their first data: -| What to collect | First data | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Log forwarding | A new sink can take about 10 minutes to start forwarding. Entries logged before that are not forwarded. | -| Metrics and resources | The first read lands within about 10 minutes. Maple reads every 5 minutes, about 5 minutes behind, so that each minute is complete. | +| What to collect | First data | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Log forwarding | A new sink can take about 10 minutes to start forwarding. Entries logged before that are not forwarded. | +| Key metrics | The first read lands within about 10 minutes. Maple reads every 5 minutes, about 5 minutes behind, so that each minute is complete. | The script contains a secret that lets anyone send logs to your Maple organization. Don't share it or commit it, and run it in Cloud Shell: there neither the paste nor the secret ends up in shell history. The pasted text keeps itself out of the history files of bash 5 and zsh, but it still shows in your terminal's scrollback. @@ -118,27 +120,28 @@ A project, folder or organization can be connected once per Maple organization. ### Change what a connection collects 1. On the connection's row, click **Configure**. The dialog opens on its first stage, **Configure**. -2. Under **Collect**, tick or untick **Log forwarding** or **Metrics and resources**. The line beside a changed checkbox says what saving it does. -3. Click **Save and continue**. For a change that takes effect in Maple at once, Maple asks first, in a dialog titled for example "Turn log forwarding off for Project acme-prod?". The dialog says what happens **In Maple** and what goes on **In Google Cloud**. Click **Turn off and continue**, or **Turn on** when you turn something back on that still exists in Google Cloud. Then the button of the first stage reads **Save**, and there is nothing to run. -4. The dialog moves to **Apply in Google Cloud**. Its recap says what is saved and what this run does. Copy the script and run it in Cloud Shell again. +2. Under **Collect**, tick or untick **Log forwarding** or **Key metrics**. The line beside a changed checkbox says what saving it does. +3. Click **Save and continue**, or **Save** when the change leaves nothing to run. For a change that takes effect in Maple at once, Maple asks first, in a dialog titled for example "Turn log forwarding off for Project acme-prod?". The dialog says what happens **In Maple** and what goes on **In Google Cloud**. Click **Turn off and continue**, **Turn off** or **Turn on**. A change with nothing to run, such as one to **Key metrics**, closes the dialog once saved. +4. Otherwise the dialog moves to **Apply in Google Cloud**. Its recap says what is saved and what this run does. Copy the script and run it in Cloud Shell again. 5. When the run reports, the dialog shows **Google Cloud matches this configuration**. Click **Done**. A change takes effect in Maple and in Google Cloud at different times: -| Change in **Configure** | In Maple | In Google Cloud | -| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Log forwarding** off | Maple stops storing the connection's logs within about a minute of saving. | Google Cloud keeps publishing logs to Pub/Sub, billed by Google, until the script has removed the log sink. | -| **Metrics and resources** off | Maple stops reading from the next 5-minute read. | The read-only service account and its roles stay until the script has removed them. | -| **Log forwarding** or **Metrics and resources** on | Starts once the script has run. | The script creates the log sink, or the read-only service account. | -| Back on while Google Cloud still has the log sink or the service account | At once: Maple stores the logs again as soon as you save, and reads metrics again from the next 5-minute read. | Nothing changes. | +| Change in **Configure** | In Maple | In Google Cloud | +| -------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Log forwarding** off | Maple stops storing the connection's logs within about a minute of saving. | Google Cloud keeps publishing logs to Pub/Sub, billed by Google, until the script has removed the log sink. | +| **Log forwarding** on | Starts once the script has run. | The script creates the log sink. | +| **Log forwarding** back on while Google Cloud has a sink | At once: Maple stores the logs again as soon as you save. | Nothing changes. | +| **Key metrics** off | Maple stops collecting from the next 5-minute read. | Nothing changes. The read-only service account stays, so live metric queries keep working. | +| **Key metrics** on | Maple collects from the next 5-minute read. | Nothing changes. | -Until the script has run, the connection's row shows **Changes not applied in Google Cloud** and says what goes on until then, for example "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink." Click **Apply changes** to open the dialog at the script. +Until the script has run, the connection's row shows **Changes not applied in Google Cloud** and says what goes on until then, for example "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink." Click **Apply changes** to open the dialog at the script. The same band shows while the read-only service account is missing: live metric queries and key metrics start once the script has run again. A run that removes the log sink takes one to two minutes: after it deletes the sink it waits a minute for Google to stop routing to the topic, so your project logs no sink error. When nothing is changed, the button in the first stage reads **Continue to the script**. The setup script exists only in the second stage of this dialog, so this is how you get it again, for example to change the [log filter](#log-filter) or to repair something. **Back** returns to the first stage. -A connection collects at least one of the two: the last one that is on can't be unticked. To stop collecting, [disconnect](#disconnect). +Both may be unticked: the connection then serves live metric queries only. To remove it, [disconnect](#disconnect). ### Log filter @@ -172,40 +175,40 @@ Once a connection has delivered data, **Integrations → Google Cloud** opens wi | **Workloads · 24h** | Workloads that reported metrics in the last 24 hours. | | **Resources** | Resources in the [inventory](#resources), and the projects they are in. | -The row follows what has delivered. While only **Log forwarding** delivers, it shows **Log entries**, **Errors** and **Warnings**. While only **Metrics and resources** delivers, it shows **Workloads**, **Resources** and **Projects**. A number Maple cannot count yet shows a dash. Below the numbers, **Workloads** lists what reported metrics in the last 24 hours, the worst first, each a link to its page under **Infrastructure → Google Cloud**. Beside it, **Resources** counts the [inventory](#resources) by type. +The row follows what has delivered. While only **Log forwarding** delivers, it shows **Log entries**, **Errors** and **Warnings**. While only **Key metrics** delivers, it shows **Workloads**, **Resources** and **Projects**. A number Maple cannot count yet shows a dash. Below the numbers, **Workloads** lists what reported metrics in the last 24 hours, the worst first, each a link to its page under **Infrastructure → Google Cloud**. Beside it, **Resources** counts the [inventory](#resources) by type. -Under **Connections**, each connection is a row, the worst state first, with a status for **Logs** and one for **Metrics**. A hint, a warning or a failure shows in a band under the row. The page updates on its own. +Under **Connections**, each connection is a row, the worst state first, with a status for **Logs** and one for **Key metrics**. A hint, a warning or a failure shows in a band under the row. The page updates on its own. | Logs | Meaning | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Not set up** | The setup script has not set log forwarding up yet. Run it. | -| **Setup running** | A run reported on metrics and resources in the last two minutes and has not reported on log forwarding yet. | +| **Setup running** | A run reported on read-only access in the last two minutes and has not reported on log forwarding yet. | | **Waiting for the first entry** | The script ran and no entry has arrived. After 20 minutes the band reads **No log entry in 20 minutes** with a `gcloud logging write` command that writes a test entry. | | **Receiving** | Entries arrive. The row shows when the last one did. | | **Nothing in 24 hours** | Nothing passed the filter for a day. Not an error. | | **Rejected** | Maple refused the last push. The band says why and what to do. | | **Off** | Turned off in **Configure**. While Google Cloud still has the log sink, the band **Changes not applied in Google Cloud** says so. | -| Metrics | Meaning | +| Key metrics | Meaning | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Not set up** | The setup script has not granted Maple read access yet. Run it. | -| **Setup running** | A run reported on log forwarding in the last two minutes and has not reported on metrics and resources yet. | +| **Setup running** | A run reported on log forwarding in the last two minutes and has not reported on read-only access yet. | | **Waiting for the first read** | The script ran and the first read has not landed. After 15 minutes the band says that no metrics have arrived yet. | | **Receiving** | Reads succeed. The row shows when the last one landed. The label reads **Receiving, resource list incomplete** when Maple could not list every resource. | | **Incomplete** | The last read is under 10 minutes old, but some metric queries failed or were cut short. The band says which. | | **Stalled** | No read for 30 minutes and no error. Maple retries every 5 minutes. | | **Can't be read** | Reads fail, and none has succeeded in the last 10 minutes. The band says why and what to do. | -| **Off** | Turned off in **Configure**. While the read-only service account still exists, the band **Changes not applied in Google Cloud** says so. | +| **Off** | Turned off in **Configure**. Live metric queries keep working. | The page header and the Integrations list show the connection's worst status: **Needs attention**, **Setup pending**, **Changes pending**, **Waiting for data** or **Healthy**. A connection that needs the setup script shows a band with one button. The button opens **Configure** at its second stage, **Apply in Google Cloud**. -| Band | Button | Shown when | -| --------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| **Setup pending** | **Finish setup** | The script of a new connection has not reported, or its last run stopped part of the way. | -| **Changes not applied in Google Cloud** | **Apply changes** | The saved configuration and what the script last reported differ. The band says what goes on until the script has run. | -| A failure's own message | **Run the script again** | The text of the failure asks for another run of the setup script. | +| Band | Button | Shown when | +| --------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Setup pending** | **Finish setup** | The script of a new connection has not reported, or its last run stopped part of the way. | +| **Changes not applied in Google Cloud** | **Apply changes** | The saved configuration and what the script last reported differ, or the read-only service account is missing. The band says what goes on until the script has run. | +| A failure's own message | **Run the script again** | The text of the failure asks for another run of the setup script. | Members who are not admins of the Maple organization see the statuses and the numbers, without **Configure**, the **⋮** menu or the band's button. The band asks them to have an admin run the setup script, and a line under the list says "Only Maple organization admins can add, configure or disconnect connections." @@ -221,17 +224,17 @@ Then check the data: The script enables these APIs in the host project: - `pubsub.googleapis.com` and `logging.googleapis.com`, for log forwarding: carrying the log entries and routing them through the sink. -- `monitoring.googleapis.com`, `cloudasset.googleapis.com`, `iam.googleapis.com` and `iamcredentials.googleapis.com`, for metrics and resources: reading metrics, listing resources, creating the read-only service account and minting its short-lived tokens. No key is ever created. +- `monitoring.googleapis.com`, `cloudasset.googleapis.com`, `iam.googleapis.com` and `iamcredentials.googleapis.com`, for the read-only service account: reading metrics, listing resources, creating the account and minting its short-lived tokens. No key is ever created. - `cloudresourcemanager.googleapis.com`, for both: granting the roles below. -For log forwarding it creates a Pub/Sub topic and push subscription in the host project and a log sink on the project, folder or organization. For metrics and resources it creates a read-only service account in the host project. Each is named `maple-` followed by 24 hexadecimal characters unique to the connection. +For log forwarding it creates a Pub/Sub topic and push subscription in the host project and a log sink on the project, folder or organization. For every connection it creates a read-only service account in the host project, which Maple reads metrics as: live queries, and **Key metrics** while it is on. Each is named `maple-` followed by 24 hexadecimal characters unique to the connection. Running the script again never removes the service account, whatever the switches say; only the [cleanup script](#disconnect) does. It grants these roles: - **Pub/Sub Publisher** (`roles/pubsub.publisher`) to the sink's Google-managed writer identity, on Maple's topic: publishing log entries to the topic. - **Logs Writer** (`roles/logging.logWriter`) to the same writer identity, on the host project: Google requires it on the project that holds a sink's destination. -- **Monitoring Viewer** (`roles/monitoring.viewer`) to the read-only service account, on the project, folder or organization: reading metrics (`monitoring.timeSeries.list`). -- **Cloud Asset Viewer** (`roles/cloudasset.viewer`) to the read-only service account, on the project, folder or organization: listing resources (`cloudasset.assets.searchAllResources`). The role can also read resource metadata and IAM policies. +- **Monitoring Viewer** (`roles/monitoring.viewer`) to the read-only service account, on the project, folder or organization: reading metrics (`monitoring.timeSeries.list`), live and every 5 minutes. +- **Cloud Asset Viewer** (`roles/cloudasset.viewer`) to the read-only service account, on the project, folder or organization: listing resources and the projects under a folder or organization (`cloudasset.assets.searchAllResources`). The role can also read resource metadata and IAM policies. - **Service Usage Consumer** (`roles/serviceusage.serviceUsageConsumer`) to the read-only service account, on the host project: calling both read APIs through the host project. - **Service Account Token Creator** (`roles/iam.serviceAccountTokenCreator`) to Maple's service account, on the read-only service account: minting short-lived tokens for that one account. @@ -241,14 +244,14 @@ It grants these roles: Owner on the host project is the simplest. Without it, these predefined roles together cover what the script does: -| On | Role | For | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | -| The host project | Service Usage Admin (`roles/serviceusage.serviceUsageAdmin`) | Switching on the APIs above. | -| The host project | Project IAM Admin (`roles/resourcemanager.projectIamAdmin`) | The role grants on the host project. | -| The host project | Pub/Sub Admin (`roles/pubsub.admin`) | Log forwarding: the topic and the subscription. | -| The host project | Service Account Admin (`roles/iam.serviceAccountAdmin`) | Metrics and resources: the read-only service account. | -| The project, folder or organization | Logs Configuration Writer (`roles/logging.configWriter`) | Log forwarding: the sink. | -| The project, folder or organization | Project IAM Admin (`roles/resourcemanager.projectIamAdmin`), Folder IAM Admin (`roles/resourcemanager.folderIamAdmin`) or Organization Administrator (`roles/resourcemanager.organizationAdmin`) | Metrics and resources: the read-only roles. | +| On | Role | For | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| The host project | Service Usage Admin (`roles/serviceusage.serviceUsageAdmin`) | Switching on the APIs above. | +| The host project | Project IAM Admin (`roles/resourcemanager.projectIamAdmin`) | The role grants on the host project. | +| The host project | Pub/Sub Admin (`roles/pubsub.admin`) | Log forwarding: the topic and the subscription. | +| The host project | Service Account Admin (`roles/iam.serviceAccountAdmin`) | The read-only service account. | +| The project, folder or organization | Logs Configuration Writer (`roles/logging.configWriter`) | Log forwarding: the sink. | +| The project, folder or organization | Project IAM Admin (`roles/resourcemanager.projectIamAdmin`), Folder IAM Admin (`roles/resourcemanager.folderIamAdmin`) or Organization Administrator (`roles/resourcemanager.organizationAdmin`) | The read-only roles. | Before it changes anything, the script asks Google which permissions the signed-in account holds and stops with the missing ones. @@ -357,7 +360,7 @@ Every hour Maple lists these resources from Cloud Asset Inventory, with their pr ## Infrastructure → Google Cloud -**Infrastructure → Google Cloud** is in the sidebar and on the Infrastructure overview while a connection has **Metrics and resources** on. +**Infrastructure → Google Cloud** is in the sidebar and on the Infrastructure overview while a connection has **Key metrics** on. The page opens with a band that counts the workloads of every service by health, then one tab per service that reported metrics in the selected time range, with one row per workload. The tab you are on stays when you change the range. Counts and byte totals cover the time range; everything else is the average over it. @@ -415,7 +418,7 @@ The **Resources** tab shows the first 500 resources that match its filters, and Until a connection's setup script has run, the page reads **Finish setting up Google Cloud**. After the run it reads **Collecting your first Google Cloud metrics** until the first read lands, within about 10 minutes. -With **Metrics and resources** off on every connection, the page leaves the sidebar and the overview. Its address still opens it: for a time range with data it shows the service tabs with what Maple collected before, under the notice **Google Cloud metrics are switched off**, and otherwise it reads **Turn on metrics for Google Cloud**. The **Resources** tab is back once a connection collects again. +With **Key metrics** off on every connection, the page leaves the sidebar and the overview. Its address still opens it: for a time range with data it shows the service tabs with what Maple collected before, under the notice **Google Cloud metrics are switched off**, and otherwise it reads **Turn on metrics for Google Cloud**. The **Resources** tab is back once a connection collects again. ## Google Cloud costs @@ -423,7 +426,7 @@ Google bills Pub/Sub and Cloud Monitoring usage to your account, separately from Google bills Pub/Sub for the log entries that pass through the topic and the subscription. A narrower `LOG_FILTER` lowers it. See [Pub/Sub pricing](https://cloud.google.com/pubsub/pricing). -Google bills Cloud Monitoring API reads to the host project. Each connection runs 46 `timeSeries.list` queries every 5 minutes, and the cost grows with the number of time series in the project, folder or organization. See [Google Cloud Observability pricing](https://cloud.google.com/stackdriver/pricing). +Google bills Cloud Monitoring API reads to the host project. Each connection with **Key metrics** on runs 46 `timeSeries.list` queries every 5 minutes, and the cost grows with the number of time series in the project, folder or organization. See [Google Cloud Observability pricing](https://cloud.google.com/stackdriver/pricing). Cloud Asset Inventory searches are free of charge. See [Cloud Asset Inventory pricing](https://cloud.google.com/asset-inventory/pricing). @@ -475,7 +478,7 @@ When you create a connection in Maple, "The Google Cloud project acme-prod is al | A workload's logs appear twice | The sink forwards its application output and the workload also sends its logs over OpenTelemetry. In **Configure**, choose **Replace filter**, leave its runtime unticked under [Log filter](#log-filter) and run the script again. | | A workload's stdout and stderr are missing | Application output is not forwarded by default. Send it over OpenTelemetry, or tick its runtime under [Log filter](#log-filter) in **Configure** and run the script again. | -### Metrics and resources +### Key metrics **Can't be read** and **Incomplete** come with one of the messages below. The first means no read has succeeded in the last 10 minutes. The second means the last read is under 10 minutes old and something is missing. The messages about the resource list show under **Receiving, resource list incomplete**. Where a project's message says "the project", a folder's or organization's says "the host project". @@ -499,7 +502,7 @@ When you create a connection in Maple, "The Google Cloud project acme-prod is al A message that ends in parentheses, such as "(Cloud Monitoring returned 403, BILLING_DISABLED)", quotes Google's answer. Include it when you write to support. -On a self-hosted Maple, the **Metrics and resources** checkbox reads **Not available on this Maple deployment** until `MAPLE_GCP_SERVICE_ACCOUNT_EMAIL` names a Google service account the deployment owns. Reading metrics also needs `MAPLE_GCP_SERVICE_ACCOUNT_KEY`, that account's key file, base64-encoded. +On a self-hosted Maple, the **Key metrics** checkbox reads **Not available on this Maple deployment** until `MAPLE_GCP_SERVICE_ACCOUNT_EMAIL` names a Google service account the deployment owns. Until then the setup script creates no read-only service account, there are no live metric queries, and **Log forwarding** stays on. Reading metrics also needs `MAPLE_GCP_SERVICE_ACCOUNT_KEY`, that account's key file, base64-encoded. ### Domain restricted sharing diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index 9dffff36f..baaf3ad0a 100644 --- a/apps/web/src/components/integrations/gcp-configure.tsx +++ b/apps/web/src/components/integrations/gcp-configure.tsx @@ -53,6 +53,7 @@ import { gcpCollectLock, gcpCreateRequest, gcpDraftEffect, + gcpEffectNeedsScript, gcpApplicationLogsLine, gcpLogFilterChoice, gcpOverlapNote, @@ -113,7 +114,8 @@ const SCOPES = { const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] const LOCK_NOTES = { - "last-on": "A connection collects at least one of the two. To stop collecting, disconnect.", + "logs-required": + "This Maple deployment can't read Google Cloud metrics, so a connection forwards logs. To stop, disconnect.", "metrics-unavailable": "Not available on this Maple deployment.", } as const satisfies { readonly [Lock in GcpCollectLock]: string } @@ -138,22 +140,26 @@ const CAPABILITIES = { }, }, metrics: { - title: "Metrics and resources", + title: "Key metrics", flag: "metrics_enabled", - description: "Cloud Monitoring, read-only, every 5 minutes", + description: "Every 5 minutes, and resources hourly, for alerts, Infrastructure and dashboards", effects: { "starts-after-script": "Starts once the script has run", - "resumes-now": "Read again from the next 5-minute read", - "stops-now": "Maple stops reading when you save", - "removed-by-script": "Its service account stays in Google Cloud until the script has run", + "resumes-now": "Collected from the next 5-minute read", + "stops-now": "Maple stops collecting when you save", + // Never shown: the read-only service account stays for live queries. + "removed-by-script": "Nothing to remove in Google Cloud", }, now: { - "stops-now": "Stops reading this connection's metrics and resources from the next 5-minute read.", - "resumes-now": "Reads this connection's metrics and resources again from the next 5-minute read.", + "stops-now": + "Stops collecting this connection's key metrics and resources from the next 5-minute read.", + "resumes-now": + "Collects this connection's key metrics and resources from the next 5-minute read.", }, later: { - "stops-now": "Keeps the read-only service account and its roles until the script has run.", - "resumes-now": "Nothing to run: the read-only service account is still there.", + "stops-now": + "Nothing to run: the read-only service account stays, so live metric queries keep working.", + "resumes-now": "Nothing to run: the read-only service account is in place.", }, }, } as const satisfies { @@ -496,8 +502,12 @@ function CollectFields({ ) })} - {/* A rule of the pair, so it stands under both and not under the one it happens to lock. */} - {locks.includes("last-on") ?

{LOCK_NOTES["last-on"]}

: null} + {/* True of the pair whatever is ticked, so it stands under both. */} +

+ {metricsAvailable + ? "Live queries of any Google Cloud metric work on every connection, with or without key metrics." + : LOCK_NOTES["logs-required"]} +

) } @@ -896,23 +906,16 @@ function ConnectStage({ {overlap} )} -
- - edit({ - logsEnabled: flags.logs_enabled, - metricsEnabled: flags.metrics_enabled, - }) - } - metricsAvailable={metricsAvailable} - /> - {draft.logsEnabled || draft.metricsEnabled ? null : ( -

- Choose at least one. -

- )} -
+ + edit({ + logsEnabled: flags.logs_enabled, + metricsEnabled: flags.metrics_enabled, + }) + } + metricsAvailable={metricsAvailable} + /> {draft.logsEnabled ? ( ) : null} @@ -950,6 +953,7 @@ function ChooseStage({ filter, onFilter, onApply, + onClose, }: { connector: V2GcpConnector metricsAvailable: boolean @@ -957,6 +961,8 @@ function ChooseStage({ onFilter: (filter: FilterState) => void /** Leads into the apply stage, with what was saved if anything changed. */ onApply: (saved: GcpFlags | null) => void + /** After a change that leaves the script nothing to do. */ + onClose: () => void }) { const update = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector"), { mode: "promiseExit", @@ -978,11 +984,9 @@ function ChooseStage({ return acts === "stops-now" || acts === "resumes-now" ? [{ capability, acts }] : [] }) const stopping = immediate.filter(({ acts }) => acts === "stops-now") - // Whether the script has anything to do once this is saved. Turning back on what still exists leaves nothing. - const scripted = CAPABILITY_IDS.some((capability) => { - const acts = effect(capability) - return acts !== null && acts !== "resumes-now" - }) + // Whether the script has anything to do once this is saved: key metrics are Maple's alone, and + // turning back on what still exists leaves nothing. + const scripted = CAPABILITY_IDS.some((capability) => gcpEffectNeedsScript(capability, effect(capability))) // Only what changed is sent: the API leaves an omitted capability as it is, so a stale view of // the other cannot overwrite it. Resolving to `false` keeps the confirmation open for a retry. @@ -1001,7 +1005,8 @@ function ChooseStage({ showErrorToast(result, { title: "Failed to save the change" }) return false } - onApply(draft) + if (scripted) onApply(draft) + else onClose() return true }) @@ -1052,13 +1057,15 @@ function ChooseStage({ <> {`Turn ${(stopping.length > 0 ? stopping : immediate) .map(({ capability }) => CAPABILITIES[capability].title.toLowerCase()) - .join(" and ")} ${stopping.length > 0 ? "off" : "back on"}`}{" "} + .join(" and ")} ${stopping.length > 0 ? "off" : "on"}`}{" "} for {GCP_SCOPE_NAMES[connector.scope_type]}{" "} {connector.scope_id}? } - // Turning off leaves something for the script to remove; turning back on leaves nothing to run. - confirmLabel={stopping.length > 0 ? "Turn off and continue" : "Turn on"} + // Only a sink turned off leaves the script something to remove. + confirmLabel={ + stopping.length > 0 ? (scripted ? "Turn off and continue" : "Turn off") : "Turn on" + } onConfirm={save} >
@@ -1098,6 +1105,7 @@ interface ApplyOpened { /** The second stage: the script that applies the configuration, and Maple's confirmation. */ function ApplyStage({ connector, + metricsAvailable, filter, nowMs, opened, @@ -1105,6 +1113,7 @@ function ApplyStage({ onClose, }: { connector: V2GcpConnector + metricsAvailable: boolean filter: FilterState nowMs: number opened: ApplyOpened @@ -1119,13 +1128,13 @@ function ApplyStage({ const reportedAt = connector.setup_reported_at // A run's sections report one after the other: confirmed once the last one has. const confirmed = - gcpScriptNeeded(connector, nowMs) === null && - !gcpSetupRunning(connector, nowMs) && + gcpScriptNeeded(connector, nowMs, metricsAvailable) === null && + !gcpSetupRunning(connector, nowMs, metricsAvailable) && (!opened.rerun || reportedAt !== opened.seen) useIntervalRefresh(refresh, { intervalMs: GCP_SETTLING_REFRESH_MS, enabled: !confirmed }) const overdue = !opened.rerun && gcpScriptOverdue(connector, opened.at, nowMs) - const scopeRoles = gcpScopeRoles(connector.scope_type, connector) + const scopeRoles = gcpScopeRoles(connector.scope_type, connector.logs_enabled, metricsAvailable) const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() const role = (name: string) => {name} @@ -1146,8 +1155,14 @@ function ApplyStage({ ) : null} -
Metrics and resources
-
{gcpApplyLine(connector.metrics_enabled, connector.applied_metrics_enabled)}
+
Read-only access
+
+ {!metricsAvailable + ? "Not available on this Maple deployment" + : connector.applied_metrics_enabled === true + ? "In place, for live metric queries and key metrics" + : "Created by this run, for live metric queries and key metrics"} +
{confirmed ? ( @@ -1307,7 +1322,7 @@ function ConnectionStages({ // any other way, nobody has said a run is under way. const [opened, setOpened] = useState(() => initialStage === "apply" - ? open(!created && gcpScriptNeeded(saved, Date.now()) !== "setup-pending") + ? open(!created && gcpScriptNeeded(saved, Date.now(), metricsAvailable) !== "setup-pending") : null, ) @@ -1322,10 +1337,12 @@ function ConnectionStages({ setOpened(open(flags === null)) onStage("apply") }} + onClose={onClose} /> ) : ( { const metrics = (over: Parameters[0]) => gcpMetricsState(connector(over), NOW) const SIGN_IN = "Maple can't sign in as this connection's read-only service account yet." - it("is off, and says whether the service account is still set up", () => { - expect(metrics({ metrics_enabled: false, applied_metrics_enabled: null })).toEqual({ - kind: "off", - stillSetUp: false, - }) - expect(metrics({ metrics_enabled: false, last_metrics_error: "x" })).toEqual({ - kind: "off", - stillSetUp: true, - }) + it("is off while switched off, whatever the poller said last", () => { + expect(metrics({ metrics_enabled: false, applied_metrics_enabled: null })).toEqual({ kind: "off" }) + expect(metrics({ metrics_enabled: false, last_metrics_error: "x" })).toEqual({ kind: "off" }) }) it("is setup pending until a run reports, and hides the poller's sign-in error", () => { @@ -252,7 +247,7 @@ describe("gcpMetricsState", () => { }) describe("gcpConnectionState", () => { - const state = (over: Parameters[0]) => gcpConnectionState(connector(over), NOW) + const state = (over: Parameters[0]) => gcpConnectionState(connector(over), NOW, true) it("needs attention when a capability is failing, incomplete or stalled", () => { expect(state({ last_log_error: "wrapped" })).toBe("attention") @@ -275,33 +270,48 @@ describe("gcpConnectionState", () => { ) }) + it("needs the script while the read-only service account is missing, with key metrics off too", () => { + const removed = { metrics_enabled: false, applied_metrics_enabled: false } + expect(state(removed)).toBe("changes-pending") + expect(state({ ...fresh, ...NEVER, logs_enabled: false, metrics_enabled: false })).toBe( + "setup-pending", + ) + // A deployment without a Google identity sets no account up, so none is missing. + expect(gcpConnectionState(connector(removed), NOW, false)).toBe("healthy") + }) + it("waits between the sections of a first run, and is setup pending if the second never reports", () => { const midRun = { applied_metrics_enabled: null, setup_reported_at: ago(0), ...NEVER } expect(state(midRun)).toBe("waiting") - expect(gcpSetupRunning(connector(midRun), NOW)).toBe(true) - expect(gcpScriptNeeded(connector(midRun), NOW)).toBeNull() - expect(gcpPendingChanges(connector(midRun), NOW)).toEqual([]) + expect(gcpSetupRunning(connector(midRun), NOW, true)).toBe(true) + expect(gcpScriptNeeded(connector(midRun), NOW, true)).toBeNull() + expect(gcpPendingChanges(connector(midRun), NOW, true)).toEqual([]) + // With key metrics off, the read-only account is the section still to come. + expect(gcpSetupRunning(connector({ ...midRun, metrics_enabled: false }), NOW, true)).toBe(true) const stopped = { ...midRun, setup_reported_at: ago(3) } expect(state(stopped)).toBe("setup-pending") - expect(gcpSetupRunning(connector(stopped), NOW)).toBe(false) + expect(gcpSetupRunning(connector(stopped), NOW, true)).toBe(false) }) it("waits for data once the script reported", () => { expect(state({ ...NEVER, setup_reported_at: ago(1) })).toBe("waiting") }) - it("is healthy while it receives, idles, or has a capability switched off and removed", () => { + it("is healthy while it receives, idles, or has a capability switched off", () => { expect(state({})).toBe("healthy") expect(state({ last_log_received_at: ago(3 * 24 * 60) })).toBe("healthy") expect(state({ last_resources_error: "The resource list is incomplete." })).toBe("healthy") - expect(state({ metrics_enabled: false, applied_metrics_enabled: false })).toBe("healthy") + expect(state({ metrics_enabled: false })).toBe("healthy") expect(state({ logs_enabled: false, applied_logs_enabled: null })).toBe("healthy") + expect(state({ logs_enabled: false, applied_logs_enabled: false, metrics_enabled: false })).toBe( + "healthy", + ) }) }) describe("gcpPendingChanges", () => { - const changes = (over: Parameters[0]) => gcpPendingChanges(connector(over), NOW) + const changes = (over: Parameters[0]) => gcpPendingChanges(connector(over), NOW, true) it("lists nothing while the configuration matches the last report, or before the first run", () => { expect(changes({})).toEqual([]) @@ -311,10 +321,8 @@ describe("gcpPendingChanges", () => { const LOGS_START = "Log forwarding starts once the script has created the log sink." const LOGS_STOP = "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink." - const METRICS_START = - "Metrics and resources start once the script has created the read-only service account." - const METRICS_STOP = - "The read-only service account stays in Google Cloud until the script has removed it." + const READER_START = + "Live metric queries and key metrics start once the script has created the read-only service account." it("says what goes on until a re-run removes and creates", () => { expect(changes({ logs_enabled: false })).toEqual([LOGS_STOP]) @@ -324,15 +332,27 @@ describe("gcpPendingChanges", () => { applied_metrics_enabled: false, last_metrics_received_at: ago(90), }), - ).toEqual([LOGS_STOP, METRICS_START]) + ).toEqual([LOGS_STOP, READER_START]) expect( - changes({ metrics_enabled: false, applied_logs_enabled: false, last_log_received_at: null }), - ).toEqual([LOGS_START, METRICS_STOP]) + changes({ + metrics_enabled: false, + applied_metrics_enabled: false, + applied_logs_enabled: false, + last_log_received_at: null, + }), + ).toEqual([ + LOGS_START, + "Live metric queries start once the script has created the read-only service account.", + ]) + }) + + it("has nothing to say about key metrics switched off: the account stays for live queries", () => { + expect(changes({ metrics_enabled: false })).toEqual([]) }) }) describe("gcpScriptNeeded", () => { - const needed = (over: Parameters[0]) => gcpScriptNeeded(connector(over), NOW) + const needed = (over: Parameters[0]) => gcpScriptNeeded(connector(over), NOW, true) it("is null while the configuration matches what the runs reported", () => { expect(needed({})).toBeNull() @@ -370,23 +390,17 @@ describe("gcpWorstState", () => { }) describe("gcpScopeRoles", () => { - const both = { logs_enabled: true, metrics_enabled: true } - it("asks for nothing beyond Owner on a project", () => { - expect(gcpScopeRoles("project", both)).toEqual([]) + expect(gcpScopeRoles("project", true, true)).toEqual([]) }) - it("asks for the sink role with logs and the IAM role with metrics", () => { - expect(gcpScopeRoles("organization", both)).toEqual([ + it("asks for the sink role with logs, and the IAM role wherever the deployment reads metrics", () => { + expect(gcpScopeRoles("organization", true, true)).toEqual([ "Logs Configuration Writer", "Organization Administrator", ]) - expect(gcpScopeRoles("folder", { logs_enabled: false, metrics_enabled: true })).toEqual([ - "Folder IAM Admin", - ]) - expect(gcpScopeRoles("folder", { logs_enabled: true, metrics_enabled: false })).toEqual([ - "Logs Configuration Writer", - ]) + expect(gcpScopeRoles("folder", false, true)).toEqual(["Folder IAM Admin"]) + expect(gcpScopeRoles("folder", true, false)).toEqual(["Logs Configuration Writer"]) }) }) @@ -417,24 +431,17 @@ describe("gcpOverlapNote", () => { describe("gcpCollectLock", () => { const both = { logs_enabled: true, metrics_enabled: true } const logsOnly = { logs_enabled: true, metrics_enabled: false } - const metricsOnly = { logs_enabled: false, metrics_enabled: true } - - it("leaves both free while both are on", () => { - expect(gcpCollectLock(both, "logs", true)).toBeNull() - expect(gcpCollectLock(both, "metrics", true)).toBeNull() - }) - - it("locks the last one that is on", () => { - expect(gcpCollectLock(logsOnly, "logs", true)).toBe("last-on") - expect(gcpCollectLock(metricsOnly, "metrics", true)).toBe("last-on") - }) + const neither = { logs_enabled: false, metrics_enabled: false } - it("lets the other be turned on", () => { - expect(gcpCollectLock(logsOnly, "metrics", true)).toBeNull() - expect(gcpCollectLock(metricsOnly, "logs", true)).toBeNull() + it("leaves both free, both off included, on a deployment that reads metrics", () => { + for (const flags of [both, logsOnly, neither]) { + expect(gcpCollectLock(flags, "logs", true)).toBeNull() + expect(gcpCollectLock(flags, "metrics", true)).toBeNull() + } }) - it("locks metrics off when the deployment cannot read them", () => { + it("keeps logs on and metrics off where the deployment can't read metrics", () => { + expect(gcpCollectLock(logsOnly, "logs", false)).toBe("logs-required") expect(gcpCollectLock(logsOnly, "metrics", false)).toBe("metrics-unavailable") }) @@ -455,8 +462,13 @@ describe("gcpDraftEffect", () => { ).toBe("starts-after-script") }) + it("starts key metrics at once while the read-only service account is in place", () => { + expect(gcpDraftEffect(connector({ metrics_enabled: false }), "metrics", true)).toBe("resumes-now") + }) + it("changes nothing for a capability that stays as it is", () => { expect(gcpDraftEffect(connector(), "logs", true)).toBeNull() + expect(gcpDraftEffect(connector({ metrics_enabled: false }), "metrics", false)).toBeNull() expect( gcpDraftEffect( connector({ metrics_enabled: false, applied_metrics_enabled: false }), @@ -479,7 +491,7 @@ describe("gcpDraftEffect", () => { expect(gcpDraftEffect(connector({ ...fresh, ...NEVER }), "logs", false)).toBeNull() }) - it("leaves the removal to the script for a capability already off in Maple", () => { + it("leaves the removal to the script for log forwarding already off in Maple", () => { expect(gcpDraftEffect(connector({ logs_enabled: false }), "logs", false)).toBe("removed-by-script") }) @@ -488,6 +500,17 @@ describe("gcpDraftEffect", () => { }) }) +describe("gcpEffectNeedsScript", () => { + it("sends a change to the script only when Google Cloud has something to set up or remove", () => { + expect(gcpEffectNeedsScript("logs", "stops-now")).toBe(true) + expect(gcpEffectNeedsScript("logs", "removed-by-script")).toBe(true) + expect(gcpEffectNeedsScript("metrics", "starts-after-script")).toBe(true) + expect(gcpEffectNeedsScript("metrics", "stops-now")).toBe(false) + expect(gcpEffectNeedsScript("metrics", "resumes-now")).toBe(false) + expect(gcpEffectNeedsScript("logs", null)).toBe(false) + }) +}) + describe("gcpRunAsked", () => { const RESET = "The subscription wraps each entry. Run the setup script again: it resets the subscription." @@ -663,8 +686,11 @@ describe("gcpCreateRequest opt-ins", () => { }) }) - it("needs at least one opt-in", () => { - expect(Option.isNone(gcpCreateRequest(draft(false, false)))).toBe(true) + it("sends a connection for live queries only, with both off", () => { + expect(Option.getOrNull(gcpCreateRequest(draft(false, false)))).toMatchObject({ + logs_enabled: false, + metrics_enabled: false, + }) }) }) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 9e8555b80..916d6c187 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -89,9 +89,9 @@ export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState { return { kind: age > IDLE_LOG_MS ? "idle" : "receiving", lastLogReceivedAt } } +/** Key metrics: the 5-minute reads of the curated metrics, switched in Maple alone. */ type GcpMetricsState = - /** `stillSetUp`: the read-only service account still exists in Google Cloud. */ - | { readonly kind: "off"; readonly stillSetUp: boolean } + | { readonly kind: "off" } | { readonly kind: "setup-pending" | "setup-running" } /** The script reported, and the first read after it has not landed. `overdue` after 15 minutes. */ | { readonly kind: "waiting"; readonly reportedAt: string | null; readonly overdue: boolean } @@ -123,8 +123,7 @@ type MetricsFields = Pick< > export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMetricsState { - const applied = connector.applied_metrics_enabled === true - if (!connector.metrics_enabled) return { kind: "off", stillSetUp: applied } + if (!connector.metrics_enabled) return { kind: "off" } const lastMetricsReceivedAt = connector.last_metrics_received_at const error = connector.last_metrics_error const age = ageMs(lastMetricsReceivedAt, nowMs) @@ -177,15 +176,33 @@ export const GCP_METRICS_STATUS = { type ConnectorFields = LogFields & MetricsFields +/** + * The read-only service account that live queries and key metrics read as. The script sets it up + * for every connection while this deployment has a Google identity (`metricsAvailable`), whatever + * the metrics switch says. Null when nothing waits on the script. + */ +const gcpAccessState = ( + connector: ConnectorFields, + nowMs: number, + metricsAvailable: boolean, +): "setup-pending" | "setup-running" | null => + metricsAvailable && + needsSetup(connector.applied_metrics_enabled, ageMs(connector.last_metrics_received_at, nowMs)) + ? setupKind(connector.applied_metrics_enabled, connector.setup_reported_at, nowMs) + : null + /** * What still waits on a re-run of the setup script, for a connection whose configuration disagrees - * with what a run reported: a sentence per capability, saying what goes on until then. A capability - * no run has reported on is not in the list: that is setup, and while a first run is under way its - * sections report one after the other. + * with what a run reported: a sentence each, saying what goes on until then. What no run has + * reported on is not in the list: that is setup, and while a first run is under way its sections + * report one after the other. */ -export const gcpPendingChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => { +export const gcpPendingChanges = ( + connector: ConnectorFields, + nowMs: number, + metricsAvailable: boolean, +): ReadonlyArray => { const log = gcpLogState(connector, nowMs) - const metrics = gcpMetricsState(connector, nowMs) return [ log.kind === "setup-pending" && connector.applied_logs_enabled === false ? "Log forwarding starts once the script has created the log sink." @@ -193,11 +210,9 @@ export const gcpPendingChanges = (connector: ConnectorFields, nowMs: number): Re log.kind === "off" && log.stillSetUp ? "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink." : null, - metrics.kind === "setup-pending" && connector.applied_metrics_enabled === false - ? "Metrics and resources start once the script has created the read-only service account." - : null, - metrics.kind === "off" && metrics.stillSetUp - ? "The read-only service account stays in Google Cloud until the script has removed it." + gcpAccessState(connector, nowMs, metricsAvailable) === "setup-pending" && + connector.applied_metrics_enabled === false + ? `Live metric queries${connector.metrics_enabled ? " and key metrics" : ""} start once the script has created the read-only service account.` : null, ].filter((line) => line !== null) } @@ -212,28 +227,39 @@ type GcpConnectionState = "attention" | "changes-pending" | "setup-pending" | "w export function gcpScriptNeeded( connector: ConnectorFields, nowMs: number, + metricsAvailable: boolean, ): "changes-pending" | "setup-pending" | null { - if (gcpPendingChanges(connector, nowMs).length > 0) return "changes-pending" + if (gcpPendingChanges(connector, nowMs, metricsAvailable).length > 0) return "changes-pending" const pending = gcpLogState(connector, nowMs).kind === "setup-pending" || - gcpMetricsState(connector, nowMs).kind === "setup-pending" + gcpMetricsState(connector, nowMs).kind === "setup-pending" || + gcpAccessState(connector, nowMs, metricsAvailable) === "setup-pending" return pending ? "setup-pending" : null } -/** A run reported on one capability and not yet on the other: the script is still working. */ -export const gcpSetupRunning = (connector: ConnectorFields, nowMs: number): boolean => +/** A run reported on one section and not yet on the other: the script is still working. */ +export const gcpSetupRunning = ( + connector: ConnectorFields, + nowMs: number, + metricsAvailable: boolean, +): boolean => gcpLogState(connector, nowMs).kind === "setup-running" || - gcpMetricsState(connector, nowMs).kind === "setup-running" + gcpMetricsState(connector, nowMs).kind === "setup-running" || + gcpAccessState(connector, nowMs, metricsAvailable) === "setup-running" -export function gcpConnectionState(connector: ConnectorFields, nowMs: number): GcpConnectionState { +export function gcpConnectionState( + connector: ConnectorFields, + nowMs: number, + metricsAvailable: boolean, +): GcpConnectionState { const log = gcpLogState(connector, nowMs).kind const metrics = gcpMetricsState(connector, nowMs).kind if (log === "failing" || metrics === "failing" || metrics === "incomplete" || metrics === "stalled") { return "attention" } return ( - gcpScriptNeeded(connector, nowMs) ?? - (log === "waiting" || metrics === "waiting" || gcpSetupRunning(connector, nowMs) + gcpScriptNeeded(connector, nowMs, metricsAvailable) ?? + (log === "waiting" || metrics === "waiting" || gcpSetupRunning(connector, nowMs, metricsAvailable) ? "waiting" : "healthy") ) @@ -296,8 +322,11 @@ export type GcpFlags = Pick export type GcpCapability = "logs" | "metrics" -/** Why a capability cannot be changed: it is the last one on, or the deployment cannot read metrics. */ -export type GcpCollectLock = "last-on" | "metrics-unavailable" +/** + * Why a capability cannot be changed, on a deployment that cannot read Google Cloud metrics: a + * connection there forwards logs, and collects no key metrics. + */ +export type GcpCollectLock = "logs-required" | "metrics-unavailable" /** The API refuses both of these, so the configuration disables the choice and says why. */ export function gcpCollectLock( @@ -305,16 +334,16 @@ export function gcpCollectLock( capability: GcpCapability, metricsAvailable: boolean, ): GcpCollectLock | null { - const on = capability === "logs" ? flags.logs_enabled : flags.metrics_enabled - const otherOn = capability === "logs" ? flags.metrics_enabled : flags.logs_enabled - if (on) return otherOn ? null : "last-on" - return capability === "metrics" && !metricsAvailable ? "metrics-unavailable" : null + if (metricsAvailable) return null + if (capability === "logs") return flags.logs_enabled ? "logs-required" : null + return flags.metrics_enabled ? null : "metrics-unavailable" } /** - * What saving a choice does, and when. Turning a capability off acts in Maple at once and in - * Google Cloud when the script runs; turning it on does nothing until the script has run, unless - * what it needs is still there from before. + * What saving a choice does, and when. Turning a capability off acts in Maple at once; a log sink + * stays in Google Cloud until the script runs, while the read-only service account behind key + * metrics stays for good. Turning one on does nothing until the script has run, unless what it + * needs is in place. */ export type GcpDraftEffect = "starts-after-script" | "resumes-now" | "stops-now" | "removed-by-script" @@ -330,9 +359,16 @@ export function gcpDraftEffect( // Data that arrived stands in for a report that never reached Maple. const setUp = applied === true || (applied === null && lastAt !== null) if (draft) return setUp ? (wanted ? null : "resumes-now") : "starts-after-script" - return setUp ? (wanted ? "stops-now" : "removed-by-script") : null + if (!setUp) return null + return wanted ? "stops-now" : logs ? "removed-by-script" : null } +/** Whether the script has something to do about a saved choice: of the two, only a sink is its to remove. */ +export const gcpEffectNeedsScript = (capability: GcpCapability, effect: GcpDraftEffect | null): boolean => + effect === "starts-after-script" || + effect === "removed-by-script" || + (effect === "stops-now" && capability === "logs") + /** One line of the apply step's recap: what the script does about a capability. */ export const gcpApplyLine = (wanted: boolean, applied: boolean | null): string => wanted @@ -370,14 +406,19 @@ export const gcpScopeLabel = (connector: Pick => +export const gcpScopeRoles = ( + scopeType: GcpScopeType, + logsEnabled: boolean, + metricsAvailable: boolean, +): ReadonlyArray => scopeType === "project" ? [] : [ - flags.logs_enabled ? "Logs Configuration Writer" : null, - flags.metrics_enabled + logsEnabled ? "Logs Configuration Writer" : null, + metricsAvailable ? scopeType === "folder" ? "Folder IAM Admin" : "Organization Administrator" @@ -413,12 +454,8 @@ export interface GcpConnectorDraft { const decodeCreateRequest = Schema.decodeUnknownOption(V2GcpCreateConnectorRequest) -/** - * The create request for a draft, decoded with the API's own schema; none while a field is wrong. - * The schema allows both opt-ins off and the API then refuses, so that case is none here too. - */ +/** The create request for a draft, decoded with the API's own schema; none while a field is wrong. */ export const gcpCreateRequest = (draft: GcpConnectorDraft): Option.Option => { - if (!draft.logsEnabled && !draft.metricsEnabled) return Option.none() const optIns = { logs_enabled: draft.logsEnabled, metrics_enabled: draft.metricsEnabled } return decodeCreateRequest( draft.scopeType === "project" diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 257852bf3..d1a33d682 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -240,11 +240,13 @@ function ScriptDue({ connector, needed, nowMs, + metricsAvailable, onRunScript, }: { connector: V2GcpConnector needed: "setup-pending" | "changes-pending" nowMs: number + metricsAvailable: boolean /** Absent for non-admins. */ onRunScript: (() => void) | null }) { @@ -256,7 +258,7 @@ function ScriptDue({ ? "Google Cloud has nothing of Maple's yet. Running the setup script takes about a minute." : "The last run stopped part of the way. Run the script again: it continues where it stopped.", ] - : gcpPendingChanges(connector, nowMs) + : gcpPendingChanges(connector, nowMs, metricsAvailable) return ( void onRemoved: (removed: RemovedGcpConnector) => void }) { @@ -330,7 +334,7 @@ function GcpConnection({ const log = gcpLogState(connector, nowMs) const metrics = gcpMetricsState(connector, nowMs) const resourcesError = metrics.kind === "receiving" ? metrics.resourcesError : null - const state = gcpConnectionState(connector, nowMs) + const state = gcpConnectionState(connector, nowMs, metricsAvailable) const tone: Tone = log.kind === "failing" || metrics.kind === "failing" ? "crit" @@ -342,7 +346,7 @@ function GcpConnection({ ? "neutral" : "warn" const onRunScript = isAdmin ? () => onConfigure("apply") : null - const needed = gcpScriptNeeded(connector, nowMs) + const needed = gcpScriptNeeded(connector, nowMs, metricsAvailable) // The card has one filled button. The script being due takes it; else the first failure that // another run repairs does. const repairs = needed === null ? gcpRunAsked(connector, nowMs)[0] : undefined @@ -388,7 +392,7 @@ function GcpConnection({ } /> {needed === null ? null : ( - + )} {log.kind === "failing" ? ( @@ -537,7 +547,9 @@ export function GcpIntegrationCard() { const connectors = status?.connectors ?? [] const metricsAvailable = status?.metrics_available === true - const settling = connectors.some((connector) => gcpConnectionState(connector, nowMs) !== "healthy") + const settling = connectors.some( + (connector) => gcpConnectionState(connector, nowMs, metricsAvailable) !== "healthy", + ) useIntervalRefresh(refreshStatus, { intervalMs: settling ? GCP_SETTLING_REFRESH_MS : STEADY_REFRESH_MS, enabled: connectors.length > 0, @@ -621,7 +633,7 @@ export function GcpIntegrationCard() { - GCP_STATE_ORDER.indexOf(gcpConnectionState(a, nowMs)) - - GCP_STATE_ORDER.indexOf(gcpConnectionState(b, nowMs)), + GCP_STATE_ORDER.indexOf(gcpConnectionState(a, nowMs, metricsAvailable)) - + GCP_STATE_ORDER.indexOf(gcpConnectionState(b, nowMs, metricsAvailable)), ) return ( @@ -685,6 +697,7 @@ export function GcpIntegrationCard() { connector={connector} isAdmin={isAdmin} nowMs={nowMs} + metricsAvailable={metricsAvailable} onConfigure={(stage) => setTarget({ kind: "connection", id: connector.id, stage })} onRemoved={(entry) => setRemoved([...removed, entry])} /> diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index f30ea0fb0..a730ba3e6 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -578,7 +578,9 @@ export function useIntegrationOverviews(): Record gcpConnectionState(connector, now)) + const states = connectors.map((connector) => + gcpConnectionState(connector, now, status.metrics_available), + ) const count = (state: (typeof states)[number]) => states.filter((entry) => entry === state).length const worst = gcpWorstState(states) return { @@ -590,14 +592,14 @@ export function useIntegrationOverviews(): Record connector.logs_enabled) ? "Logs" : null, - connectors.some((connector) => connector.metrics_enabled) - ? "Metrics and resources" - : null, - ] - .filter((part) => part !== null) - .join(" · "), + // Connections that collect nothing still serve live metric queries. + stat: + [ + connectors.some((connector) => connector.logs_enabled) ? "Logs" : null, + connectors.some((connector) => connector.metrics_enabled) ? "Key metrics" : null, + ] + .filter((part) => part !== null) + .join(" · ") || "Live metric queries", // Not next to "Waiting for data": the two would contradict each other. lastSyncLabel: worst === "waiting" diff --git a/apps/web/src/routes/infra/gcp/index.tsx b/apps/web/src/routes/infra/gcp/index.tsx index 7fa159250..2be3a5ada 100644 --- a/apps/web/src/routes/infra/gcp/index.tsx +++ b/apps/web/src/routes/infra/gcp/index.tsx @@ -130,7 +130,7 @@ function GcpPage() { } title="Connect Google Cloud to see your infrastructure" - description="Connect an organization, folder or project that collects metrics and resources. Maple reads Cloud Monitoring metrics and lists your resources, with no agents to install." + description="Connect an organization, folder or project with key metrics on. Maple reads Cloud Monitoring metrics and lists your resources, with no agents to install." integration="gcp" actionLabel="Connect Google Cloud" docsPage="gcp" @@ -144,7 +144,7 @@ function GcpPage() { } title="Finish setting up Google Cloud" - description="Metrics and resources are on, but the setup script hasn't run yet." + description="Key metrics are on, but the setup script hasn't run yet." integration="gcp" actionLabel="Open the integration" docsPage="gcp" @@ -248,7 +248,7 @@ function GcpInfra({ } title="Turn on metrics for Google Cloud" - description="Your Google Cloud connections forward logs only. Turn on Metrics and resources under Configure in the Google Cloud integration, then run the setup script." + description="Your Google Cloud connections don't collect key metrics. Turn on Key metrics under Configure in the Google Cloud integration." integration="gcp" actionLabel="Open the integration" docsPage="gcp" @@ -382,8 +382,7 @@ function GcpNotice({ notice }: { notice: GcpInfraNotice }) { Google Cloud metrics are off These tables show what Maple collected before they were turned off. To collect again, - turn on Metrics and resources under Configure in the Google Cloud integration, then - run the setup script. + turn on Key metrics under Configure in the Google Cloud integration. {integration("Open the integration")} diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts index a87bec570..e2c8a774d 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts @@ -250,10 +250,10 @@ export const gcpTemplate: TemplateDefinition = { tags: ["gcp", "google cloud", "cloud run", "gke", "cloud sql", "pubsub"], requirement: { kind: "integration", - label: "Google Cloud integration connected with Metrics and resources switched on", - collector: "the Google Cloud integration with Metrics and resources switched on", + label: "Google Cloud integration connected with Key metrics switched on", + collector: "the Google Cloud integration with Key metrics switched on", setupLabel: "the Google Cloud integration", - hint: "Connect a project, folder or organization, switch on Metrics and resources and run the setup script. Each widget fills in once its service reports.", + hint: "Connect a project, folder or organization with Key metrics switched on. Each widget fills in once its service reports.", }, requiredMetricPrefixes: ["gcp."], parameters: [ diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index 0f2f1e1d1..d118a862c 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -154,7 +154,7 @@ describe("GcpConnectorService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) - it.effect("switches capabilities; the setup script follows log forwarding and always sets up the reader", () => { + it.effect("switches capabilities, and the setup script follows log forwarding alone", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const gcp = yield* GcpConnectorService diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts index e73ae2cd9..e2f647442 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts @@ -67,10 +67,7 @@ const SCOPES = { type Scope = (typeof SCOPES)[keyof typeof SCOPES] const scopeTypes = ["project", "folder", "organization"] as const -const setupInput = ( - scope: keyof typeof SCOPES, - logsEnabled: boolean, -): GcpSetupScriptInput => ({ +const setupInput = (scope: keyof typeof SCOPES, logsEnabled: boolean): GcpSetupScriptInput => ({ ...SCOPES[scope].target, pushEndpoint: PUSH_ENDPOINT, mapleServiceAccountEmail: MAPLE_ACCOUNT, @@ -358,9 +355,7 @@ describe.each(scopeTypes)("renderGcpSetupScript for a %s", (scopeType) => { const host = scopeType === "project" ? "Project" : "Host project" it("sets up logs and read-only access from nothing, at the right scope, one line per step", () => { - const { status, stdout, stderr, commands, access } = run( - setup(setupInput(scopeType, true)), - ) + const { status, stdout, stderr, commands, access } = run(setup(setupInput(scopeType, true))) expect(status).toBe(0) expect(stderr).toBe("") expect(commands).toEqual([ @@ -568,12 +563,9 @@ describe("the text that is copied", () => { 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", true)), - { - describe: "found", - }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", true)), { + describe: "found", + }) expect(status).toBe(0) expect(commands.filter((command) => command.includes(" create "))).toEqual([]) expect(commands).not.toContainEqual(expect.stringContaining("--log-filter")) @@ -656,12 +648,9 @@ Done. Everything is in place. }) 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", true)), - { - fail: "sinks create", - }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", true)), { + fail: "sinks create", + }) expect(status).toBe(1) expect(commands.at(-1)).toContain("logging sinks create") expect( @@ -736,12 +725,9 @@ ${UNFINISHED}`), }) it("changes nothing when the project cannot be opened", () => { - const { status, stdout, commands } = run( - setup(setupInput("project", true)), - { - fail: "projects describe", - }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", true)), { + fail: "projects describe", + }) expect(status).toBe(1) expect(commands).toEqual([]) expect(stdout).toContain(` @@ -863,17 +849,10 @@ ${UNFINISHED}`) }) 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", true)), - { - apis: [ - "pubsub.googleapis.com", - "logging.googleapis.com", - "cloudresourcemanager.googleapis.com", - ], - lacking: "serviceusage.services.enable", - }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", true)), { + apis: ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"], + lacking: "serviceusage.services.enable", + }) expect(status).toBe(1) expect(commands).toEqual([]) expect(stdout) @@ -884,10 +863,9 @@ ${UNFINISHED}`) }) it("reads an account without any of the permissions as a wrong ID first", () => { - const { status, stdout, commands } = run( - setup(setupInput("project", true)), - { lacking: "everything" }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", true)), { + lacking: "everything", + }) expect(status).toBe(1) expect(commands).toEqual([]) expect(stdout) @@ -928,12 +906,9 @@ ${UNFINISHED}`) }) 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", true)), - { - lacking: "unseen", - }, - ) + const { status, stdout, commands } = run(setup(setupInput("folder", true)), { + lacking: "unseen", + }) expect(status).toBe(1) expect(commands).toEqual([]) expect(stdout).toContain(` ✗ jane@acme.com can't see project acme-host. @@ -1034,10 +1009,7 @@ case "$*" in fi ;; esac ` - const { status, stdout, commands } = run( - setup(setupInput("project", false)), - { gcloud: slow }, - ) + const { status, stdout, commands } = run(setup(setupInput("project", false)), { 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) @@ -1054,10 +1026,14 @@ esac ) const { status, stdout, commands } = run(script, { describe: "found" }) expect(status).toBe(0) - expect(stdout).toContain(" Read-only access not available on this Maple deployment, left as is\n") + expect(stdout).toContain( + " Read-only access not available on this Maple deployment, left as is\n", + ) expect(stdout).not.toContain("\nRead-only access\n") expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) - expect(commands).not.toContainEqual(expect.stringContaining("iam-policy-binding --member=serviceAccount")) + expect(commands).not.toContainEqual( + expect.stringContaining("iam-policy-binding --member=serviceAccount"), + ) } }) @@ -1225,10 +1201,7 @@ esac }) it("keeps gcloud from writing its arguments, the secret among them, to its log files", () => { - for (const script of [ - setup(setupInput("project", true)), - cleanup(SCOPES.project, PUSH_ENDPOINT), - ]) { + for (const script of [setup(setupInput("project", 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"])) diff --git a/packages/db/src/tables/gcp-connectors.ts b/packages/db/src/tables/gcp-connectors.ts index 23f98741d..50757f355 100644 --- a/packages/db/src/tables/gcp-connectors.ts +++ b/packages/db/src/tables/gcp-connectors.ts @@ -32,7 +32,9 @@ export const GcpConnectors = PG.table("gcp_connectors", { 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. + // receiver. Null until a run reports. Logs: the switch above says what should be there. + // Metrics: the read-only service account every connection should have; `metrics_enabled` + // only switches the poller. 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" }), From 02314ee5624a928eb6bb13ecf0599773ac000720 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:26:39 +0200 Subject: [PATCH 03/10] fix(gcp): review follow-ups for read-only access Script-pending state comes from the read-only account alone, so a deployment without a Google identity is never stuck on setup pending; confirm label says when the script follows; v2 descriptions no longer read as contradicting. --- .../src/routes/v2/integrations-gcp.http.test.ts | 6 +++--- .../src/components/integrations/gcp-configure.tsx | 8 ++------ .../integrations/gcp-connector-state.test.ts | 4 ++++ .../components/integrations/gcp-connector-state.ts | 2 -- .../integrations/GcpConnectorService.test.ts | 2 +- .../services/integrations/GcpConnectorService.ts | 2 +- .../integrations/gcp/setup-scripts.test.ts | 14 ++++++++++---- packages/domain/src/http/v2/integrations-gcp.ts | 6 +++--- 8 files changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index 28dc0d25d..afb28127b 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -250,9 +250,9 @@ describe("v2 gcp integration over HTTP", () => { 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") + const logsOff = await harness.request("POST", `${path}/setup_scripts`, admin.secret, {}) + expect(logsOff.body.setup_script).not.toContain("LOG_FILTER") + expect(logsOff.body.setup_script).toContain("gcloud logging sinks delete") // With both off the connection serves live queries, and the script keeps its reader. const liveOnly = await harness.request("PATCH", path, admin.secret, { metrics_enabled: false }) diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index baaf3ad0a..44e4b9fa3 100644 --- a/apps/web/src/components/integrations/gcp-configure.tsx +++ b/apps/web/src/components/integrations/gcp-configure.tsx @@ -114,8 +114,7 @@ const SCOPES = { const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] const LOCK_NOTES = { - "logs-required": - "This Maple deployment can't read Google Cloud metrics, so a connection forwards logs. To stop, disconnect.", + "logs-required": "This Maple deployment can't read Google Cloud metrics, so a connection forwards logs.", "metrics-unavailable": "Not available on this Maple deployment.", } as const satisfies { readonly [Lock in GcpCollectLock]: string } @@ -1062,10 +1061,7 @@ function ChooseStage({ {connector.scope_id}? } - // Only a sink turned off leaves the script something to remove. - confirmLabel={ - stopping.length > 0 ? (scripted ? "Turn off and continue" : "Turn off") : "Turn on" - } + confirmLabel={`Turn ${stopping.length > 0 ? "off" : "on"}${scripted ? " and continue" : ""}`} onConfirm={save} >
diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index 7acb73ac5..fa15e158b 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -278,6 +278,10 @@ describe("gcpConnectionState", () => { ) // A deployment without a Google identity sets no account up, so none is missing. expect(gcpConnectionState(connector(removed), NOW, false)).toBe("healthy") + const neverRead = { ...NEVER, applied_metrics_enabled: null, last_log_received_at: ago(1) } + expect(gcpConnectionState(connector({ ...neverRead, metrics_enabled: false }), NOW, false)).toBe( + "healthy", + ) }) it("waits between the sections of a first run, and is setup pending if the second never reports", () => { diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 916d6c187..b1ed45218 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -232,7 +232,6 @@ export function gcpScriptNeeded( if (gcpPendingChanges(connector, nowMs, metricsAvailable).length > 0) return "changes-pending" const pending = gcpLogState(connector, nowMs).kind === "setup-pending" || - gcpMetricsState(connector, nowMs).kind === "setup-pending" || gcpAccessState(connector, nowMs, metricsAvailable) === "setup-pending" return pending ? "setup-pending" : null } @@ -244,7 +243,6 @@ export const gcpSetupRunning = ( metricsAvailable: boolean, ): boolean => gcpLogState(connector, nowMs).kind === "setup-running" || - gcpMetricsState(connector, nowMs).kind === "setup-running" || gcpAccessState(connector, nowMs, metricsAvailable) === "setup-running" export function gcpConnectionState( diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index d118a862c..2bbdb35a2 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -196,7 +196,7 @@ describe("GcpConnectorService", () => { userId, projectScope("acme-prod", { logsEnabled: false, metricsEnabled: false }), ) - assert.isFalse(created.logsEnabled || created.metricsEnabled) + assert.deepStrictEqual([created.logsEnabled, created.metricsEnabled], [false, false]) }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) }) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index d0caaab94..72fff4280 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -427,7 +427,7 @@ export class GcpConnectorService extends Context.Service + command.includes("add-iam-policy-binding"), + ), + ), + ) } }) @@ -1031,9 +1037,9 @@ esac ) expect(stdout).not.toContain("\nRead-only access\n") expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) - expect(commands).not.toContainEqual( - expect.stringContaining("iam-policy-binding --member=serviceAccount"), - ) + expect(commands).not.toContainEqual(expect.stringContaining(`serviceAccount:${ACCOUNT}`)) + // Nothing is said about the account: what an earlier run reported stands. + expect(script).not.toContain('"metrics":') } }) diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts index 4e51e4cfc..4240765c7 100644 --- a/packages/domain/src/http/v2/integrations-gcp.ts +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -74,7 +74,7 @@ export const V2GcpConnector = Schema.Struct({ }), metrics_enabled: Schema.Boolean.annotate({ description: - "Whether Maple collects the scope's key Cloud Monitoring metrics every 5 minutes (for alert rules, the Infrastructure page and the Google Cloud dashboard template) and syncs its Cloud Asset resource inventory every hour. A change takes effect in Maple at once and needs no script run. Live metric queries work on every connector either way.", + "Whether Maple collects the scope's key Cloud Monitoring metrics every 5 minutes (for alert rules, the Infrastructure page and the Google Cloud dashboard template) and syncs its Cloud Asset resource inventory every hour. A change takes effect in Maple at once: the setup script does not depend on it. Collection and live metric queries both read as the read-only service account (see `applied_metrics_enabled`).", examples: [true], }), created_at: Timestamp.annotate({ description: "When the connector was created." }), @@ -156,7 +156,7 @@ const logsEnabledField = Schema.optionalKey(Schema.Boolean).annotate({ }) const metricsEnabledField = Schema.optionalKey(Schema.Boolean).annotate({ description: - "Collect key metrics every 5 minutes and the resource inventory every hour. Takes effect at once, with no script run. Rejected when the status reports `metrics_available: false`.", + "Collect key metrics every 5 minutes and the resource inventory every hour. Takes effect in Maple at once: the setup script does not depend on it. Rejected when the status reports `metrics_available: false`.", examples: [false], }) @@ -194,7 +194,7 @@ export const V2GcpCreateConnectorRequest = Schema.Union([ 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`. Both may be off: the connector then serves live metric queries only.", + "What to connect, discriminated on `scope_type`. `logs_enabled` defaults to `true` and `metrics_enabled` to `false`. Both may be off, for a connector that serves live metric queries only, unless the status reports `metrics_available: false`.", examples: [ wireExample({ scope_type: "project", scope_id: "acme-prod" }), wireExample({ From d48bac5c126bd9e427ba4484a916049d883d300b Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:30:23 +0200 Subject: [PATCH 04/10] fix(gcp): set up without billing, noting that live queries need it Billing is checked for every connection now that each gets the read-only account. Only Maple's reads need it, so a host project without billing gets a note and the rest of the setup instead of a stop. --- apps/landing/src/content/docs/integrations/gcp.md | 4 ++-- .../services/integrations/gcp/setup-scripts.test.ts | 13 +++++++------ .../src/services/integrations/gcp/setup-scripts.ts | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 8ec0e24e4..5fe244544 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -33,7 +33,7 @@ If you can't get Owner, see [Roles for running the script](#roles-for-running-th The host project holds Maple's Pub/Sub topic, subscription and read-only service account. A project is its own host project. For a folder or organization, name a shared operations project inside it that won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to the host project. -The host project needs billing enabled: Cloud Monitoring only answers for projects that have a billing account. The script checks this before it changes anything. +Live metric queries and **Key metrics** need billing enabled on the host project: Cloud Monitoring only answers for projects that have a billing account. Without billing the script says so and sets everything up anyway. ### Find your IDs @@ -454,7 +454,7 @@ A step that fails stops the script. It prints what went wrong, Google's answer, | "Can't open project acme-prod as _account_." | The ID is wrong, or the account has no access to the project. Check the ID with `gcloud projects list`. It is the project ID, not the name or number. If it is wrong, remove the connection and connect the right ID. | | "_account_ has none of the permissions this script needs on project acme-prod." | Check the ID in Maple first. If it is wrong, remove the connection and connect the right ID. If it is right, the account has no rights there: ask for the roles under [Prerequisites](#prerequisites), or have an administrator run the script. | | "_account_ is missing permissions on project acme-prod:" and a list | The account lacks a role. The line names the permissions and the role to ask for. If the only one listed is the permission to enable services, an API the script needs is off: ask for Service Usage Admin on the host project, or have an administrator switch the named APIs on. Nothing was created yet. | -| "Project acme-prod has no billing account." | Link a billing account to the host project, then paste the script again. | +| "Billing is off in project acme-prod" | A note, not a failure: the script sets everything up. Live metric queries and key metrics stay unavailable until you link a billing account to the host project. | | "gcloud _version_ is too old for this script." | Run the script in Cloud Shell, or update gcloud (`gcloud components update`, or the package manager that installed it). Versions before 470 can't update a folder or organization sink. | | "_account_ can't see folder 123456789012." (or organization) | The ID is wrong, or the account has no rights there. Check it with `gcloud resource-manager folders list --organization=ORGANIZATION_ID` or `gcloud organizations list`. | | "Couldn't look for _a resource_: its API is off in project acme-prod." | Cleanup only. The API was switched off after a setup run, so the resource may still be there. Switch the API on and paste the cleanup script again. | diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts index 20e670523..7e8d14e7f 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts @@ -800,14 +800,15 @@ ${UNFINISHED}`) ) }) - it("stops before the first change without billing, a sign-in, or gcloud", () => { + it("stops before the first change without a sign-in or gcloud, and sets up without billing", () => { const script = setup(setupInput("project", true)) + // Only Maple's reads need billing: log forwarding and the account are set up anyway. 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}`) + expect(unbilled.status).toBe(0) + expect(unbilled.commands).toEqual(expect.arrayContaining(logsSetupCommands(SCOPES.project))) + expect(unbilled.stdout).toContain( + " • Billing is off in project acme-host: live queries and key metrics stay unavailable until a billing account is linked at https://console.cloud.google.com/billing/linkedaccount?project=acme-host. The rest is set up regardless.\n", + ) const signedOut = run(script, { account: "" }) expect(signedOut.commands).toEqual([]) diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts index 9b78c9cf8..4da322f88 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -417,7 +417,8 @@ ${ 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" ;; + # Only Maple's reads need billing, and Maple explains a refused read: the rest is set up anyway. + False) note "Billing is off in project $PROJECT_ID: live queries and key metrics stay unavailable until a billing account is linked at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID. The rest is set up regardless." ;; *) note "Couldn't tell whether billing is on. Cloud Monitoring only answers for projects with billing." ;; esac ` From 56b78dfc39bd1fa80e53434427a117c821b420bd Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 01:43:32 +0200 Subject: [PATCH 05/10] chore(ios): regenerate the OpenAPI spec for the new GCP group description --- apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index d5dc983db..4e9cbbc86 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7746,7 +7746,7 @@ "name": "PlanetScale 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.", + "description": "Connect Google Cloud projects, folders or organizations: register a connector, choose whether it forwards logs and whether Maple collects key metrics and resources, fetch the `gcloud` scripts that set it up, and see whether logs are arriving.", "name": "Google Cloud Integration" }, { From 91b132068e342324caeba55d0932a27ca457b7b9 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 11:48:58 +0200 Subject: [PATCH 06/10] fix(gcp): read-only access never blocks log forwarding The read-only access section checks its own permissions and runs in a subshell after log forwarding. With log forwarding on, a failure there (a missing role, an organization policy refusing Maple's account) prints what to do and the run still succeeds without a metrics report; with it off, the run fails as before. The card shows a calm 'Live queries not set up' band instead of a setup that never finishes. --- .../src/content/docs/integrations/gcp.md | 13 +- .../integrations/gcp-connector-state.test.ts | 27 ++- .../integrations/gcp-connector-state.ts | 44 +++-- .../integrations/gcp-integration-card.tsx | 18 ++ apps/web/src/routes/infra/gcp/index.tsx | 5 +- .../dashboard-templates/infrastructure/gcp.ts | 2 +- .../integrations/gcp/setup-scripts.test.ts | 79 ++++++--- .../integrations/gcp/setup-scripts.ts | 155 +++++++++--------- 8 files changed, 220 insertions(+), 123 deletions(-) diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 5fe244544..50de1d7a0 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -29,7 +29,7 @@ If your workloads also send telemetry over OpenTelemetry, read [Google Cloud wit | A folder | Owner on the host project. On the folder: Logs Configuration Writer for the log sink, and Folder IAM Admin for the read-only roles. | | An organization | Owner on the host project. On the organization: Logs Configuration Writer for the log sink, and Organization Administrator for the read-only roles. | -If you can't get Owner, see [Roles for running the script](#roles-for-running-the-script). +For log forwarding alone, Owner on the host project and Logs Configuration Writer on a folder or organization are enough. The read-only access part of the script then stops with what it is missing, log forwarding stays set up, and the row says **Live queries not set up**. If you can't get Owner, see [Roles for running the script](#roles-for-running-the-script). The host project holds Maple's Pub/Sub topic, subscription and read-only service account. A project is its own host project. For a folder or organization, name a shared operations project inside it that won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to the host project. @@ -133,7 +133,7 @@ A change takes effect in Maple and in Google Cloud at different times: | **Log forwarding** on | Starts once the script has run. | The script creates the log sink. | | **Log forwarding** back on while Google Cloud has a sink | At once: Maple stores the logs again as soon as you save. | Nothing changes. | | **Key metrics** off | Maple stops collecting from the next 5-minute read. | Nothing changes. The read-only service account stays, so live metric queries keep working. | -| **Key metrics** on | Maple collects from the next 5-minute read. | Nothing changes. | +| **Key metrics** on | Maple collects from the next 5-minute read. | Nothing changes while the read-only service account exists. Without it, run the setup script. | Until the script has run, the connection's row shows **Changes not applied in Google Cloud** and says what goes on until then, for example "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink." Click **Apply changes** to open the dialog at the script. The same band shows while the read-only service account is missing: live metric queries and key metrics start once the script has run again. @@ -198,7 +198,7 @@ Under **Connections**, each connection is a row, the worst state first, with a s | **Incomplete** | The last read is under 10 minutes old, but some metric queries failed or were cut short. The band says which. | | **Stalled** | No read for 30 minutes and no error. Maple retries every 5 minutes. | | **Can't be read** | Reads fail, and none has succeeded in the last 10 minutes. The band says why and what to do. | -| **Off** | Turned off in **Configure**. Live metric queries keep working. | +| **Off** | Turned off in **Configure**. Live metric queries work while the read-only service account exists. | The page header and the Integrations list show the connection's worst status: **Needs attention**, **Setup pending**, **Changes pending**, **Waiting for data** or **Healthy**. @@ -209,6 +209,7 @@ A connection that needs the setup script shows a band with one button. The butto | **Setup pending** | **Finish setup** | The script of a new connection has not reported, or its last run stopped part of the way. | | **Changes not applied in Google Cloud** | **Apply changes** | The saved configuration and what the script last reported differ, or the read-only service account is missing. The band says what goes on until the script has run. | | A failure's own message | **Run the script again** | The text of the failure asks for another run of the setup script. | +| **Live queries not set up** | **Run the script again** | The last run could not create the read-only service account, for example for a missing role or an organization policy. Log forwarding is unaffected. | Members who are not admins of the Maple organization see the statuses and the numbers, without **Configure**, the **⋮** menu or the band's button. The band asks them to have an admin run the setup script, and a line under the list says "Only Maple organization admins can add, configure or disconnect connections." @@ -253,7 +254,7 @@ Owner on the host project is the simplest. Without it, these predefined roles to | The project, folder or organization | Logs Configuration Writer (`roles/logging.configWriter`) | Log forwarding: the sink. | | The project, folder or organization | Project IAM Admin (`roles/resourcemanager.projectIamAdmin`), Folder IAM Admin (`roles/resourcemanager.folderIamAdmin`) or Organization Administrator (`roles/resourcemanager.organizationAdmin`) | The read-only roles. | -Before it changes anything, the script asks Google which permissions the signed-in account holds and stops with the missing ones. +Before it changes anything, the script asks Google which permissions the signed-in account holds and stops with the missing ones. Read-only access asks for its own just before its section: what it lacks stops only that section while log forwarding is on, and the run ends with "Done, except read-only access". ## Collected data @@ -506,13 +507,13 @@ On a self-hosted Maple, the **Key metrics** checkbox reads **Not available on th ### Domain restricted sharing -An organization policy can restrict which identities may hold IAM roles in your organization (`constraints/iam.allowedPolicyMemberDomains`, or a custom constraint on member domains). Maple's service account lives outside your organization, so such a policy can reject a grant the script makes, most often the one that lets Maple read as your read-only service account. The script then stops with "An organization policy (domain restricted sharing) blocks granting Maple's service account". +An organization policy can restrict which identities may hold IAM roles in your organization (`constraints/iam.allowedPolicyMemberDomains`, or a custom constraint on member domains). Maple's service account lives outside your organization, so such a policy can reject a grant the script makes for every connection: the one that lets Maple read as your read-only service account. Google turns this policy on by default for newer organizations. The script then prints "An organization policy (domain restricted sharing) blocks granting Maple's service account". With log forwarding on, log forwarding is set up anyway and the row says **Live queries not set up**; without it, the run stops. Don't turn the policy off or allow all principals for this. An Organization Policy Administrator can allow Maple's one service account on the host project, and nothing else: 1. Use the managed constraint **Restrict allowed policy members in IAM allow policies** (`iam.managed.allowedPolicyMembers`). The older `iam.allowedPolicyMemberDomains` can't make an exception for a single principal. 2. On the host project, set a policy for it whose allowed principals are your organization's principal set plus Maple's service account (`serviceAccount:` followed by the address the script's error names). Listing your organization keeps everyone in it allowed on the project. Google's steps: [Restrict identities with domain-restricted sharing](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains). -3. Paste the setup script again. It continues where it stopped. +3. Paste the setup script again. It sets up read-only access, and live metric queries work within a few minutes. Google checks the policy when a grant is made, so leave the exception in place: a later re-run grants again. diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index fa15e158b..3d1bd399f 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -12,6 +12,7 @@ import { gcpDraftEffect, gcpEffectNeedsScript, gcpApplicationLogsLine, + gcpLiveQueriesMissing, gcpLogFilterChoice, gcpLogState, gcpMessageParts, @@ -146,10 +147,16 @@ describe("gcpMetricsState", () => { }) }) - it("is setup running for two minutes after a run reported on logs alone", () => { + it("is setup running for two minutes after a run reported on logs alone, then says what the poller says", () => { const midRun = { applied_metrics_enabled: null, ...NEVER, last_metrics_error: SIGN_IN } expect(metrics({ ...midRun, setup_reported_at: ago(1) })).toEqual({ kind: "setup-running" }) - expect(metrics({ ...midRun, setup_reported_at: ago(3) })).toEqual({ kind: "setup-pending" }) + // The run could not set the read-only account up: no "setup pending" that never ends. + expect(metrics({ ...midRun, setup_reported_at: ago(3) })).toMatchObject({ kind: "waiting" }) + expect(metrics({ ...midRun, setup_reported_at: ago(30) })).toEqual({ + kind: "failing", + error: SIGN_IN, + lastMetricsReceivedAt: null, + }) }) it("trusts a read from the last 15 minutes over a missing report, not over a report of removal", () => { @@ -284,7 +291,7 @@ describe("gcpConnectionState", () => { ) }) - it("waits between the sections of a first run, and is setup pending if the second never reports", () => { + it("waits between the sections of a first run, then says live queries are not set up", () => { const midRun = { applied_metrics_enabled: null, setup_reported_at: ago(0), ...NEVER } expect(state(midRun)).toBe("waiting") expect(gcpSetupRunning(connector(midRun), NOW, true)).toBe(true) @@ -293,9 +300,19 @@ describe("gcpConnectionState", () => { // With key metrics off, the read-only account is the section still to come. expect(gcpSetupRunning(connector({ ...midRun, metrics_enabled: false }), NOW, true)).toBe(true) - const stopped = { ...midRun, setup_reported_at: ago(3) } - expect(state(stopped)).toBe("setup-pending") + expect(gcpLiveQueriesMissing(connector(midRun), NOW, true)).toBe(false) + + // Log forwarding is set up and the read-only account is not: calm, never "setup pending". + const stopped = { + ...midRun, + setup_reported_at: ago(3), + metrics_enabled: false, + last_log_received_at: ago(1), + } + expect(state(stopped)).toBe("healthy") expect(gcpSetupRunning(connector(stopped), NOW, true)).toBe(false) + expect(gcpLiveQueriesMissing(connector(stopped), NOW, true)).toBe(true) + expect(gcpLiveQueriesMissing(connector(stopped), NOW, false)).toBe(false) }) it("waits for data once the script reported", () => { diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index b1ed45218..d6b2106b1 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -54,6 +54,27 @@ const setupKind = ( ): "setup-pending" | "setup-running" => applied === null && ageMs(reportedAt, nowMs) < RUNNING_MS ? "setup-running" : "setup-pending" +/** + * The read-only service account that live queries and key metrics read as. A run that reported + * without it (`missing`) set log forwarding up and could not set the account up: the run says why, + * and log forwarding does not wait on it. Null when it is in place. + */ +const readerState = ( + connector: Pick< + V2GcpConnector, + "applied_metrics_enabled" | "last_metrics_received_at" | "setup_reported_at" + >, + nowMs: number, +): "setup-pending" | "setup-running" | "missing" | null => { + const applied = connector.applied_metrics_enabled + if (!needsSetup(applied, ageMs(connector.last_metrics_received_at, nowMs))) return null + return applied === null && + connector.setup_reported_at !== null && + ageMs(connector.setup_reported_at, nowMs) >= RUNNING_MS + ? "missing" + : setupKind(applied, connector.setup_reported_at, nowMs) +} + type GcpLogState = /** `stillSetUp`: the sink still forwards, because the script has not run since the switch. */ | { readonly kind: "off"; readonly stillSetUp: boolean } @@ -128,10 +149,10 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet const error = connector.last_metrics_error const age = ageMs(lastMetricsReceivedAt, nowMs) // Before the script has run the poller says it can't sign in. That is the expected start. + // After a run without the account, the poller's error says what to do. const reportedAt = connector.setup_reported_at - if (needsSetup(connector.applied_metrics_enabled, age)) { - return { kind: setupKind(connector.applied_metrics_enabled, reportedAt, nowMs) } - } + const reader = readerState(connector, nowMs) + if (reader === "setup-pending" || reader === "setup-running") return { kind: reader } const reportedAge = ageMs(reportedAt, nowMs) const waiting = { kind: "waiting", reportedAt, overdue: reportedAge > OVERDUE_READ_MS } as const // A run just reported: what the poller said before its grant works counts for nothing yet. @@ -177,19 +198,18 @@ export const GCP_METRICS_STATUS = { type ConnectorFields = LogFields & MetricsFields /** - * The read-only service account that live queries and key metrics read as. The script sets it up - * for every connection while this deployment has a Google identity (`metricsAvailable`), whatever - * the metrics switch says. Null when nothing waits on the script. + * The read-only service account, which the script sets up for every connection while this + * deployment has a Google identity (`metricsAvailable`), whatever the metrics switch says. */ -const gcpAccessState = ( +const gcpAccessState = (connector: ConnectorFields, nowMs: number, metricsAvailable: boolean) => + metricsAvailable ? readerState(connector, nowMs) : null + +/** A run set log forwarding up and could not set the read-only account up: no live queries. */ +export const gcpLiveQueriesMissing = ( connector: ConnectorFields, nowMs: number, metricsAvailable: boolean, -): "setup-pending" | "setup-running" | null => - metricsAvailable && - needsSetup(connector.applied_metrics_enabled, ageMs(connector.last_metrics_received_at, nowMs)) - ? setupKind(connector.applied_metrics_enabled, connector.setup_reported_at, nowMs) - : null +): boolean => gcpAccessState(connector, nowMs, metricsAvailable) === "missing" /** * What still waits on a re-run of the setup script, for a connection whose configuration disagrees diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index d1a33d682..629d9a853 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -51,6 +51,7 @@ import { GCP_SCOPE_NAMES, GCP_STATE_ORDER, gcpConnectionState, + gcpLiveQueriesMissing, gcpLogState, gcpMessageParts, gcpMetricsState, @@ -428,6 +429,23 @@ function GcpConnection({ onRunScript={onRunScript} /> )} + {/* With key metrics on, the poller's own error says this. */} + {!connector.metrics_enabled && gcpLiveQueriesMissing(connector, nowMs, metricsAvailable) ? ( + + Live queries not set up +

+ The last setup run couldn't create the read-only service account, so Maple + can't query this connection's metrics. Run the script again and fix what it + names under Read-only access. +

+
+ ) : null} {log.kind === "failing" ? ( diff --git a/apps/web/src/routes/infra/gcp/index.tsx b/apps/web/src/routes/infra/gcp/index.tsx index 2be3a5ada..d8f1e686d 100644 --- a/apps/web/src/routes/infra/gcp/index.tsx +++ b/apps/web/src/routes/infra/gcp/index.tsx @@ -248,7 +248,7 @@ function GcpInfra({ } title="Turn on metrics for Google Cloud" - description="Your Google Cloud connections don't collect key metrics. Turn on Key metrics under Configure in the Google Cloud integration." + description="Your Google Cloud connections don't collect key metrics. Turn on Key metrics under Configure in the Google Cloud integration. A connection without read-only access asks for its setup script there." integration="gcp" actionLabel="Open the integration" docsPage="gcp" @@ -382,7 +382,8 @@ function GcpNotice({ notice }: { notice: GcpInfraNotice }) { Google Cloud metrics are off These tables show what Maple collected before they were turned off. To collect again, - turn on Key metrics under Configure in the Google Cloud integration. + turn on Key metrics under Configure in the Google Cloud integration. A connection + without read-only access asks for its setup script there. {integration("Open the integration")} diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts index e2c8a774d..34ccda54a 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts @@ -253,7 +253,7 @@ export const gcpTemplate: TemplateDefinition = { label: "Google Cloud integration connected with Key metrics switched on", collector: "the Google Cloud integration with Key metrics switched on", setupLabel: "the Google Cloud integration", - hint: "Connect a project, folder or organization with Key metrics switched on. Each widget fills in once its service reports.", + hint: "Connect a project, folder or organization, run its setup script and switch on Key metrics. Each widget fills in once its service reports.", }, requiredMetricPrefixes: ["gcp."], parameters: [ diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts index 7e8d14e7f..5c5120188 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts @@ -372,8 +372,8 @@ describe.each(scopeTypes)("renderGcpSetupScript for a %s", (scopeType) => { RESOURCE_MANAGER_ON, ...scope.open, "projects describe acme-host --format=value(name)", - "billing projects describe acme-host --format=value(billingEnabled)", `logging read ${DEFAULT_LOG_FILTER} --limit=1 --freshness=1h ${scope.sink}`, + "billing projects describe acme-host --format=value(billingEnabled)", ]) expect(stdout).toBe(`Maple setup for ${scopeType} ${scope.id} Log forwarding on @@ -382,7 +382,6 @@ describe.each(scopeTypes)("renderGcpSetupScript for a %s", (scopeType) => { 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 @@ -396,6 +395,7 @@ Log forwarding ✓ Check message sent to Maple through the topic Read-only access + ✓ Billing is enabled ✓ 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) @@ -586,6 +586,7 @@ Log forwarding ✓ Check message sent to Maple through the topic Read-only access + ✓ Billing is enabled ✓ 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) @@ -747,24 +748,58 @@ Checking access ${UNFINISHED}`) }) - it("needs the organization to open for the read-only roles, and leaves that to the permissions for logs alone", () => { - const options = { fail: "organizations describe" } - const both = run(setup(setupInput("organization", 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.", + it("never lets read-only access block log forwarding for an admin with logging rights alone", () => { + const options = { lacking: "resourcemanager.organizations.setIamPolicy" } + // The organization's name is not theirs to read either, which is no error. + const on = run(setup(setupInput("organization", true)), { + ...options, + fail: "organizations describe", + }) + expect(on.status).toBe(0) + expect(on.commands).toEqual(expect.arrayContaining(logsSetupCommands(SCOPES.organization))) + expect(on.commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(on.stdout).toContain( + ` ✗ jane@acme.com is missing permissions on organization 123456789012: resourcemanager.organizations.setIamPolicy. + + What to do: Ask for Organization Administrator there, or have an administrator run this script. If the ID looks wrong, check it with: gcloud organizations list +`, + ) + expect(on.stdout).not.toContain(UNFINISHED) + expect(on.stdout).toContain( + "\nDone, except read-only access: live metric queries are not available yet. Fix what it\nnames above and paste the script again.\n", + ) + expect(on.stdout).not.toContain("Metrics: ") + + // Turning log forwarding off still removes the sink first; with nothing else to set up, + // the read-only access that fails then fails the run. + const off = run(setup(setupInput("organization", false)), { ...options, describe: "found" }) + expect(off.commands.slice(0, logsRemovalCommands(SCOPES.organization).length)).toEqual( + logsRemovalCommands(SCOPES.organization), ) + expect(off.status).toBe(1) + expect(off.stdout).toContain(UNFINISHED) + }) - // Logs Configuration Writer does not include reading the organization. Without a Google - // identity the script sets up log forwarding alone. - const logsOnly = run( - setup({ ...setupInput("organization", true), mapleServiceAccountEmail: undefined }), - options, + it("reads an organization policy that refuses Maple's account as a note when logs are forwarded", () => { + const refusal = + "ERROR: (gcloud.iam.service-accounts.add-iam-policy-binding) FAILED_PRECONDITION: One or more users named in the policy do not belong to a permitted customer." + const options = { fail: "roles/iam.serviceAccountTokenCreator", answer: refusal } + const on = run(setup(setupInput("project", true)), options) + expect(on.status).toBe(0) + expect(on.stdout).toContain(" ✗ Couldn't let Maple use the service account.") + expect(on.stdout).toContain( + "What to do: An organization policy (domain restricted sharing) blocks granting Maple's service account", ) - expect(logsOnly.status).toBe(0) - expect(logsOnly.stdout).not.toContain("Organization 123456789012 found") + expect(on.stdout).toContain("https://maple.dev/docs/integrations/gcp#domain-restricted-sharing") + expect(on.stdout).toContain("\nDone, except read-only access") + // No report on the account: what an earlier run said stands. + expect(on.commands).toContainEqual(publish('"logs":true')) + expect(on.commands).not.toContainEqual(expect.stringContaining('"metrics"')) + + const off = run(setup(setupInput("project", false)), options) + expect(off.status).toBe(1) + expect(off.stdout).toContain(UNFINISHED) + expect(off.curl).not.toContainEqual(expect.stringContaining('"metrics"')) }) it("switches on only the APIs that are off, so a caller who may not switch any on can still run it", () => { @@ -855,18 +890,20 @@ ${UNFINISHED}`) 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", () => { + it("sets log forwarding up when an API read-only access needs is off and may not be switched on", () => { const { status, stdout, commands } = run(setup(setupInput("project", true)), { apis: ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"], lacking: "serviceusage.services.enable", }) - expect(status).toBe(1) - expect(commands).toEqual([]) + expect(status).toBe(0) + expect(commands).toContainEqual(publish('"logs":true')) + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) 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. `) + expect(stdout).toContain("\nDone, except read-only access") }) it("reads an account without any of the permissions as a wrong ID first", () => { @@ -902,7 +939,7 @@ ${UNFINISHED}`) 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 + What to do: Ask for Logs Configuration Writer 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") diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts index 4da322f88..2f1615111 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -149,7 +149,8 @@ stop() { 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)} + # In a section the rest of the run does without (OPTIONAL=1), the run goes on. + [ "\${OPTIONAL-0}" = 1 ] || printf '\\n%s\\n' ${sh(unfinished)} exit 1 } @@ -329,30 +330,17 @@ interface AccessChecks { 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. */ + /** Log forwarding's `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 +const openScope = (scopeType: Exclude): string => + `# 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)" +if SCOPE_NAME="$(${SCOPES[scopeType].gcloud} describe "$SCOPE_ID" --format='value(displayName)' 2>/dev/null)"; then + ok "${scopeType === "folder" ? "Folder" : "Organization"} $SCOPE_ID found ($SCOPE_NAME)" fi ` -} const PERMISSION_CHECK = ` # allowed : asks Google which of @@ -409,32 +397,19 @@ fi 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 +${checks.apis.length === 0 ? "" : apisOff(scopeType, checks.apis)}${scopeType === "project" ? "" : openScope(scopeType)}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" ;; - # Only Maple's reads need billing, and Maple explains a refused read: the rest is set up anyway. - False) note "Billing is off in project $PROJECT_ID: live queries and key metrics stay unavailable until a billing account is linked at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID. The rest is set up regardless." ;; - *) 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 + : `${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 + .join("")}${switchingOn(LOG_APIS)}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." @@ -442,53 +417,47 @@ fi ` }${checks.sink === undefined ? "" : sinkAndFilter(checks.sink)}` -/** What the sections that set something up need, asked of Google before the first change. */ -const permissionChecks = ( - scopeType: GcpScopeType, - logs: boolean, - reader: boolean, -): AccessChecks["permissions"] => { - if (!logs && !reader) return [] +const ADMINISTRATOR = "or have an administrator run this script." + +/** What log forwarding needs, asked of Google before the first change. */ +const logPermissionChecks = (scopeType: GcpScopeType): AccessChecks["permissions"] => { 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", - ] - : []), - ...(reader - ? ["iam.serviceAccounts.get", "iam.serviceAccounts.create", "iam.serviceAccounts.setIamPolicy"] - : []), + "pubsub.topics.get", + "pubsub.topics.create", + "pubsub.topics.attachSubscription", + "pubsub.topics.setIamPolicy", + "pubsub.topics.publish", + "pubsub.subscriptions.get", + "pubsub.subscriptions.create", + "pubsub.subscriptions.update", ] - const sink = logs ? ["logging.sinks.get", "logging.sinks.create", "logging.sinks.update"] : [] - const askHost = `Ask for Owner there, ${administrator}` + const sink = ["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"] : []), ...(reader ? [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, ...(reader ? [`resourcemanager.${scopeType}s.setIamPolicy`] : [])], + `Ask for Logs Configuration Writer there, ${ADMINISTRATOR} If the ID looks wrong, check it with: ${scope.list}`, + sink, ], ] } +// Switching an API on takes a right of its own, asked about only when one of a section's is off. +const switchingOn = (apis: ReadonlyArray) => `OFF="$(missing ${apis.join(" ")})" +[ -z "$OFF" ] || allowed "projects/$PROJECT_ID" "project $PROJECT_ID" "This script has to switch on:$OFF. Ask for Service Usage Admin there, or have an administrator switch them on." \\ + serviceusage.services.enable +` + // 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 = ` @@ -623,13 +592,30 @@ ${notifies ? `notify "Maple notified" '"logs":false'\n` : ""}` const serviceAccountMember = '--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL"' -const readerSetup = (scopeType: GcpScopeType): string => ` +const readerSetup = (scopeType: GcpScopeType, logs: boolean): string => ` # ---- Read-only access ---- # Every connection has it. Maple queries the scope's metrics live as this account and, while key # metrics are on in Maple, reads them every 5 minutes and lists resources every hour. A re-run # never removes it: only the cleanup script does. +# It runs in a subshell, which a failed step ends. ${logs ? "Log forwarding, set up above, does not depend on it: the run still finishes and says what is missing." : "Nothing else was set up, so the run stops there."} +# Every step stops for itself: errexit does not hold where the caller reads the status. +read_only_access() ( section "Read-only access" - +# Only Maple's reads need billing, and Maple explains a refused read: the rest is set up anyway. +case "$(gcloud billing projects describe "$PROJECT_ID" --format='value(billingEnabled)' 2>/dev/null || true)" in + True) ok "Billing is enabled" ;; + False) note "Billing is off in project $PROJECT_ID: live queries and key metrics stay unavailable until a billing account is linked at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID. The rest is set up regardless." ;; + *) note "Couldn't tell whether billing is on. Cloud Monitoring only answers for projects with billing." ;; +esac +allowed "projects/$PROJECT_ID" "project $PROJECT_ID" "Ask for Owner there, ${ADMINISTRATOR}" \\ + resourcemanager.projects.getIamPolicy resourcemanager.projects.setIamPolicy iam.serviceAccounts.get iam.serviceAccounts.create iam.serviceAccounts.setIamPolicy +${ + scopeType === "project" + ? "" + : `allowed "${scopeType}s/$SCOPE_ID" "$SCOPE" "Ask for ${SCOPES[scopeType].iamRole} there, ${ADMINISTRATOR} If the ID looks wrong, check it with: ${SCOPES[scopeType].list}" \\ + resourcemanager.${scopeType}s.setIamPolicy +` +}${switchingOn(READER_APIS)} # 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 @@ -676,6 +662,15 @@ grant "Couldn't let Maple use the service account." "$OWNER" \\ ok "Maple allowed to read as that account" # "metrics" in a report is this account: in place (true) or removed (false). notify "Maple notified" '"metrics":true' +# The status tells the caller whether the account is new. +[ "$ACCOUNT_CREATED" = 0 ] || exit 3 +) +${logs ? "OPTIONAL=1\n" : ""}read_only_access && READER=0 || READER=$? +case "$READER" in + 0) ;; + 3) ACCOUNT_CREATED=1 ;; + *) ${logs ? "READER_FAILED=1" : 'exit "$READER"'} ;; +esac ` const readerRemoval = (scopeType: GcpScopeType): string => ` @@ -806,6 +801,7 @@ LOG_FILTER=${sh(gcpLogFilter(applicationLogs))} FILTER_REPLACED=0 ACCOUNT_CREATED=0 REMOVED=0 +READER_FAILED=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.", @@ -825,13 +821,19 @@ off() { done } +# missing : the ones of them the access check found off. +missing() { + local api + for api in "$@"; do + case " $APIS_OFF " in *" $api "*) printf ' %s' "$api" ;; esac + done +} + # apis : switches on the ones the access check found off. apis() { - local names="$1" api missing="" + local names="$1" missing shift - for api in "$@"; do - case " $APIS_OFF " in *" $api "*) missing="$missing $api" ;; esac - done + missing="$(missing "$@")" if [ -n "$missing" ]; then # Unquoted on purpose: one argument per API. try "Couldn't switch on:$missing." "Service Usage Admin on project $PROJECT_ID" \\ @@ -839,17 +841,18 @@ apis() { fi ok "APIs enabled ($names)" } -${logs || reader ? GRANT : ""}${logs ? "" : REMOVAL_HELPERS} +${logs || reader ? `${GRANT}${PERMISSION_CHECK}` : ""}${logs ? "" : REMOVAL_HELPERS} printf 'Maple setup for %s\\n' "$SCOPE" printf ' Log forwarding %s\\n Read-only access %s\\n' ${sh(logs ? "on" : "off, removing")} ${sh(readerState)} ${access(scopeType, { apis: [...new Set([...(logs ? LOG_APIS : []), ...(reader ? READER_APIS : [])])], sink: logs ? SCOPES[scopeType].sink : undefined, - scopeMustOpen: reader, - billing: reader, - permissions: permissionChecks(scopeType, logs, reader), -})}${logs ? logsSetup(scopeType) : logsRemoval(scopeType, true)}${reader ? readerSetup(scopeType) : ""} -if [ "$SINK_CREATED$ACCOUNT_CREATED$REMOVED$FILTER_REPLACED" = 0000 ]; then + permissions: logs ? logPermissionChecks(scopeType) : [], +})}${logs ? logsSetup(scopeType) : logsRemoval(scopeType, true)}${reader ? readerSetup(scopeType, logs) : ""} +if [ "$READER_FAILED" = 1 ]; then + printf '\\nDone, except read-only access: live metric queries are not available yet. Fix what it\\n' + printf 'names above and paste the script again.\\n' +elif [ "$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' @@ -891,7 +894,7 @@ ${output( "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)}${readerRemoval(input.scopeType)}${ +${access(input.scopeType, { apis: [], sink: undefined, permissions: [] })}${logsRemoval(input.scopeType, false)}${readerRemoval(input.scopeType)}${ input.pushEndpoint === undefined ? "" : ` From 6c1966d3e5dc6212c10120b09e624148d8decfb2 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 12:34:50 +0200 Subject: [PATCH 07/10] fix(gcp): harden the read-only access section errexit holds inside the section's subshell, the report on the account is sent by the parent so a failed report is remembered, OPTIONAL starts at 0, Maple's token grant comes before the roles on the scope, and creating the account is only asked about while it is missing. --- .../src/content/docs/integrations/gcp.md | 6 +-- .../integrations/gcp/setup-scripts.test.ts | 54 ++++++++++++++++--- .../integrations/gcp/setup-scripts.ts | 48 ++++++++++------- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 50de1d7a0..c20056317 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -66,7 +66,6 @@ Maple setup for project acme-prod Checking access ✓ Signed in as jane@acme.com ✓ Project acme-prod found (Acme Production) - ✓ Billing is enabled ✓ jane@acme.com has the permissions this script needs ✓ Log filter accepted @@ -80,10 +79,11 @@ Log forwarding ✓ Check message sent to Maple through the topic Read-only access + ✓ Billing is enabled ✓ 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 + ✓ Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer) ✓ Maple notified Done. Google Cloud is set up for Maple. @@ -93,7 +93,7 @@ Done. Google Cloud is set up for Maple. Metrics: Maple can query them within a few minutes. ``` -If a step fails, the script stops and prints what to do. Fix it and paste the script again: it continues where it stopped. Running the script again is safe, and its last line says what the run did: +If a step fails, the script stops and prints what to do. Fix it and paste the script again: it continues where it stopped. A failed step under **Read-only access** stops only that section while log forwarding is on: the run ends with "Done, except read-only access". Running the script again is safe, and its last line says what the run did: | The script ends with | The run | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts index 5c5120188..2d36c62d2 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.test.ts @@ -316,14 +316,19 @@ const logsSetupCommands = (scope: Scope) => [ publish('"logs":true'), ] +const ACCOUNT_DESCRIBE = `iam service-accounts describe ${ACCOUNT} --project=acme-host` + +// The account is looked for before the permission check and, missing, again once its APIs are on. +// Maple's grant comes before the roles on the scope. const readerSetupCommands = (scope: Scope) => [ + ACCOUNT_DESCRIBE, "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`, + ACCOUNT_DESCRIBE, `iam service-accounts create ${NAME} --project=acme-host --display-name=Maple metrics and resource reader`, + `iam service-accounts add-iam-policy-binding ${ACCOUNT} --project=acme-host --member=serviceAccount:${MAPLE_ACCOUNT} --role=roles/iam.serviceAccountTokenCreator --condition=None`, `${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) => [ @@ -398,8 +403,8 @@ Read-only access ✓ Billing is enabled ✓ 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 + ✓ Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer) ✓ Maple notified Done. Google Cloud is set up for Maple. @@ -589,8 +594,8 @@ Read-only access ✓ Billing is enabled ✓ 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 + ✓ Read-only roles granted (Monitoring Viewer, Cloud Asset Viewer, Service Usage Consumer) ✓ Maple notified Done. Everything is in place. @@ -757,7 +762,7 @@ ${UNFINISHED}`) }) expect(on.status).toBe(0) expect(on.commands).toEqual(expect.arrayContaining(logsSetupCommands(SCOPES.organization))) - expect(on.commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(on.commands).not.toContainEqual(expect.stringContaining("service-accounts create")) expect(on.stdout).toContain( ` ✗ jane@acme.com is missing permissions on organization 123456789012: resourcemanager.organizations.setIamPolicy. @@ -778,6 +783,7 @@ ${UNFINISHED}`) ) expect(off.status).toBe(1) expect(off.stdout).toContain(UNFINISHED) + expect(off.curl).not.toContainEqual(expect.stringContaining('"metrics"')) }) it("reads an organization policy that refuses Maple's account as a note when logs are forwarded", () => { @@ -890,6 +896,36 @@ ${UNFINISHED}`) expect(kept.access).not.toContainEqual(expect.stringContaining("logging read")) }) + it("ends read-only access at a failed step, with no report on the account", () => { + const created = run(setup(setupInput("folder", true)), { fail: "service-accounts create" }) + expect(created.status).toBe(0) + expect(created.commands).not.toContainEqual(expect.stringContaining(`serviceAccount:${ACCOUNT}`)) + expect(created.commands).not.toContainEqual(expect.stringContaining('"metrics"')) + expect(created.stdout).toContain(" ✗ Couldn't create the service account.") + expect(created.stdout).toContain("\nDone, except read-only access") + + // Maple's grant comes first, so a refused role on the scope is the last step issued. + const scope = run(setup(setupInput("folder", true)), { fail: "roles/monitoring.viewer" }) + expect(scope.status).toBe(0) + expect(scope.commands).not.toContainEqual(expect.stringContaining("roles/cloudasset.viewer")) + expect(scope.commands).not.toContainEqual(expect.stringContaining('"metrics"')) + }) + + it("doesn't claim a confirmation when the report on the account doesn't reach Maple", () => { + const { status, stdout } = run(setup(setupInput("project", true)), { fail: '"metrics":true' }) + expect(status).toBe(0) + expect(stdout).toContain("Couldn't reach Maple to confirm.") + expect(stdout).not.toContain("Maple confirms it") + }) + + it("ignores an OPTIONAL in the caller's environment", () => { + const { stdout } = run(setup(setupInput("project", true)), { + fail: "projects describe", + env: { OPTIONAL: "1" }, + }) + expect(stdout).toContain(UNFINISHED) + }) + it("sets log forwarding up when an API read-only access needs is off and may not be switched on", () => { const { status, stdout, commands } = run(setup(setupInput("project", true)), { apis: ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"], @@ -897,7 +933,7 @@ ${UNFINISHED}`) }) expect(status).toBe(0) expect(commands).toContainEqual(publish('"logs":true')) - expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) + expect(commands).not.toContainEqual(expect.stringContaining("service-accounts create")) expect(stdout) .toContain(` ✗ jane@acme.com is missing permissions on project acme-host: serviceusage.services.enable. @@ -1056,7 +1092,9 @@ esac const { status, stdout, commands } = run(setup(setupInput("project", false)), { 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) + expect( + commands.filter((command) => command.includes("roles/iam.serviceAccountTokenCreator")), + ).toHaveLength(3) }) it("leaves the read-only access alone when the deployment has lost its Google identity", () => { @@ -1323,7 +1361,7 @@ esac }), ) expect(status).toBe(0) - expect(commands.at(-1)).toContain(`--member=serviceAccount:${hostile} `) + expect(commands).toContainEqual(expect.stringContaining(`--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 index 2f1615111..5072c8cf8 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -140,6 +140,7 @@ interrupted() { printf '\\n\\n%s\\n' ${sh(interrupted)}; exit 130; } trap interrupted INT # stop [Google's answer] +OPTIONAL=0 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 @@ -150,7 +151,7 @@ stop() { fi printf '\\n What to do: %s\\n' "$2" # In a section the rest of the run does without (OPTIONAL=1), the run goes on. - [ "\${OPTIONAL-0}" = 1 ] || printf '\\n%s\\n' ${sh(unfinished)} + [ "$OPTIONAL" = 1 ] || printf '\\n%s\\n' ${sh(unfinished)} exit 1 } @@ -597,9 +598,8 @@ const readerSetup = (scopeType: GcpScopeType, logs: boolean): string => ` # Every connection has it. Maple queries the scope's metrics live as this account and, while key # metrics are on in Maple, reads them every 5 minutes and lists resources every hour. A re-run # never removes it: only the cleanup script does. -# It runs in a subshell, which a failed step ends. ${logs ? "Log forwarding, set up above, does not depend on it: the run still finishes and says what is missing." : "Nothing else was set up, so the run stops there."} -# Every step stops for itself: errexit does not hold where the caller reads the status. -read_only_access() ( +# It runs in a subshell with errexit on, which a failed step ends. ${logs ? "Log forwarding, set up above, does not depend on it: the run still finishes and says what is missing." : "Nothing else was set up, so the run stops there."} +read_only_access() { section "Read-only access" # Only Maple's reads need billing, and Maple explains a refused read: the rest is set up anyway. case "$(gcloud billing projects describe "$PROJECT_ID" --format='value(billingEnabled)' 2>/dev/null || true)" in @@ -607,8 +607,12 @@ case "$(gcloud billing projects describe "$PROJECT_ID" --format='value(billingEn False) note "Billing is off in project $PROJECT_ID: live queries and key metrics stay unavailable until a billing account is linked at https://console.cloud.google.com/billing/linkedaccount?project=$PROJECT_ID. The rest is set up regardless." ;; *) note "Couldn't tell whether billing is on. Cloud Monitoring only answers for projects with billing." ;; esac +# A look before the APIs are on can miss the account: it is looked for again before it is created. +ACCOUNT_EXISTS=0 +if exists gcloud iam service-accounts describe "$SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID"; then ACCOUNT_EXISTS=1; fi +# Unquoted on purpose: creating is asked about only while the account is missing. allowed "projects/$PROJECT_ID" "project $PROJECT_ID" "Ask for Owner there, ${ADMINISTRATOR}" \\ - resourcemanager.projects.getIamPolicy resourcemanager.projects.setIamPolicy iam.serviceAccounts.get iam.serviceAccounts.create iam.serviceAccounts.setIamPolicy + resourcemanager.projects.getIamPolicy resourcemanager.projects.setIamPolicy iam.serviceAccounts.get iam.serviceAccounts.setIamPolicy $([ "$ACCOUNT_EXISTS" = 1 ] || echo iam.serviceAccounts.create) ${ scopeType === "project" ? "" @@ -628,7 +632,7 @@ apis "Cloud Monitoring, Cloud Asset, IAM, IAM Credentials, Cloud Resource Manage # A service account that was deleted (by a cleanup run, or by a setup run from when key metrics # were 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 +if [ "$ACCOUNT_EXISTS" = 1 ] || 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" \\ @@ -637,6 +641,14 @@ else ACCOUNT_CREATED=1 fi +# Maple's own account may mint short-lived tokens for this one service account, nothing else. +# First: an organization policy that refuses Maple's account leaves no roles behind on the scope. +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" + # Read-only roles on the ${scopeType}${scopeType === "project" ? "" : ", inherited by every project under it"}: # roles/monitoring.viewer read metrics (monitoring.timeSeries.list), live or every 5 minutes # roles/cloudasset.viewer list resources and the scope's projects (cloudasset.assets.searchAllResources) @@ -653,22 +665,18 @@ 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" -# "metrics" in a report is this account: in place (true) or removed (false). -notify "Maple notified" '"metrics":true' # The status tells the caller whether the account is new. [ "$ACCOUNT_CREATED" = 0 ] || exit 3 -) -${logs ? "OPTIONAL=1\n" : ""}read_only_access && READER=0 || READER=$? -case "$READER" in - 0) ;; - 3) ACCOUNT_CREATED=1 ;; +} +${logs ? "OPTIONAL=1\n" : ""}set +e +( set -e; read_only_access ) +READER=$? +set -e +${logs ? "OPTIONAL=0\n" : ""}case "$READER" in + 0 | 3) + [ "$READER" = 0 ] || ACCOUNT_CREATED=1 + # "metrics" in a report is this account: in place (true) or removed (false). + notify "Maple notified" '"metrics":true' ;; *) ${logs ? "READER_FAILED=1" : 'exit "$READER"'} ;; esac ` From dad42b18f7a19bef5d99b79f378b50b9ef9ca4f2 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 20:36:22 +0200 Subject: [PATCH 08/10] feat(gcp): set a connection up with Terraform, and sign log pushes instead of a URL secret - deploy/terraform-google-maple: the resources the setup script creates, under the same names, with additive grants only; mock-provider tests run in CI. - The push subscription signs each request as a push service account that holds no role. The script and the module both create it; the script's secret now only authenticates its own reports. - setup_scripts returns the module block as `terraform`. check_access reads the scope as the read-only account and records it as in place, since Terraform reports nothing. - The run step switches between Cloud Shell and Terraform; the Terraform view confirms log forwarding by its first signed push and read-only access by Check access. --- .github/workflows/terraform-google-maple.yml | 37 +++ .../routes/v2/integrations-gcp.http.test.ts | 27 +- .../src/routes/v2/integrations-gcp.http.ts | 19 +- apps/api/src/routes/v2/v2-test-support.ts | 5 +- .../src/content/docs/integrations/gcp.md | 74 +++-- .../components/integrations/gcp-configure.tsx | 272 ++++++++++++++++-- apps/web/src/lab/gcp-connect-lab.tsx | 46 +++ deploy/terraform-google-maple/.gitignore | 3 + deploy/terraform-google-maple/README.md | 72 +++++ deploy/terraform-google-maple/logs.tf | 150 ++++++++++ deploy/terraform-google-maple/main.tf | 30 ++ deploy/terraform-google-maple/outputs.tf | 14 + deploy/terraform-google-maple/reader.tf | 61 ++++ .../tests/module.tftest.hcl | 151 ++++++++++ deploy/terraform-google-maple/variables.tf | 62 ++++ deploy/terraform-google-maple/versions.tf | 15 + .../integrations/GcpConnectorService.test.ts | 14 +- .../integrations/GcpConnectorService.ts | 24 +- .../integrations/GcpPromQlService.test.ts | 86 ++++++ .../services/integrations/GcpPromQlService.ts | 78 ++++- .../integrations/gcp/setup-scripts.test.ts | 114 +++++--- .../integrations/gcp/setup-scripts.ts | 118 +++++--- .../integrations/gcp/terraform.test.ts | 66 +++++ .../services/integrations/gcp/terraform.ts | 62 ++++ packages/domain/src/gcp.test.ts | 2 + packages/domain/src/gcp.ts | 14 +- .../domain/src/http/v2/integrations-gcp.ts | 46 +++ packages/domain/src/http/v2/openapi.test.ts | 1 + 28 files changed, 1531 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/terraform-google-maple.yml create mode 100644 deploy/terraform-google-maple/.gitignore create mode 100644 deploy/terraform-google-maple/README.md create mode 100644 deploy/terraform-google-maple/logs.tf create mode 100644 deploy/terraform-google-maple/main.tf create mode 100644 deploy/terraform-google-maple/outputs.tf create mode 100644 deploy/terraform-google-maple/reader.tf create mode 100644 deploy/terraform-google-maple/tests/module.tftest.hcl create mode 100644 deploy/terraform-google-maple/variables.tf create mode 100644 deploy/terraform-google-maple/versions.tf create mode 100644 packages/backend/src/services/integrations/gcp/terraform.test.ts create mode 100644 packages/backend/src/services/integrations/gcp/terraform.ts diff --git a/.github/workflows/terraform-google-maple.yml b/.github/workflows/terraform-google-maple.yml new file mode 100644 index 000000000..941e95534 --- /dev/null +++ b/.github/workflows/terraform-google-maple.yml @@ -0,0 +1,37 @@ +name: Terraform module (Google Cloud) + +on: + push: + branches: [main] + paths: + - "deploy/terraform-google-maple/**" + - ".github/workflows/terraform-google-maple.yml" + pull_request: + paths: + - "deploy/terraform-google-maple/**" + - ".github/workflows/terraform-google-maple.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Format, validate and plan against mocked providers + runs-on: ubuntu-latest + defaults: + run: + working-directory: deploy/terraform-google-maple + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + with: + tofu_version: 1.13.1 + - run: tofu fmt -recursive -check -diff + - run: tofu init -backend=false + - run: tofu validate + - run: tofu test diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index afb28127b..63c396a8a 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -216,11 +216,26 @@ describe("v2 gcp integration over HTTP", () => { application_logs: ["cloud_run", "cloud_functions", "app_engine", "gke"], }) expect(both.status).toBe(200) - expect(Object.keys(both.body).sort()).toEqual(["cleanup_script", "object", "setup_script"]) + expect(Object.keys(both.body).sort()).toEqual([ + "cleanup_script", + "object", + "setup_script", + "terraform", + ]) expect(both.body.object).toBe("gcp_connector.setup_scripts") + // The subscription is given no secret; only the script's own reports carry it. expect(both.body.setup_script).toMatch( - /PUSH_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}\?secret=maple_gcp_/, + /PUSH_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}'\n/, ) + expect(both.body.setup_script).toMatch( + /REPORT_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}\?secret=maple_gcp_/, + ) + expect(both.body.terraform).toMatch(/^ {2}scope_type += "folder"$/m) + expect(both.body.terraform).toMatch(/^ {2}project_id += "acme-host"$/m) + expect(both.body.terraform).toContain( + 'NOT protoPayload.methodName=\\"io.k8s.coordination.v1.leases.update\\"', + ) + expect(both.body.terraform).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{32}|secret=/) 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') @@ -262,7 +277,7 @@ describe("v2 gcp integration over HTTP", () => { expect(reader.body.setup_script).toContain( "MAPLE_SERVICE_ACCOUNT='collector@maple-prod.iam.gserviceaccount.com'", ) - expect(reader.body.setup_script).not.toContain("service-accounts delete") + expect(reader.body.setup_script).not.toContain('service-accounts delete "$SERVICE_ACCOUNT_EMAIL"') await harness.dispose() }) @@ -295,6 +310,11 @@ describe("v2 gcp integration over HTTP", () => { 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") + + // Without a Google identity there is no account to read as. + const check = await harness.request("POST", `${path}/check_access`, admin.secret) + expect(check.status).toBe(409) + expect(check.body.error.code).toBe("gcp_metrics_unavailable") await harness.dispose() }) @@ -310,6 +330,7 @@ describe("v2 gcp integration over HTTP", () => { ["POST", CONNECTORS, { scope_type: "project", scope_id: "other-project" }], ["PATCH", path, { metrics_enabled: true }], ["POST", `${path}/setup_scripts`, {}], + ["POST", `${path}/check_access`, undefined], ["DELETE", path, undefined], ] as const) { const asMember = await harness.request(method, target, member.secret, body) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts index dbfb87d6e..374948a99 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.ts @@ -1,5 +1,5 @@ import { HttpApiBuilder } from "effect/http-api" -import { CurrentTenant } from "@maple/domain/http" +import { CurrentTenant, GcpMetricsUnavailableError } from "@maple/domain/http" import type { V2GcpConnector } from "@maple/domain/http/v2" import { MapleApiV2, @@ -14,6 +14,7 @@ import { GcpConnectorService, type GcpConnector, } from "@maple/backend/services/integrations/GcpConnectorService" +import { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" // 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 @@ -51,6 +52,7 @@ const auditMetadata = (connector: GcpConnector) => ({ export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIntegration", (handlers) => Effect.gen(function* () { const gcp = yield* GcpConnectorService + const gcpPromQl = yield* GcpPromQlService return handlers .handle("status", () => @@ -109,9 +111,24 @@ export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIn object: "gcp_connector.setup_scripts" as const, setup_script: scripts.setupScript, cleanup_script: scripts.cleanupScript, + terraform: scripts.terraform, } }), ) + .handle("checkAccess", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const checked = yield* gcpPromQl + .checkAccess(tenant.orgId, params.id) + .pipe( + Effect.catchTag("@maple/http/errors/GcpPromQlUnavailableError", (error) => + Effect.fail(new GcpMetricsUnavailableError({ message: error.message })), + ), + ) + return { object: "gcp_connector.access_check" as const, ...checked } + }), + ) .handle("deleteConnector", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 56033f764..5c6a254f3 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -45,6 +45,7 @@ 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 { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" import { HttpV2InvestigationsLive } from "./investigations.http" @@ -158,7 +159,9 @@ const v2GroupLayersExceptOnboardingChecklist = (chatWorkspace: Layer.Layer Host project ID - Holds Maple's Pub/Sub topic and read-only service account. A project inside the{" "} + Holds Maple's Pub/Sub topic and service accounts. A project inside the{" "} {scopeName} with billing enabled that won't be deleted. +
+ + maple.tf{text === null ? null : ` · ${countLabel(text.split("\n").length, "line")}`} + + + + +
+ {failure !== null ? ( +
+

+ {failure.message} +

+ +
+ ) : text === null ? ( +
+ +
+ ) : ( +
+					{text}
+				
+ )} +
+ + No secret: Pub/Sub signs each push to Maple. Safe to commit. + + What it creates +
+ + ) +} + +/** Maple reading the scope as the read-only account, on request: Terraform reports nothing. */ +export interface GcpAccessCheck { + readonly checking: boolean + /** The last check's outcome; null before one. */ + readonly outcome: { readonly ok: boolean; readonly message: string | null } | null + readonly onCheck: () => void +} + +/** + * Under the module: what Maple has confirmed in Google Cloud. A Terraform run tells Maple nothing, + * so log forwarding is confirmed by its first signed push and read-only access by a check. + */ +function TerraformStatus({ + connector, + metricsAvailable, + access, +}: { + connector: V2GcpConnector + metricsAvailable: boolean + access: GcpAccessCheck +}) { + const logsDone = connector.applied_logs_enabled === true + const accessDone = connector.applied_metrics_enabled === true + const rows = [ + connector.logs_enabled + ? { + key: "logs", + title: CAPABILITIES.logs.title, + state: logsDone ? ("done" as const) : ("running" as const), + detail: logsDone + ? "Maple received a signed push" + : "Confirmed by the first entry, within about 10 minutes of the apply", + } + : logsDone + ? { + key: "logs", + title: CAPABILITIES.logs.title, + state: "pending" as const, + // No push and no report says a sink is gone. + detail: "Removed by the apply. Maple isn't told: close this once it ran", + } + : null, + metricsAvailable + ? { + key: "access", + title: "Read-only access", + state: accessDone ? ("done" as const) : ("pending" as const), + detail: accessDone ? "Maple read the scope as the account" : null, + } + : null, + ].filter((row) => row !== null) + const done = rows.filter((row) => row.state === "done").length + + return ( +
+
+ +

Waiting for Google Cloud

+ + {done > 0 ? `${done} of ${rows.length} done` : "Terraform reports nothing"} + +
+
    + {rows.map((row) => ( +
  • + + {row.title} + {row.detail !== null ? ( + {row.detail} + ) : ( + + + {access.outcome !== null && !access.outcome.ok ? ( + + {access.outcome.message} + + ) : ( + After the apply + )} + + )} +
  • + ))} +
+
+ ) +} + type RunRowState = "pending" | "running" | "done" function RunRowMarker({ state }: { state: RunRowState }) { @@ -1570,7 +1720,7 @@ function Applied({ "Google Cloud matches this configuration" )} -

The script finished. Data shows up in Maple on its own.

+

Setup finished. Data shows up in Maple on its own.

    {rows.map((row) => ( @@ -1603,7 +1753,9 @@ const runConfirmed = ( function ApplyStage({ filter, ...view -}: Omit & { filter: FilterState }) { +}: Omit & { + filter: FilterState +}) { const { connector, opened, nowMs, metricsAvailable } = view const choice = gcpLogFilterChoice(filter, connector.applied_logs_enabled === true) const { scripts, failure } = useGcpScripts(connector, choice.applicationLogs) @@ -1612,12 +1764,27 @@ function ApplyStage({ intervalMs: GCP_SETTLING_REFRESH_MS, enabled: !runConfirmed(connector, opened, nowMs, metricsAvailable), }) + const check = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "checkAccess"), { + mode: "promiseExit", + }) + const [outcome, setOutcome] = useState(null) + const [onCheck, checking] = useAsyncAction(async () => { + const result = await check({ params: { id: connector.id }, reactivityKeys: GCP_REACTIVITY_KEYS }) + if (Exit.isFailure(result)) { + showErrorToast(result, { title: "Couldn't check the read-only access" }) + return + } + setOutcome(result.value) + }) return ( void onCheck() }} /> ) } @@ -1631,8 +1798,13 @@ export interface GcpApplyViewProps { metricsAvailable: boolean /** The log filter in a few words. */ logFilter: string + /** An existing sink keeps its filter: the script leaves it, Terraform cannot. */ + filterKept: boolean script: string | null + /** The same setup as a Terraform module block. */ + terraform: string | null failure: ScriptFailure | null + access: GcpAccessCheck nowMs: number opened: ApplyOpened onBack: () => void @@ -1646,13 +1818,17 @@ export function GcpApplyView({ created, metricsAvailable, logFilter, + filterKept, script, + terraform, failure, + access, nowMs, opened, onBack, onClose, }: GcpApplyViewProps) { + const [method, setMethod] = useState<"script" | "terraform">("script") const sinkExists = connector.applied_logs_enabled === true const reportedAt = connector.setup_reported_at const confirmed = runConfirmed(connector, opened, nowMs, metricsAvailable) @@ -1687,13 +1863,41 @@ export function GcpApplyView({
    -
    -

    Run this script in Cloud Shell

    -

    Nothing changes in Google Cloud until it runs.

    +
    +
    +

    + {method === "script" + ? "Run this script in Cloud Shell" + : "Add this module to your Terraform"} +

    +

    + {method === "script" + ? "Nothing changes in Google Cloud until it runs." + : "It creates what the script does, under the same names."} +

    +
    + { + // Pressing the selected segment again empties the group: the view stays. + const next = values.find((value) => value !== method) + if (next === "script" || next === "terraform") setMethod(next) + }} + > + Cloud Shell + Terraform +
    - + {method === "script" ? ( + + ) : ( + + )}

    - Run it as an {role("Owner")} of{" "} + {method === "script" ? "Run" : "Apply"} it as an {role("Owner")} of{" "} {connector.scope_type === "project" ? null : "the host project "} {connector.project_id} {scopeRoles.map((name, index) => ( @@ -1703,22 +1907,42 @@ export function GcpApplyView({ ))} {scopeRoles.length > 0 ? ` on the ${scopeName}` : null}.{" "} - {!connector.logs_enabled && sinkExists - ? "Takes one to two minutes. " - : "Takes about a minute and is safe to run again. "} - - Roles if you aren't an Owner - + {method === "terraform" ? ( + <> + {connector.logs_enabled + ? filterKept + ? "Terraform sets the log filter in full, so the kept filter becomes platform logs only. " + : "Terraform sets the log filter in full. " + : null} + A connection the script set up is taken over by importing its resources first.{" "} + + Terraform setup + + + ) : ( + <> + {!connector.logs_enabled && sinkExists + ? "Takes one to two minutes. " + : "Takes about a minute and is safe to run again. "} + + Roles if you aren't an Owner + + + )}

    - + {method === "script" ? ( + + ) : ( + + )} Saved. Close this any time and come back to it.