diff --git a/platform/src/components/aws/helpers/apigateway-account.ts b/platform/src/components/aws/helpers/apigateway-account.ts index 7bbc3c7040..e2a4127669 100644 --- a/platform/src/components/aws/helpers/apigateway-account.ts +++ b/platform/src/components/aws/helpers/apigateway-account.ts @@ -1,54 +1,79 @@ import { getPartitionOutput, apigateway, iam } from "@pulumi/aws"; import { ComponentResourceOptions, + Output, + ProviderResource, jsonStringify, interpolate, } from "@pulumi/pulumi"; import { $print } from "../../component"; +import { lazy } from "../../../util/lazy"; + +// The API Gateway account is a singleton per provider (account + region), so +// one Read resource per provider is enough. Reading it once per ApiGateway +// component re-reads it from AWS that many times on every update. +const useAccountCache = lazy( + () => new Map>(), +); + +let cloudWatchRole: iam.Role | undefined; + +function useCloudWatchRole(opts: ComponentResourceOptions) { + const partition = getPartitionOutput(undefined, opts).partition; + cloudWatchRole ??= new iam.Role( + `APIGatewayPushToCloudWatchLogsRole`, + { + assumeRolePolicy: jsonStringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { + Service: "apigateway.amazonaws.com", + }, + Action: "sts:AssumeRole", + }, + ], + }), + managedPolicyArns: [ + interpolate`arn:${partition}:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs`, + ], + }, + { retainOnDelete: true, provider: opts.provider }, + ); + return cloudWatchRole; +} export function setupApiGatewayAccount( namePrefix: string, opts: ComponentResourceOptions, ) { + const cache = useAccountCache(); + const existing = cache.get(opts.provider); + if (existing) return existing; + + // The first provider keeps the bare name; later providers get a suffix to + // keep URNs unique when gateways span multiple providers. + const suffix = cache.size === 0 ? "" : `${cache.size + 1}`; const account = apigateway.Account.get( - `${namePrefix}APIGatewayAccount`, + `APIGatewayAccount${suffix}`, "APIGatewayAccount", undefined, { provider: opts.provider }, ); - return account.cloudwatchRoleArn.apply((arn) => { + const result = account.cloudwatchRoleArn.apply((arn) => { if (arn) return account; - const partition = getPartitionOutput(undefined, opts).partition; - const role = new iam.Role( - `APIGatewayPushToCloudWatchLogsRole`, - { - assumeRolePolicy: jsonStringify({ - Version: "2012-10-17", - Statement: [ - { - Effect: "Allow", - Principal: { - Service: "apigateway.amazonaws.com", - }, - Action: "sts:AssumeRole", - }, - ], - }), - managedPolicyArns: [ - interpolate`arn:${partition}:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs`, - ], - }, - { retainOnDelete: true, provider: opts.provider }, - ); - return new apigateway.Account( `${namePrefix}APIGatewayAccountSetup`, { - cloudwatchRoleArn: role.arn, + cloudwatchRoleArn: useCloudWatchRole(opts).arn, }, - { provider: opts.provider }, + { retainOnDelete: true, provider: opts.provider }, ); }); + + cache.set(opts.provider, result); + return result; } diff --git a/platform/test/components/apigateway-account.test.ts b/platform/test/components/apigateway-account.test.ts new file mode 100644 index 0000000000..187792bcb8 --- /dev/null +++ b/platform/test/components/apigateway-account.test.ts @@ -0,0 +1,98 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import * as pulumi from "@pulumi/pulumi"; + +// @ts-ignore +global.$app = { + name: "app", + stage: "test", +}; +// @ts-ignore +global.$util = pulumi; + +const ACCOUNT_TYPE = "aws:apigateway/account:Account"; +const registered: { type: string; name: string }[] = []; + +pulumi.runtime.setMocks( + { + newResource: function (args: pulumi.runtime.MockResourceArgs): { + id: string; + state: any; + } { + registered.push({ type: args.type, name: args.name }); + if (args.type === ACCOUNT_TYPE) { + return { + id: "APIGatewayAccount", + state: { + ...args.inputs, + cloudwatchRoleArn: "arn:aws:iam::111111111111:role/existing", + }, + }; + } + return { + id: args.name + "_id", + state: args.inputs, + }; + }, + call: function (args: pulumi.runtime.MockCallArgs) { + return args.inputs; + }, + }, + "project", + "stack", + false, +); + +describe("setupApiGatewayAccount", () => { + let setupApiGatewayAccount: typeof import("../../src/components/aws/helpers/apigateway-account").setupApiGatewayAccount; + let aws: typeof import("@pulumi/aws"); + + function resolveOutput(value: pulumi.Output) { + return new Promise((resolve) => { + value.apply((resolved) => { + resolve(resolved); + return resolved; + }); + }); + } + + function accountReads() { + return registered.filter((r) => r.type === ACCOUNT_TYPE); + } + + beforeAll(async () => { + setupApiGatewayAccount = ( + await import("../../src/components/aws/helpers/apigateway-account") + ).setupApiGatewayAccount; + aws = await import("@pulumi/aws"); + }); + + it("returns the same account for every gateway on the same provider", async () => { + const first = setupApiGatewayAccount("GatewayA", {}); + const second = setupApiGatewayAccount("GatewayB", {}); + + expect(second).toBe(first); + + await resolveOutput(first); + await resolveOutput(second); + expect(accountReads()).toHaveLength(1); + expect(accountReads()[0].name).toBe("APIGatewayAccount"); + }); + + it("creates one uniquely named read per distinct provider", async () => { + const east = new aws.Provider("east", { region: "us-east-1" }); + const west = new aws.Provider("west", { region: "us-west-2" }); + + const eastAccount = setupApiGatewayAccount("GatewayC", { provider: east }); + const westAccount = setupApiGatewayAccount("GatewayD", { provider: west }); + const eastAgain = setupApiGatewayAccount("GatewayE", { provider: east }); + + expect(eastAgain).toBe(eastAccount); + expect(westAccount).not.toBe(eastAccount); + + await resolveOutput(eastAccount); + await resolveOutput(westAccount); + const reads = accountReads(); + expect(reads).toHaveLength(3); + expect(new Set(reads.map((r) => r.name)).size).toBe(3); + }); +});