Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
32ffe23
feat(gcp): read-only access for every connection, metrics switch cont…
JeremyFunk Oct 14, 2026
67376cb
feat(gcp): card, configure dialog and docs follow the key-metrics switch
JeremyFunk Oct 14, 2026
e314715
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into feat…
JeremyFunk Oct 14, 2026
02314ee
fix(gcp): review follow-ups for read-only access
JeremyFunk Oct 14, 2026
c1b098b
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into feat…
JeremyFunk Oct 14, 2026
d48bac5
fix(gcp): set up without billing, noting that live queries need it
JeremyFunk Oct 14, 2026
56b78df
chore(ios): regenerate the OpenAPI spec for the new GCP group descrip…
JeremyFunk Oct 14, 2026
91b1320
fix(gcp): read-only access never blocks log forwarding
JeremyFunk Oct 15, 2026
a436b1f
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into feat…
JeremyFunk Oct 15, 2026
6c1966d
fix(gcp): harden the read-only access section
JeremyFunk Oct 15, 2026
d4e06db
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
6fec18d
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
2f67c9f
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
356069a
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
f9f1e33
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
6cb1518
Merge feat/gcp-promql-query into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
11ac984
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into HEAD
JeremyFunk Oct 15, 2026
dad42b1
feat(gcp): set a connection up with Terraform, and sign log pushes in…
JeremyFunk Oct 15, 2026
666142a
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into HEAD
JeremyFunk Oct 15, 2026
03b24e8
fix(ci): raise the web bundle budget for #1477
JeremyFunk Oct 15, 2026
a3529f7
fix(ci): 1 KB headroom on the bundle budget; CI measures the PR merge…
JeremyFunk Oct 15, 2026
004fef6
fix(ci): resolve the bundle budget merge
JeremyFunk Oct 15, 2026
235ef6b
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
0f070c8
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 15, 2026
db4aa58
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
491ee88
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
7d6df5b
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into feat…
JeremyFunk Oct 16, 2026
44d36f9
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
39bbb31
Merge commit '479cea8e806ee08b9a5dcc0dc2bed564647710cf' into HEAD
JeremyFunk Oct 16, 2026
31ec7a2
Merge commit 'e9046f5f50052790138a4a4a91a63555c9593141' into HEAD
JeremyFunk Oct 16, 2026
1f64d36
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
99b54c3
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
18a14de
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
e5e0270
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
b3acbaf
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
6555809
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
84e4945
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
9047677
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
336bb8f
Merge branch 'feat/gcp-promql-query' into feat/gcp-live-query-access
JeremyFunk Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/terraform-google-maple.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 37 additions & 6 deletions apps/api/src/routes/v2/integrations-gcp.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -250,13 +265,23 @@ 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 })
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 "$SERVICE_ACCOUNT_EMAIL"')
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()

Expand Down Expand Up @@ -285,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()
})

Expand All @@ -300,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)
Expand Down
19 changes: 18 additions & 1 deletion apps/api/src/routes/v2/integrations-gcp.http.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,6 +14,7 @@
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
Expand Down Expand Up @@ -51,6 +52,7 @@
export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIntegration", (handlers) =>
Effect.gen(function* () {
const gcp = yield* GcpConnectorService
const gcpPromQl = yield* GcpPromQlService

return handlers
.handle("status", () =>
Expand Down Expand Up @@ -109,9 +111,24 @@
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) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

check_access returns 409 when status.metrics_available is true

F1 · Warning · correctness

status.metricsAvailable is derived from MAPLE_GCP_SERVICE_ACCOUNT_EMAIL (GcpConnectorService.ts:249) while GcpPromQlService.checkAccess needs MAPLE_GCP_SERVICE_ACCOUNT_KEY and fails with GcpPromQlUnavailableError, which this mapping rewrites to GcpMetricsUnavailableError (409). On a deployment that sets the email but not the key, the card and docs tell the admin live queries and the account check are available, then the check returns "Key-metrics collection is not available" — a 409 for an unrelated flag.

Derive `metricsAvailable` from both options (email and key) so the flag the client reads matches what `checkAccess` requires, or give the check its own error that the status flag agrees with.
🤖 Prompt to fix with an AI agent
In `apps/api/src/routes/v2/integrations-gcp.http.ts:125-127`: `check_access` returns 409 when `status.metrics_available` is `true`.

`status.metricsAvailable` is derived from `MAPLE_GCP_SERVICE_ACCOUNT_EMAIL` (`GcpConnectorService.ts:249`) while `GcpPromQlService.checkAccess` needs `MAPLE_GCP_SERVICE_ACCOUNT_KEY` and fails with `GcpPromQlUnavailableError`, which this mapping rewrites to `GcpMetricsUnavailableError` (409). On a deployment that sets the email but not the key, the card and docs tell the admin live queries and the account check are available, then the check returns "Key-metrics collection is not available" — a 409 for an unrelated flag.

Suggested fix: Derive `metricsAvailable` from both options (email and key) so the flag the client reads matches what `checkAccess` requires, or give the check its own error that the status flag agrees with.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

Effect.fail(new GcpMetricsUnavailableError({ message: error.message })),
),

Check warning on line 127 in apps/api/src/routes/v2/integrations-gcp.http.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: `check_access` returns 409 when `status.metrics_available` is `true`

`status.metricsAvailable` is derived from `MAPLE_GCP_SERVICE_ACCOUNT_EMAIL` (`GcpConnectorService.ts:249`) while `GcpPromQlService.checkAccess` needs `MAPLE_GCP_SERVICE_ACCOUNT_KEY` and fails with `GcpPromQlUnavailableError`, which this mapping rewrites to `GcpMetricsUnavailableError` (409). On a deployment that sets the email but not the key, the card and docs tell the admin live queries and the account check are available, then the check returns "Key-metrics collection is not available" — a 409 for an unrelated flag.
)
return { object: "gcp_connector.access_check" as const, ...checked }
}),
)
.handle("deleteConnector", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -158,7 +159,9 @@ const v2GroupLayersExceptOnboardingChecklist = (chatWorkspace: Layer.Layer<ChatW
),
HttpV2AgentFeedbackLive.pipe(Layer.provide(AgentFeedbackService.layer)),
// Same, plus the Env every harness provides as well.
HttpV2GcpIntegrationsLive.pipe(Layer.provide(GcpConnectorService.layer)),
HttpV2GcpIntegrationsLive.pipe(
Layer.provide(Layer.mergeAll(GcpConnectorService.layer, GcpPromQlService.layer)),
),
// Real reads over whichever `WarehouseQueryService` stub the test provides.
HttpV2SessionReplaysLive.pipe(
Layer.provide(
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7778,7 +7778,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"
},
{
Expand Down
Loading
Loading