Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 52 additions & 27 deletions platform/src/components/aws/helpers/apigateway-account.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderResource | undefined, Output<apigateway.Account>>(),
);

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;
}
98 changes: 98 additions & 0 deletions platform/test/components/apigateway-account.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: pulumi.Output<T>) {
return new Promise<T>((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);
});
});
Loading