diff --git a/.github/workflows/terraform-google-maple.yml b/.github/workflows/terraform-google-maple.yml new file mode 100644 index 0000000000..941e955344 --- /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 e7ca5b0e6b..63c396a8aa 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') @@ -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() @@ -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() }) @@ -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) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts index dbfb87d6ec..374948a99a 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 56033f7649..5c6a254f3b 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 total + chunk.gzipBytes, 0) // integrations hub read, and the Infrastructure nav entry with its icon. The // /infra/gcp pages stay in their route chunks. // 702.5 KB from the PromQL query contract (#1475, 2026-10-15), measured in CI at 701.3. -// 706.5 KB with the Google Cloud page redesign (#1336) merged up. -// 707.5 KB with main's 1 KB from #1494, #1500, #1502 merged up the stack. -const maxGzipBytes = 707.5 * 1024 +// 703.5 KB from the connection access settings (#1477, 2026-10-15), measured in CI at 702.1. +// 707.5 KB with the Google Cloud page redesign (#1336) merged up. +// 708.5 KB with main's 1 KB from #1494, #1500, #1502 merged up the stack. +const maxGzipBytes = 708.5 * 1024 const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB` // Anything lazy-only: chat, replay, and every dev-only lab surface. The diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index b0aafc44ab..1ae9d1321e 100644 --- a/apps/web/src/components/integrations/gcp-configure.tsx +++ b/apps/web/src/components/integrations/gcp-configure.tsx @@ -58,6 +58,7 @@ import { gcpCollectLock, gcpCreateRequest, gcpDraftEffect, + gcpEffectNeedsScript, gcpLogFilterChoice, gcpLogState, gcpMetricsState, @@ -121,7 +122,7 @@ const SCOPES = { const SCOPE_TYPES: ReadonlyArray = ["project", "folder", "organization"] 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.", "metrics-unavailable": "Not available on this Maple deployment.", } as const satisfies { readonly [Lock in GcpCollectLock]: string } @@ -147,24 +148,27 @@ const CAPABILITIES = { }, }, metrics: { - title: "Metrics and resources", + title: "Key metrics", flag: "metrics_enabled", - description: - "Cloud Run, GKE, Compute Engine, Cloud SQL, Pub/Sub, Load Balancing and Cloud Functions. Read-only, every 5 minutes.", - run: { on: "Read-only service account", off: "Removes the service account" }, + description: "Every 5 minutes, and resources hourly, for alerts, Infrastructure and dashboards.", + run: { on: "Read-only service account", off: "Read-only service account" }, 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 { @@ -600,8 +604,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"]} +

) } @@ -947,7 +955,7 @@ function ScopeFields({ 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. void /** Leads into the run step, 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", @@ -1149,18 +1160,16 @@ function ChooseStage({ const resuming = immediate.filter(({ acts }) => acts === "resumes-now") const named = (list: typeof immediate) => list.map(({ capability }) => CAPABILITIES[capability].title.toLowerCase()).join(" and ") - // One save can turn one capability off and the other back on: the question names both. + // One save can turn one capability off and the other on: the question names both. const turned = [ stopping.length > 0 ? `${named(stopping)} off` : null, - resuming.length > 0 ? `${named(resuming)} back on` : null, + resuming.length > 0 ? `${named(resuming)} on` : null, ] .filter((part) => part !== null) .join(" and ") - // 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. @@ -1179,7 +1188,8 @@ function ChooseStage({ showErrorToast(result, { title: "Failed to save the change" }) return false } - onApply(draft) + if (scripted) onApply(draft) + else onClose() return true }) @@ -1231,14 +1241,7 @@ function ChooseStage({ {connector.scope_id}? } - // Turning off leaves something for the script to remove; turning back on leaves nothing to run. - confirmLabel={ - stopping.length === 0 - ? "Turn on" - : resuming.length === 0 - ? "Turn off and continue" - : "Save and continue" - } + confirmLabel={`${stopping.length > 0 && resuming.length > 0 ? "Save" : `Turn ${stopping.length > 0 ? "off" : "on"}`}${scripted ? " and continue" : ""}`} onConfirm={save} >
@@ -1298,11 +1301,7 @@ function RunSummary({ : connector.applied_logs_enabled === true ? "Logs · removed by this run" : null, - connector.metrics_enabled - ? "Metrics" - : connector.applied_metrics_enabled === true - ? "Metrics · removed by this run" - : null, + connector.metrics_enabled ? "Key metrics" : null, ].filter((label) => label !== null) return (
@@ -1419,6 +1418,156 @@ function ScriptCard({ ) } +/** + * The Terraform module block: a few lines, shown whole. It holds no secret, so unlike the script it + * can be committed. + */ +function TerraformCard({ terraform, failure }: { terraform: string | null; failure: ScriptFailure | null }) { + const text = terraform?.trimEnd() ?? null + return ( +
+
+ + 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 }) { @@ -1440,24 +1589,42 @@ function RunStatus({ opened, overdue, nowMs, + metricsAvailable, }: { connector: V2GcpConnector opened: ApplyOpened overdue: boolean nowMs: number + metricsAvailable: boolean }) { // A row ticks on a report made since the step opened, not on what an earlier run left in place. const reported = connector.setup_reported_at !== opened.seen - const running = gcpSetupRunning(connector, nowMs) - const rows = CAPABILITY_IDS.flatMap((capability) => { - const { title, flag, run } = CAPABILITIES[capability] - const wanted = connector[flag] - const applied = - capability === "logs" ? connector.applied_logs_enabled : connector.applied_metrics_enabled - if (!wanted && applied !== true) return [] + const running = gcpSetupRunning(connector, nowMs, metricsAvailable) + const row = (key: string, title: string, detail: string, wanted: boolean, applied: boolean | null) => { const state: RunRowState = applied === wanted && reported ? "done" : running ? "running" : "pending" - return [{ capability, title, detail: wanted ? run.on : run.off, state }] - }) + return { key, title, detail, state } + } + const rows = [ + connector.logs_enabled || connector.applied_logs_enabled === true + ? row( + "logs", + CAPABILITIES.logs.title, + connector.logs_enabled ? CAPABILITIES.logs.run.on : CAPABILITIES.logs.run.off, + connector.logs_enabled, + connector.applied_logs_enabled, + ) + : null, + // Every connection gets the read-only account while this deployment can read metrics. + metricsAvailable + ? row( + "access", + "Read-only access", + "Service account for live queries and key metrics", + true, + connector.applied_metrics_enabled, + ) + : null, + ].filter((entry) => entry !== null) const done = rows.filter((row) => row.state === "done").length const reporting = done > 0 || running @@ -1503,7 +1670,7 @@ function RunStatus({
    {rows.map((row) => (
  • @@ -1540,7 +1707,7 @@ function Applied({ metrics.kind === "off" ? null : { - title: "Metrics", + title: "Key metrics", status: GCP_METRICS_STATUS[metrics.kind], detail: metrics.kind === "waiting" ? "First read within about 10 minutes" : null, }, @@ -1561,12 +1728,12 @@ 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) => (
  • - {row.title} + {row.title} {row.detail} @@ -1580,30 +1747,52 @@ function Applied({ } /** A run's sections report one after the other: confirmed once the last one has. */ -const runConfirmed = (connector: V2GcpConnector, opened: ApplyOpened, nowMs: number) => - gcpScriptNeeded(connector, nowMs) === null && - !gcpSetupRunning(connector, nowMs) && +const runConfirmed = ( + connector: V2GcpConnector, + opened: ApplyOpened, + nowMs: number, + metricsAvailable: boolean, +) => + gcpScriptNeeded(connector, nowMs, metricsAvailable) === null && + !gcpSetupRunning(connector, nowMs, metricsAvailable) && (!opened.rerun || connector.setup_reported_at !== opened.seen) /** The run step: loads the script for the chosen filter and polls until Maple confirms the run. */ function ApplyStage({ filter, ...view -}: Omit & { filter: FilterState }) { - const { connector, opened, nowMs } = view +}: 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) const refresh = useAtomRefresh(gcpStatusQuery()) useIntervalRefresh(refresh, { intervalMs: GCP_SETTLING_REFRESH_MS, - enabled: !runConfirmed(connector, opened, nowMs), + 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() }} /> ) } @@ -1613,10 +1802,17 @@ export interface GcpApplyViewProps { connector: V2GcpConnector /** Created in this surface: the confirmed run reads as the connection being made. */ created: boolean + /** This deployment has a Google identity: every run sets read-only access up. */ + 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 @@ -1628,20 +1824,25 @@ export function GcpApplyView({ title, connector, 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) + const confirmed = runConfirmed(connector, opened, nowMs, metricsAvailable) 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} @@ -1670,13 +1871,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) => ( @@ -1686,16 +1915,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.