diff --git a/examples/aws-lambda-rollout/infra/alarms.ts b/examples/aws-lambda-rollout/infra/alarms.ts new file mode 100644 index 0000000000..613df3decd --- /dev/null +++ b/examples/aws-lambda-rollout/infra/alarms.ts @@ -0,0 +1,79 @@ +export function createCanaryAlarms( + name: string, + opts: { + fn: sst.aws.Function; + alertsTopic: sst.aws.SnsTopic; + }, +) { + const { fn, alertsTopic } = opts; + + // These alarms track a specific Lambda version via the ExecutedVersion + // dimension. When we deploy a new version, we replace the alarm so it starts + // fresh — no leftover state from the previous version. The old alarm is + // deleted after the new one is created so the deployment is not interrupted + // by the removal of the alarm mid-deploy. + const pulumiOpts: $util.CustomResourceOptions = { + replaceOnChanges: ["dimensions"], + deleteBeforeReplace: false, + }; + + // Triggers if any errors occur in the deployed version within a 5-minute window. + const errorAlarm = new aws.cloudwatch.MetricAlarm( + `${name}ErrorAlarm`, + { + alarmActions: [alertsTopic.arn], + namespace: "AWS/Lambda", + metricName: "Errors", + dimensions: { + FunctionName: fn.name, + Resource: getFunctionResource(fn.targetArn), + ExecutedVersion: fn.nodes.function.version, + }, + statistic: "Sum", + period: 300, + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: "GreaterThanOrEqualToThreshold", + treatMissingData: "notBreaching", + }, + pulumiOpts, + ); + + // Triggers if average latency exceeds 2 seconds in a 5-minute window. + const latencyAlarm = new aws.cloudwatch.MetricAlarm( + `${name}LatencyAlarm`, + { + alarmActions: [alertsTopic.arn], + namespace: "AWS/Lambda", + metricName: "Duration", + dimensions: { + FunctionName: fn.name, + Resource: getFunctionResource(fn.targetArn), + ExecutedVersion: fn.nodes.function.version, + }, + statistic: "Average", + period: 300, + evaluationPeriods: 1, + threshold: 2000, + comparisonOperator: "GreaterThanOrEqualToThreshold", + treatMissingData: "notBreaching", + }, + pulumiOpts, + ); + + return { errorAlarm, latencyAlarm }; +} + +/** + * Extracts the `FunctionName:Alias` resource identifier from a Lambda alias ARN. + * CloudWatch metrics use this format for the `Resource` dimension when tracking + * a specific alias. + * + * For example, given `arn:aws:lambda:us-east-1:123456789:function:my-fn:live`, + * this returns `my-fn:live`. + */ +function getFunctionResource(targetArn: $util.Input) { + return aws + .getArnOutput({ arn: targetArn }) + .resource.apply((r) => r.split(":").slice(1).join(":")); +} diff --git a/examples/aws-lambda-rollout/infra/topics.ts b/examples/aws-lambda-rollout/infra/topics.ts new file mode 100644 index 0000000000..eb9560a611 --- /dev/null +++ b/examples/aws-lambda-rollout/infra/topics.ts @@ -0,0 +1,36 @@ +export function createTopics(opts?: { email?: string }) { + const alertsTopic = new sst.aws.SnsTopic("Alerts"); + + allowCloudWatchPublish("Alerts", alertsTopic); + + if (opts?.email) { + subscribeEmail("Alerts", alertsTopic, opts.email); + } + + return { alertsTopic }; +} + +function subscribeEmail(name: string, topic: sst.aws.SnsTopic, email: string) { + new aws.sns.TopicSubscription(`${name}Email`, { + topic: topic.arn, + protocol: "email", + endpoint: email, + }); +} + +function allowCloudWatchPublish(name: string, topic: sst.aws.SnsTopic) { + new aws.sns.TopicPolicy(`${name}CloudWatchPolicy`, { + arn: topic.arn, + policy: aws.iam.getPolicyDocumentOutput({ + statements: [ + { + actions: ["sns:Publish"], + principals: [ + { type: "Service", identifiers: ["cloudwatch.amazonaws.com"] }, + ], + resources: [topic.arn], + }, + ], + }).json, + }); +} diff --git a/examples/aws-lambda-rollout/package.json b/examples/aws-lambda-rollout/package.json new file mode 100644 index 0000000000..88696893cd --- /dev/null +++ b/examples/aws-lambda-rollout/package.json @@ -0,0 +1,5 @@ +{ + "name": "aws-lambda-rollout", + "private": true, + "type": "module" +} diff --git a/examples/aws-lambda-rollout/scripts/test.ts b/examples/aws-lambda-rollout/scripts/test.ts new file mode 100644 index 0000000000..3b8490bdc9 --- /dev/null +++ b/examples/aws-lambda-rollout/scripts/test.ts @@ -0,0 +1,64 @@ +interface Result { + status: number; + latency: number; + version: string; + error: boolean; +} + +export async function loadTest(url: string, count = 50) { + console.log(`URL: ${url}\n`); + console.log(`Sending ${count} concurrent requests...\n`); + + const results: Result[] = await Promise.all( + Array.from({ length: count }, async () => { + const start = Date.now(); + try { + const res = await fetch(url); + const body = (await res.json()) as { version?: string }; + return { + status: res.status, + latency: Date.now() - start, + version: body.version ?? "unknown", + error: false, + }; + } catch { + return { + status: 0, + latency: Date.now() - start, + version: "unknown", + error: true, + }; + } + }), + ); + + const byVersion = new Map(); + for (const r of results) { + if (!byVersion.has(r.version)) byVersion.set(r.version, []); + byVersion.get(r.version)!.push(r); + } + + for (const [version, vResults] of byVersion) { + const succeeded = vResults.filter( + (r) => !r.error && r.status >= 200 && r.status < 300, + ); + const failed = vResults.filter((r) => r.error || r.status >= 400); + const avgLatency = + vResults.reduce((sum, r) => sum + r.latency, 0) / vResults.length; + + console.log(`Version ${version}:`); + console.log(` Requests: ${vResults.length}`); + console.log(` Succeeded: ${succeeded.length}`); + console.log(` Failed: ${failed.length}`); + console.log(` Avg latency: ${Math.round(avgLatency)}ms`); + + if (failed.length > 0) { + const statusCounts: Record = {}; + for (const r of failed) { + statusCounts[r.status] = (statusCounts[r.status] || 0) + 1; + } + console.log(` Status codes:`, statusCounts); + } + console.log(); + } +} diff --git a/examples/aws-lambda-rollout/src/api.ts b/examples/aws-lambda-rollout/src/api.ts new file mode 100644 index 0000000000..84ea0a78cb --- /dev/null +++ b/examples/aws-lambda-rollout/src/api.ts @@ -0,0 +1,9 @@ +export async function handler() { + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello from the API", + version: process.env.AWS_LAMBDA_FUNCTION_VERSION ?? "unknown", + }), + }; +} diff --git a/examples/aws-lambda-rollout/sst.config.ts b/examples/aws-lambda-rollout/sst.config.ts new file mode 100644 index 0000000000..dc585a4b18 --- /dev/null +++ b/examples/aws-lambda-rollout/sst.config.ts @@ -0,0 +1,84 @@ +/// + +/** + * ## AWS Lambda Rollout + * + * Deploys a Lambda function with a canary rollout. Each deploy publishes a new version + * and uses CodeDeploy to gradually shift traffic — 10% for 10 minutes, then 100%. + * + * CloudWatch alarms monitor the error rate and latency during the rollout. If either + * alarm fires, CodeDeploy automatically rolls back to the previous version. + * + * An SNS topic sends notifications on failures, rollbacks, and stops. + */ +export default $config({ + app(input) { + return { + name: "aws-lambda-rollout", + home: "aws", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const { createTopics } = await import("./infra/topics"); + const { createCanaryAlarms } = await import("./infra/alarms"); + + const { alertsTopic } = createTopics({ + // email: EMAIL, + }); + + const fn = new sst.aws.Function("Function", { + handler: "src/api.handler", + rollout: { latestUrl: true }, + url: true, + // Rollout only runs when function code changes. Set to false to deploy + // actual code since sst dev deploys a stub that never changes. + dev: false, + }); + + const { errorAlarm: canaryErrorAlarm, latencyAlarm: canaryLatencyAlarm } = + createCanaryAlarms("Function", { + fn, + alertsTopic, + }); + + fn.addRollout({ + type: "canary", + percentage: 10, + duration: "10 minutes", + wait: true, + alarms: [canaryErrorAlarm.name, canaryLatencyAlarm.name], + notifications: [ + { + name: "Alerts", + events: ["failure", "rollback", "stop"], + topic: alertsTopic.arn, + }, + ], + }); + + $util + .all([ + fn.url, + fn.nodes.function.version, + fn.nodes.rolloutDeployment?.apply( + (deployment) => deployment?.deploymentId, + ), + ]) + .apply(async ([url, version, deploymentId]) => { + // wait for CodeDeploy to update the lambda alias + await new Promise((r) => setTimeout(r, 10_000)); + + console.log( + `\nDeployed version ${version} (deployment: ${deploymentId})\n`, + ); + const { loadTest } = await import("./scripts/test"); + await loadTest(url); + }); + + return { + url: fn.url, + latestUrl: fn.latestUrl, + }; + }, +}); diff --git a/examples/aws-lambda-rollout/tsconfig.json b/examples/aws-lambda-rollout/tsconfig.json new file mode 100644 index 0000000000..28356dd6c2 --- /dev/null +++ b/examples/aws-lambda-rollout/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/examples/aws-lambda-smoke-test-function-url/package.json b/examples/aws-lambda-smoke-test-function-url/package.json new file mode 100644 index 0000000000..4d89e42452 --- /dev/null +++ b/examples/aws-lambda-smoke-test-function-url/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-lambda-smoke-test-function-url", + "private": true, + "dependencies": { + "sst": "^4" + } +} diff --git a/examples/aws-lambda-smoke-test-function-url/src/api.ts b/examples/aws-lambda-smoke-test-function-url/src/api.ts new file mode 100644 index 0000000000..89de3d8d73 --- /dev/null +++ b/examples/aws-lambda-smoke-test-function-url/src/api.ts @@ -0,0 +1,19 @@ +export async function handler(event: any) { + if (event.type === "health-check") { + return { + statusCode: 200, + body: JSON.stringify({ status: "healthy" }), + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello from the API", + version: process.env.AWS_LAMBDA_FUNCTION_VERSION, + }), + headers: { + "content-type": "application/json", + }, + }; +} diff --git a/examples/aws-lambda-smoke-test-function-url/src/before-traffic.ts b/examples/aws-lambda-smoke-test-function-url/src/before-traffic.ts new file mode 100644 index 0000000000..0648c4fe7a --- /dev/null +++ b/examples/aws-lambda-smoke-test-function-url/src/before-traffic.ts @@ -0,0 +1,24 @@ +import { Resource } from "sst"; +import { rollout } from "sst/aws/rollout"; + +export const handler = rollout.handler(async (event) => { + const status = await validate(); + await rollout.report(event, status); +}); + +async function validate(): Promise<"Succeeded" | "Failed"> { + try { + const resp = await fetch(Resource.Function.latestUrl); + const payload = await resp.text(); + + if (resp.ok) { + console.log("Health check passed:", payload); + return "Succeeded"; + } + console.log("Health check failed:", resp.status, payload); + return "Failed"; + } catch (err) { + console.error("Validation failed:", err); + return "Failed"; + } +} diff --git a/examples/aws-lambda-smoke-test-function-url/sst.config.ts b/examples/aws-lambda-smoke-test-function-url/sst.config.ts new file mode 100644 index 0000000000..9ce03a8f05 --- /dev/null +++ b/examples/aws-lambda-smoke-test-function-url/sst.config.ts @@ -0,0 +1,59 @@ +/// + +/** + * ## AWS Lambda Smoke Test with Function URL + * + * Deploys a Lambda function with a before-traffic smoke test using function URLs. + * + * The function has two URLs: + * - `url` — the stable endpoint, pointing to the alias managed by CodeDeploy + * - `latestUrl` — points to the latest published version for pre-deployment testing + * + * A before-traffic hook validates the new version before any traffic shifts to it. + * If validation fails, the deployment is aborted. + * + * ```ts title="sst.config.ts" + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * beforeTraffic: "src/before-traffic.handler", + * latestUrl: true, + * }, + * url: true, + * }); + * + * // Stable endpoint — traffic shifts here after validation + * fn.url; + * // Latest version endpoint — for pre-deployment testing + * fn.latestUrl; + * ``` + */ +export default $config({ + app(input) { + return { + name: "aws-lambda-smoke-test-function-url", + home: "aws", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const fn = new sst.aws.Function("Function", { + handler: "src/api.handler", + rollout: { + type: "all-at-once", + beforeTraffic: "src/before-traffic.handler", + latestUrl: true, + }, + url: true, + // Rollout only runs when function code changes. Set to false to deploy + // actual code since sst dev deploys a stub that never changes. + dev: false, + }); + + return { + url: fn.url, + latestUrl: fn.latestUrl, + }; + }, +}); diff --git a/examples/aws-lambda-smoke-test-function-url/tsconfig.json b/examples/aws-lambda-smoke-test-function-url/tsconfig.json new file mode 100644 index 0000000000..6ec1c39406 --- /dev/null +++ b/examples/aws-lambda-smoke-test-function-url/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true + } +} diff --git a/examples/aws-lambda-smoke-test-http-api/package.json b/examples/aws-lambda-smoke-test-http-api/package.json new file mode 100644 index 0000000000..3171940fde --- /dev/null +++ b/examples/aws-lambda-smoke-test-http-api/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-lambda-smoke-test-http-api", + "private": true, + "dependencies": { + "sst": "^4" + } +} diff --git a/examples/aws-lambda-smoke-test-http-api/src/api.ts b/examples/aws-lambda-smoke-test-http-api/src/api.ts new file mode 100644 index 0000000000..89de3d8d73 --- /dev/null +++ b/examples/aws-lambda-smoke-test-http-api/src/api.ts @@ -0,0 +1,19 @@ +export async function handler(event: any) { + if (event.type === "health-check") { + return { + statusCode: 200, + body: JSON.stringify({ status: "healthy" }), + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello from the API", + version: process.env.AWS_LAMBDA_FUNCTION_VERSION, + }), + headers: { + "content-type": "application/json", + }, + }; +} diff --git a/examples/aws-lambda-smoke-test-http-api/src/before-traffic.ts b/examples/aws-lambda-smoke-test-http-api/src/before-traffic.ts new file mode 100644 index 0000000000..1070196b7a --- /dev/null +++ b/examples/aws-lambda-smoke-test-http-api/src/before-traffic.ts @@ -0,0 +1,24 @@ +import { Resource } from "sst"; +import { rollout } from "sst/aws/rollout"; + +export const handler = rollout.handler(async (event) => { + const status = await validate(); + await rollout.report(event, status); +}); + +async function validate(): Promise<"Succeeded" | "Failed"> { + try { + const resp = await fetch(Resource.TestApi.url); + const payload = await resp.text(); + + if (resp.ok) { + console.log("Health check passed:", payload); + return "Succeeded"; + } + console.log("Health check failed:", resp.status, payload); + return "Failed"; + } catch (err) { + console.error("Validation failed:", err); + return "Failed"; + } +} diff --git a/examples/aws-lambda-smoke-test-http-api/sst.config.ts b/examples/aws-lambda-smoke-test-http-api/sst.config.ts new file mode 100644 index 0000000000..da5c5e56f7 --- /dev/null +++ b/examples/aws-lambda-smoke-test-http-api/sst.config.ts @@ -0,0 +1,90 @@ +/// + +/** + * ## AWS Lambda Smoke Test with HTTP API + * + * Deploys a Lambda function with a before-traffic smoke test using API Gateway HTTP API. + * + * A before-traffic hook validates the new version before any traffic shifts to it. + * If validation fails, the deployment is aborted. + * + * #### Create the function with rollout + * + * ```ts title="sst.config.ts" + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * beforeTraffic: { + * handler: "src/before-traffic.handler", + * link: [testApi], + * }, + * }, + * }); + * ``` + * + * #### Route traffic through API Gateway + * + * Two API Gateway HTTP API endpoints expose the function: + * - `api` — routes to the stable alias managed by CodeDeploy + * - `testApi` — routes to the latest published version for pre-deployment testing + * + * ```ts title="sst.config.ts" + * const api = new sst.aws.ApiGatewayV2("Api"); + * api.route("$default", fn); + * + * const testApi = new sst.aws.ApiGatewayV2("TestApi"); + * testApi.route("$default", fn.latestTargetArn); + * ``` + * + * #### Full config + * + * ```ts title="sst.config.ts" + * const api = new sst.aws.ApiGatewayV2("Api"); + * const testApi = new sst.aws.ApiGatewayV2("TestApi"); + * + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * beforeTraffic: { + * handler: "src/before-traffic.handler", + * link: [testApi], + * }, + * }, + * }); + * + * api.route("$default", fn); + * testApi.route("$default", fn.latestTargetArn); + * ``` + */ +export default $config({ + app(input) { + return { + name: "aws-lambda-smoke-test-http-api", + home: "aws", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const api = new sst.aws.ApiGatewayV2("Api"); + const testApi = new sst.aws.ApiGatewayV2("TestApi"); + + const fn = new sst.aws.Function("Function", { + handler: "src/api.handler", + rollout: { + type: "all-at-once", + beforeTraffic: { + handler: "src/before-traffic.handler", + link: [testApi], + }, + }, + // Rollout only runs when function code changes. Set to false to deploy + // actual code since sst dev deploys a stub that never changes. + dev: false, + }); + + api.route("$default", fn); + testApi.route("$default", fn.latestTargetArn); + }, +}); diff --git a/examples/aws-lambda-smoke-test-http-api/tsconfig.json b/examples/aws-lambda-smoke-test-http-api/tsconfig.json new file mode 100644 index 0000000000..6ec1c39406 --- /dev/null +++ b/examples/aws-lambda-smoke-test-http-api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true + } +} diff --git a/examples/aws-lambda-smoke-test-router/package.json b/examples/aws-lambda-smoke-test-router/package.json new file mode 100644 index 0000000000..a379ced1a8 --- /dev/null +++ b/examples/aws-lambda-smoke-test-router/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-lambda-smoke-test-router", + "private": true, + "dependencies": { + "sst": "^4" + } +} diff --git a/examples/aws-lambda-smoke-test-router/src/api.ts b/examples/aws-lambda-smoke-test-router/src/api.ts new file mode 100644 index 0000000000..89de3d8d73 --- /dev/null +++ b/examples/aws-lambda-smoke-test-router/src/api.ts @@ -0,0 +1,19 @@ +export async function handler(event: any) { + if (event.type === "health-check") { + return { + statusCode: 200, + body: JSON.stringify({ status: "healthy" }), + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello from the API", + version: process.env.AWS_LAMBDA_FUNCTION_VERSION, + }), + headers: { + "content-type": "application/json", + }, + }; +} diff --git a/examples/aws-lambda-smoke-test-router/src/before-traffic.ts b/examples/aws-lambda-smoke-test-router/src/before-traffic.ts new file mode 100644 index 0000000000..0648c4fe7a --- /dev/null +++ b/examples/aws-lambda-smoke-test-router/src/before-traffic.ts @@ -0,0 +1,24 @@ +import { Resource } from "sst"; +import { rollout } from "sst/aws/rollout"; + +export const handler = rollout.handler(async (event) => { + const status = await validate(); + await rollout.report(event, status); +}); + +async function validate(): Promise<"Succeeded" | "Failed"> { + try { + const resp = await fetch(Resource.Function.latestUrl); + const payload = await resp.text(); + + if (resp.ok) { + console.log("Health check passed:", payload); + return "Succeeded"; + } + console.log("Health check failed:", resp.status, payload); + return "Failed"; + } catch (err) { + console.error("Validation failed:", err); + return "Failed"; + } +} diff --git a/examples/aws-lambda-smoke-test-router/sst.config.ts b/examples/aws-lambda-smoke-test-router/sst.config.ts new file mode 100644 index 0000000000..fd7a84c173 --- /dev/null +++ b/examples/aws-lambda-smoke-test-router/sst.config.ts @@ -0,0 +1,72 @@ +/// + +/** + * ## AWS Lambda Smoke Test with Router + * + * Deploys a Lambda function with a before-traffic smoke test using an SST Router. + * + * A before-traffic hook validates the new version before any traffic shifts to it. + * If validation fails, the deployment is aborted. + * + * #### Create the router and function with rollout + * + * The function's `url` is the stable endpoint at `/api`. The `latestUrl` exposes the + * latest published version at `/api/test` for pre-deployment testing. + * + * ```ts title="sst.config.ts" + * const router = new sst.aws.Router("Router"); + * + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * latestUrl: { router: { instance: router, path: "/api/test" } }, + * beforeTraffic: "src/before-traffic.handler", + * }, + * url: { + * router: { instance: router, path: "/api" }, + * }, + * }); + * ``` + * + * #### Access the URLs + * + * ```ts title="sst.config.ts" + * // Stable endpoint — traffic shifts here after validation + * fn.url; + * // Latest version endpoint — for pre-deployment testing + * fn.latestUrl; + * ``` + */ +export default $config({ + app(input) { + return { + name: "aws-lambda-smoke-test-router", + home: "aws", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const router = new sst.aws.Router("Router"); + + const fn = new sst.aws.Function("Function", { + handler: "src/api.handler", + rollout: { + type: "all-at-once", + latestUrl: { router: { instance: router, path: "/api/test" } }, + beforeTraffic: "src/before-traffic.handler", + }, + url: { + router: { instance: router, path: "/api" }, + }, + // Rollout only runs when function code changes. Set to false to deploy + // actual code since sst dev deploys a stub that never changes. + dev: false, + }); + + return { + url: fn.url, + latestUrl: fn.latestUrl, + }; + }, +}); diff --git a/examples/aws-lambda-smoke-test-router/tsconfig.json b/examples/aws-lambda-smoke-test-router/tsconfig.json new file mode 100644 index 0000000000..6ec1c39406 --- /dev/null +++ b/examples/aws-lambda-smoke-test-router/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true + } +} diff --git a/examples/aws-lambda-smoke-test/package.json b/examples/aws-lambda-smoke-test/package.json new file mode 100644 index 0000000000..4a823531a1 --- /dev/null +++ b/examples/aws-lambda-smoke-test/package.json @@ -0,0 +1,8 @@ +{ + "name": "aws-lambda-smoke-test", + "private": true, + "dependencies": { + "@aws-sdk/client-lambda": "^3.540.0", + "sst": "^4" + } +} diff --git a/examples/aws-lambda-smoke-test/src/api.ts b/examples/aws-lambda-smoke-test/src/api.ts new file mode 100644 index 0000000000..89de3d8d73 --- /dev/null +++ b/examples/aws-lambda-smoke-test/src/api.ts @@ -0,0 +1,19 @@ +export async function handler(event: any) { + if (event.type === "health-check") { + return { + statusCode: 200, + body: JSON.stringify({ status: "healthy" }), + }; + } + + return { + statusCode: 200, + body: JSON.stringify({ + message: "Hello from the API", + version: process.env.AWS_LAMBDA_FUNCTION_VERSION, + }), + headers: { + "content-type": "application/json", + }, + }; +} diff --git a/examples/aws-lambda-smoke-test/src/before-traffic.ts b/examples/aws-lambda-smoke-test/src/before-traffic.ts new file mode 100644 index 0000000000..20627fb9a7 --- /dev/null +++ b/examples/aws-lambda-smoke-test/src/before-traffic.ts @@ -0,0 +1,34 @@ +import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda"; +import { Resource } from "sst"; +import { rollout } from "sst/aws/rollout"; + +const lambda = new LambdaClient(); + +export const handler = rollout.handler(async (event) => { + const status = await validate(); + await rollout.report(event, status); +}); + +async function validate(): Promise<"Succeeded" | "Failed"> { + try { + const resp = await lambda.send( + new InvokeCommand({ + FunctionName: Resource.Function.name, + Qualifier: Resource.Function.latestQualifier, + Payload: JSON.stringify({ health: true }), + }), + ); + + if (resp.FunctionError) { + console.log("Invocation failed:", resp.FunctionError); + return "Failed"; + } + + const payload = new TextDecoder().decode(resp.Payload); + console.log("Health check passed:", payload); + return "Succeeded"; + } catch (err) { + console.error("Validation failed:", err); + return "Failed"; + } +} diff --git a/examples/aws-lambda-smoke-test/sst.config.ts b/examples/aws-lambda-smoke-test/sst.config.ts new file mode 100644 index 0000000000..1a2b180049 --- /dev/null +++ b/examples/aws-lambda-smoke-test/sst.config.ts @@ -0,0 +1,85 @@ +/// + +/** + * ## AWS Lambda Smoke Test + * + * Deploys a Lambda function with a before-traffic smoke test that invokes the + * new version directly using the AWS Lambda SDK. + * + * A before-traffic hook invokes the new version via `Resource.Function.name` + * and validates the response before any traffic shifts to it. If validation + * fails, the deployment is aborted. + * + * #### Create the function with rollout + * + * The before-traffic hook is linked to the function so it can access the + * function name at runtime via `Resource.Function.name`. + * + * ```ts title="sst.config.ts" + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * beforeTraffic: "src/before-traffic.handler", + * }, + * }); + * ``` + * + * #### Invoke the new version in the before-traffic hook + * + * ```ts title="src/before-traffic.ts" + * const resp = await lambda.send( + * new InvokeCommand({ + * FunctionName: Resource.Function.name, + * Qualifier: Resource.Function.latestQualifier, + * Payload: JSON.stringify({ health: true }), + * }), + * ); + * ``` + * + * #### Report the result + * + * ```ts title="src/before-traffic.ts" + * import { rollout } from "sst/aws/rollout"; + * + * export const handler = rollout.handler(async (event) => { + * const result = // ... invoke and validate the new version + * await rollout.report(event, result ? "Succeeded" : "Failed"); + * }); + * ``` + */ +export default $config({ + app(input) { + return { + name: "aws-lambda-smoke-test", + home: "aws", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const fn = new sst.aws.Function("Function", { + handler: "src/api.handler", + rollout: { + type: "all-at-once", + beforeTraffic: "src/before-traffic.handler", + }, + // Rollout only runs when function code changes. Set to false to deploy + // actual code since sst dev deploys a stub that never changes. + dev: false, + }); + + new sst.x.DevCommand("InvokeStable", { + dev: { + autostart: false, + command: $interpolate`aws lambda invoke --function-name ${fn.targetArn} --payload '{}' /dev/stdout`, + }, + }); + + new sst.x.DevCommand("InvokeLatest", { + dev: { + autostart: false, + command: $interpolate`aws lambda invoke --function-name ${fn.latestTargetArn} --payload '{}' /dev/stdout`, + }, + }); + }, +}); diff --git a/examples/aws-lambda-smoke-test/tsconfig.json b/examples/aws-lambda-smoke-test/tsconfig.json new file mode 100644 index 0000000000..6ec1c39406 --- /dev/null +++ b/examples/aws-lambda-smoke-test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true + } +} diff --git a/go.mod b/go.mod index 14bcac4ab0..974622cc3f 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ require ( github.com/BurntSushi/toml v1.2.1 github.com/Masterminds/semver/v3 v3.2.1 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 - github.com/aws/aws-sdk-go-v2 v1.36.2 + github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.27.11 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/appsync v1.39.0 github.com/aws/aws-sdk-go-v2/service/cloudfront v1.38.4 github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore v1.8.16 + github.com/aws/aws-sdk-go-v2/service/codedeploy v1.35.13 github.com/aws/aws-sdk-go-v2/service/ecr v1.32.0 github.com/aws/aws-sdk-go-v2/service/ecs v1.53.1 github.com/aws/aws-sdk-go-v2/service/iam v1.38.2 @@ -21,7 +22,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.53.1 github.com/aws/aws-sdk-go-v2/service/ssm v1.49.2 github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 - github.com/aws/smithy-go v1.22.2 + github.com/aws/smithy-go v1.24.2 github.com/briandowns/spinner v1.23.0 github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 github.com/charmbracelet/huh v0.3.0 @@ -66,8 +67,8 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.33 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.33 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect diff --git a/go.sum b/go.sum index e99aa24b82..187fce620c 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.36.2 h1:Ub6I4lq/71+tPb/atswvToaLGVMxKZvjYDVOWEExOcU= -github.com/aws/aws-sdk-go-v2 v1.36.2/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -37,10 +37,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHH github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.33 h1:knLyPMw3r3JsU8MFHWctE4/e2qWbPaxDYLlohPvnY8c= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.33/go.mod h1:EBp2HQ3f+XCB+5J+IoEbGhoV7CpJbnrsd4asNXmTL0A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.33 h1:K0+Ne08zqti8J9jwENxZ5NoUyBnaFDTu3apwQJWrwwA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.33/go.mod h1:K97stwwzaWzmqxO8yLGHhClbVW1tC6VT1pDLk1pGrq4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.33 h1:/frG8aV09yhCVSOEC2pzktflJJO48NwY3xntHBwxHiA= @@ -51,6 +51,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudfront v1.38.4 h1:I/sQ9uGOs72/483obb2SP github.com/aws/aws-sdk-go-v2/service/cloudfront v1.38.4/go.mod h1:P6ByphKl2oNQZlv4WsCaLSmRncKEcOnbitYLtJPfqZI= github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore v1.8.16 h1:VxfVyaJ/0XKzjRq79MA56vNfkcRVZ64AoqD7KiPS/yk= github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore v1.8.16/go.mod h1:HD1r3kr68+NEPZw+JbHzrfJ1QlhDCujDjlwI+hJrbWU= +github.com/aws/aws-sdk-go-v2/service/codedeploy v1.35.13 h1:YpFVX7FEGD15QICwKNLqVASm8ecum2f9dtQfkXP/ZKg= +github.com/aws/aws-sdk-go-v2/service/codedeploy v1.35.13/go.mod h1:654AJ0/dq0smO+gJBvDSh42YmWUZl28+yLPRDvfRCq4= github.com/aws/aws-sdk-go-v2/service/ecr v1.32.0 h1:lZoKOTEQUf5Oi9qVaZM/Hb0Z6SHIwwpDjbLFOVgB2t8= github.com/aws/aws-sdk-go-v2/service/ecr v1.32.0/go.mod h1:RhaP7Wil0+uuuhiE4FzOOEFZwkmFAk1ZflXzK+O3ptU= github.com/aws/aws-sdk-go-v2/service/ecs v1.53.1 h1:sAT2jzHkds1cv7VvNpzFfCw2w3zAkh306x3MTLPjuoA= @@ -81,8 +83,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2K github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= diff --git a/pkg/server/resource/aws-codedeploy-deployment-waiter.go b/pkg/server/resource/aws-codedeploy-deployment-waiter.go new file mode 100644 index 0000000000..8c37165fe5 --- /dev/null +++ b/pkg/server/resource/aws-codedeploy-deployment-waiter.go @@ -0,0 +1,136 @@ +package resource + +import ( + "log/slog" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codedeploy" + codedeploytypes "github.com/aws/aws-sdk-go-v2/service/codedeploy/types" +) + +type CodeDeployDeploymentWaiter struct { + *AwsResource +} + +type CodeDeployDeploymentWaiterInputs struct { + DeploymentID string `json:"deploymentId"` + Wait bool `json:"wait"` + Trigger string `json:"trigger"` +} + +type CodeDeployDeploymentWaiterOutputs struct { + DeploymentID string `json:"deploymentId"` + Status string `json:"status"` +} + +func (r *CodeDeployDeploymentWaiter) Create(input *CodeDeployDeploymentWaiterInputs, output *CreateResult[CodeDeployDeploymentWaiterOutputs]) error { + outs := r.handle(input) + *output = CreateResult[CodeDeployDeploymentWaiterOutputs]{ + ID: "waiter", + Outs: outs, + } + return nil +} + +// Update is called on every deploy because the TypeScript side passes a +// changing "trigger" input (e.g. Date.now()). We ignore the trigger and only +// compare deploymentId to decide whether there's a real new deployment to +// wait on. +// +// This solves a key problem: after a failed deployment, the waiter's status +// output is "Failed". If we didn't force Update to run, re-deploying with no +// code changes would leave the stale "Failed" status in Pulumi state, causing +// any downstream .apply() check to keep throwing. By always running Update +// and returning "Skipped" when deploymentId hasn't changed, we reset the +// status so the next deploy succeeds cleanly. +func (r *CodeDeployDeploymentWaiter) Update(input *UpdateInput[CodeDeployDeploymentWaiterInputs, CodeDeployDeploymentWaiterOutputs], output *UpdateResult[CodeDeployDeploymentWaiterOutputs]) error { + if input.News.DeploymentID == input.Olds.DeploymentID { + slog.Info("deployment ID unchanged, skipping wait", "deploymentId", input.News.DeploymentID) + *output = UpdateResult[CodeDeployDeploymentWaiterOutputs]{ + Outs: CodeDeployDeploymentWaiterOutputs{ + DeploymentID: input.News.DeploymentID, + Status: "Skipped", + }, + } + return nil + } + outs := r.handle(&input.News) + *output = UpdateResult[CodeDeployDeploymentWaiterOutputs]{ + Outs: outs, + } + return nil +} + +// handle polls the deployment status until it reaches a terminal state. +// +// This function never returns an error. If it did, Pulumi would not persist +// the waiter's outputs. On the next deploy, the waiter's old deployment ID +// would be stale, causing it to re-check a dead deployment and fail again — +// even if no code changed. By always returning successfully, we ensure the +// deployment ID is saved so the Update handler can skip unchanged deployments. +// +// Failure status is surfaced to the user on the TypeScript side by checking +// the status output. +func (r *CodeDeployDeploymentWaiter) handle(input *CodeDeployDeploymentWaiterInputs) CodeDeployDeploymentWaiterOutputs { + out := func(status string) CodeDeployDeploymentWaiterOutputs { + return CodeDeployDeploymentWaiterOutputs{ + DeploymentID: input.DeploymentID, + Status: status, + } + } + + if !input.Wait || input.DeploymentID == "" { + return out("Skipped") + } + + cfg, err := r.config() + if err != nil { + slog.Error("failed to get AWS config", "error", err) + return out("Error") + } + client := codedeploy.NewFromConfig(cfg) + + start := time.Now() + timeout := 60 * time.Minute + + for { + result, err := client.GetDeployment(r.context, &codedeploy.GetDeploymentInput{ + DeploymentId: aws.String(input.DeploymentID), + }) + if err != nil { + slog.Error("failed to get deployment", "deploymentId", input.DeploymentID, "error", err) + return out("Error") + } + + if terminal, status := checkDeploymentStatus(result.DeploymentInfo, input.DeploymentID); terminal { + return out(status) + } + + if time.Since(start) > timeout { + slog.Warn("CodeDeploy deployment timed out", "deploymentId", input.DeploymentID) + return out("TimedOut") + } + + time.Sleep(10 * time.Second) + } +} + +func checkDeploymentStatus(info *codedeploytypes.DeploymentInfo, deploymentID string) (terminal bool, status string) { + switch info.Status { + case codedeploytypes.DeploymentStatusSucceeded: + slog.Info("CodeDeploy deployment succeeded", "deploymentId", deploymentID) + return true, string(info.Status) + case codedeploytypes.DeploymentStatusFailed: + errMsg := "unknown error" + if info.ErrorInformation != nil && info.ErrorInformation.Message != nil { + errMsg = *info.ErrorInformation.Message + } + slog.Warn("CodeDeploy deployment failed", "deploymentId", deploymentID, "error", errMsg) + return true, string(info.Status) + case codedeploytypes.DeploymentStatusStopped: + slog.Warn("CodeDeploy deployment was stopped", "deploymentId", deploymentID) + return true, string(info.Status) + } + return false, "" +} diff --git a/pkg/server/resource/aws-codedeploy-lambda-deployment.go b/pkg/server/resource/aws-codedeploy-lambda-deployment.go new file mode 100644 index 0000000000..b4753d5ef2 --- /dev/null +++ b/pkg/server/resource/aws-codedeploy-lambda-deployment.go @@ -0,0 +1,159 @@ +package resource + +import ( + "fmt" + "log/slog" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codedeploy" + "github.com/aws/aws-sdk-go-v2/service/lambda" +) + +type CodeDeployLambdaDeployment struct { + *AwsResource +} + +type CodeDeployLambdaDeploymentInputs struct { + ApplicationName string `json:"applicationName"` + DeploymentGroupName string `json:"deploymentGroupName"` + FunctionName string `json:"functionName"` + AliasName string `json:"aliasName"` + TargetVersion string `json:"targetVersion"` + OnConflict string `json:"onConflict"` + BeforeTrafficFnArn string `json:"beforeTrafficFnArn,omitempty"` + AfterTrafficFnArn string `json:"afterTrafficFnArn,omitempty"` +} + +type CodeDeployLambdaDeploymentOutputs struct { + DeploymentID string `json:"deploymentId"` + TargetVersion string `json:"targetVersion"` +} + +func (r *CodeDeployLambdaDeployment) Create(input *CodeDeployLambdaDeploymentInputs, output *CreateResult[CodeDeployLambdaDeploymentOutputs]) error { + outs, err := r.handle(input) + if err != nil { + return err + } + *output = CreateResult[CodeDeployLambdaDeploymentOutputs]{ + ID: "deployment", + Outs: outs, + } + return nil +} + +func (r *CodeDeployLambdaDeployment) Update(input *UpdateInput[CodeDeployLambdaDeploymentInputs, CodeDeployLambdaDeploymentOutputs], output *UpdateResult[CodeDeployLambdaDeploymentOutputs]) error { + if input.News.TargetVersion != "" && input.Olds.TargetVersion == input.News.TargetVersion { + slog.Info("target version unchanged, skipping deployment", "version", input.News.TargetVersion) + *output = UpdateResult[CodeDeployLambdaDeploymentOutputs]{ + Outs: input.Olds, + } + return nil + } + outs, err := r.handle(&input.News) + if err != nil { + return err + } + *output = UpdateResult[CodeDeployLambdaDeploymentOutputs]{ + Outs: outs, + } + return nil +} + +func (r *CodeDeployLambdaDeployment) handle(input *CodeDeployLambdaDeploymentInputs) (CodeDeployLambdaDeploymentOutputs, error) { + cfg, err := r.config() + if err != nil { + return CodeDeployLambdaDeploymentOutputs{}, err + } + cdClient := codedeploy.NewFromConfig(cfg) + + if err := handleDeploymentConflict(r.AwsResource, handleDeploymentConflictInput{ + Client: cdClient, + ApplicationName: input.ApplicationName, + DeploymentGroupName: input.DeploymentGroupName, + OnConflict: input.OnConflict, + }); err != nil { + return CodeDeployLambdaDeploymentOutputs{}, err + } + + appSpecJSON, err := r.buildAppSpec(input, cfg) + if err != nil { + return CodeDeployLambdaDeploymentOutputs{}, err + } + + if appSpecJSON == "" { + slog.Info("skipping CodeDeploy deployment (no version change)") + return CodeDeployLambdaDeploymentOutputs{ + DeploymentID: "", + TargetVersion: input.TargetVersion, + }, nil + } + + deploymentID, err := createDeployment(r.AwsResource, createDeploymentInput{ + Client: cdClient, + ApplicationName: input.ApplicationName, + DeploymentGroupName: input.DeploymentGroupName, + AppSpecJSON: appSpecJSON, + }) + if err != nil { + return CodeDeployLambdaDeploymentOutputs{}, err + } + + return CodeDeployLambdaDeploymentOutputs{ + DeploymentID: deploymentID, + TargetVersion: input.TargetVersion, + }, nil +} + +func (r *CodeDeployLambdaDeployment) buildAppSpec(input *CodeDeployLambdaDeploymentInputs, cfg aws.Config) (string, error) { + lambdaClient := lambda.NewFromConfig(cfg) + + aliasResult, err := lambdaClient.GetAlias(r.context, &lambda.GetAliasInput{ + FunctionName: aws.String(input.FunctionName), + Name: aws.String(input.AliasName), + }) + if err != nil { + return "", fmt.Errorf("failed to get alias %s for function %s: %w", input.AliasName, input.FunctionName, err) + } + currentVersion := *aliasResult.FunctionVersion + + if currentVersion == input.TargetVersion { + return "", nil + } + + slog.Info("CodeDeploy Lambda version shift", + "function", input.FunctionName, + "from", currentVersion, + "to", input.TargetVersion, + ) + + type properties struct { + Name string `json:"Name"` + Alias string `json:"Alias"` + CurrentVersion string `json:"CurrentVersion"` + TargetVersion string `json:"TargetVersion"` + } + type resource struct { + Type string `json:"Type"` + Properties properties `json:"Properties"` + } + + resources := []map[string]resource{ + { + input.FunctionName: { + Type: "AWS::Lambda::Function", + Properties: properties{ + Name: input.FunctionName, + Alias: input.AliasName, + CurrentVersion: currentVersion, + TargetVersion: input.TargetVersion, + }, + }, + }, + } + + return buildAppSpec(buildAppSpecInput{ + Resources: resources, + BeforeTrafficFn: input.BeforeTrafficFnArn, + AfterTrafficFn: input.AfterTrafficFnArn, + }) +} diff --git a/pkg/server/resource/aws-codedeploy.go b/pkg/server/resource/aws-codedeploy.go new file mode 100644 index 0000000000..a787d04bbf --- /dev/null +++ b/pkg/server/resource/aws-codedeploy.go @@ -0,0 +1,189 @@ +package resource + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/codedeploy" + codedeploytypes "github.com/aws/aws-sdk-go-v2/service/codedeploy/types" +) + +type handleDeploymentConflictInput struct { + Client *codedeploy.Client + ApplicationName string + DeploymentGroupName string + OnConflict string +} + +func handleDeploymentConflict(r *AwsResource, input handleDeploymentConflictInput) error { + existingID, err := findActiveDeployment(r, input.Client, input.ApplicationName, input.DeploymentGroupName) + if err != nil { + return err + } + if existingID == "" { + return nil + } + switch input.OnConflict { + case "fail": + return fmt.Errorf("deployment %s already in progress for deployment group %s", existingID, input.DeploymentGroupName) + case "cancel": + return stopDeployment(r, stopDeploymentInput{ + Client: input.Client, + DeploymentID: existingID, + Rollback: false, + }) + case "rollback": + return stopDeployment(r, stopDeploymentInput{ + Client: input.Client, + DeploymentID: existingID, + Rollback: true, + }) + } + return nil +} + +type stopDeploymentInput struct { + Client *codedeploy.Client + DeploymentID string + Rollback bool +} + +func stopDeployment(r *AwsResource, input stopDeploymentInput) error { + if input.Rollback { + slog.Info("stopping existing deployment (rollback)", "deploymentId", input.DeploymentID) + } else { + slog.Info("stopping existing deployment (keep current state)", "deploymentId", input.DeploymentID) + } + _, err := input.Client.StopDeployment(r.context, &codedeploy.StopDeploymentInput{ + DeploymentId: aws.String(input.DeploymentID), + AutoRollbackEnabled: aws.Bool(input.Rollback), + }) + if err != nil { + return fmt.Errorf("failed to stop deployment %s: %w", input.DeploymentID, err) + } + return waitForDeploymentStopped(r, input.Client, input.DeploymentID) +} + +func findActiveDeployment(r *AwsResource, client *codedeploy.Client, applicationName, deploymentGroupName string) (string, error) { + var nextToken *string + for { + listResult, err := client.ListDeployments(r.context, &codedeploy.ListDeploymentsInput{ + ApplicationName: aws.String(applicationName), + DeploymentGroupName: aws.String(deploymentGroupName), + IncludeOnlyStatuses: []codedeploytypes.DeploymentStatus{ + codedeploytypes.DeploymentStatusCreated, + codedeploytypes.DeploymentStatusQueued, + codedeploytypes.DeploymentStatusInProgress, + }, + NextToken: nextToken, + }) + if err != nil { + return "", fmt.Errorf("failed to list deployments: %w", err) + } + if len(listResult.Deployments) > 0 { + return listResult.Deployments[0], nil + } + if listResult.NextToken == nil { + return "", nil + } + nextToken = listResult.NextToken + } +} + +type createDeploymentInput struct { + Client *codedeploy.Client + ApplicationName string + DeploymentGroupName string + AppSpecJSON string +} + +func createDeployment(r *AwsResource, input createDeploymentInput) (string, error) { + slog.Info("creating CodeDeploy deployment", "application", input.ApplicationName, "group", input.DeploymentGroupName) + result, err := input.Client.CreateDeployment(r.context, &codedeploy.CreateDeploymentInput{ + ApplicationName: aws.String(input.ApplicationName), + DeploymentGroupName: aws.String(input.DeploymentGroupName), + Revision: &codedeploytypes.RevisionLocation{ + RevisionType: codedeploytypes.RevisionLocationTypeAppSpecContent, + AppSpecContent: &codedeploytypes.AppSpecContent{ + Content: aws.String(input.AppSpecJSON), + }, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to create deployment: %w", err) + } + + deploymentID := *result.DeploymentId + slog.Info("CodeDeploy deployment created", "deploymentId", deploymentID) + return deploymentID, nil +} + +type buildAppSpecInput struct { + Resources any + BeforeTrafficFn string + AfterTrafficFn string +} + +func buildAppSpec(input buildAppSpecInput) (string, error) { + type hookEntry struct { + BeforeAllowTraffic string `json:"BeforeAllowTraffic,omitempty"` + AfterAllowTraffic string `json:"AfterAllowTraffic,omitempty"` + } + type appSpec struct { + Version string `json:"version"` + Resources any `json:"Resources"` + Hooks []hookEntry `json:"Hooks,omitempty"` + } + + spec := appSpec{ + Version: "0.0", + Resources: input.Resources, + } + + if input.BeforeTrafficFn != "" || input.AfterTrafficFn != "" { + hook := hookEntry{} + if input.BeforeTrafficFn != "" { + hook.BeforeAllowTraffic = input.BeforeTrafficFn + } + if input.AfterTrafficFn != "" { + hook.AfterAllowTraffic = input.AfterTrafficFn + } + spec.Hooks = []hookEntry{hook} + } + + data, err := json.Marshal(spec) + if err != nil { + return "", fmt.Errorf("failed to marshal AppSpec: %w", err) + } + return string(data), nil +} + +func waitForDeploymentStopped(r *AwsResource, client *codedeploy.Client, deploymentID string) error { + start := time.Now() + timeout := 5 * time.Minute + + for { + result, err := client.GetDeployment(r.context, &codedeploy.GetDeploymentInput{ + DeploymentId: aws.String(deploymentID), + }) + if err != nil { + return fmt.Errorf("failed to get deployment %s: %w", deploymentID, err) + } + + status := result.DeploymentInfo.Status + if status == codedeploytypes.DeploymentStatusStopped || + status == codedeploytypes.DeploymentStatusFailed || + status == codedeploytypes.DeploymentStatusSucceeded { + return nil + } + + if time.Since(start) > timeout { + return fmt.Errorf("timed out waiting for deployment %s to stop after 5 minutes", deploymentID) + } + + time.Sleep(5 * time.Second) + } +} diff --git a/pkg/server/resource/resource.go b/pkg/server/resource/resource.go index a0dd03fbd3..9467ae7d90 100644 --- a/pkg/server/resource/resource.go +++ b/pkg/server/resource/resource.go @@ -82,6 +82,8 @@ func Register(ctx context.Context, p *project.Project, r *rpc.Server) error { r.RegisterName("Resource.Aws.OriginAccessControl", &OriginAccessControl{awsResource}) r.RegisterName("Resource.Aws.RdsRoleLookup", &RdsRoleLookup{awsResource}) r.RegisterName("Resource.Aws.VectorTable", &VectorTable{awsResource}) + r.RegisterName("Resource.Aws.CodeDeployLambdaDeployment", &CodeDeployLambdaDeployment{awsResource}) + r.RegisterName("Resource.Aws.CodeDeployDeploymentWaiter", &CodeDeployDeploymentWaiter{awsResource}) // Cloudflare Resources r.RegisterName("Resource.Cloudflare.DnsRecord", &CloudflareDnsRecord{cloudflareResource}) diff --git a/platform/src/components/aws/apigateway-websocket-route.ts b/platform/src/components/aws/apigateway-websocket-route.ts index 99be0e4edd..c44c0776a6 100644 --- a/platform/src/components/aws/apigateway-websocket-route.ts +++ b/platform/src/components/aws/apigateway-websocket-route.ts @@ -7,7 +7,7 @@ import { output, } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { ApiGatewayWebSocketRouteArgs } from "./apigateway-websocket"; import { apigatewayv2, lambda } from "@pulumi/aws"; import { FunctionBuilder, functionBuilder } from "./helpers/function-builder"; @@ -37,7 +37,7 @@ export interface Args extends ApiGatewayWebSocketRouteArgs { /** * The function that’ll be invoked. */ - handler: Input; + handler: Input; /** * @internal */ diff --git a/platform/src/components/aws/apigateway-websocket.ts b/platform/src/components/aws/apigateway-websocket.ts index 6806e7f6b1..d8a1aac49f 100644 --- a/platform/src/components/aws/apigateway-websocket.ts +++ b/platform/src/components/aws/apigateway-websocket.ts @@ -14,7 +14,7 @@ import { } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { hashStringToPrettyString, physicalName, logicalName } from "../naming"; import { DnsValidatedCertificate } from "./dns-validated-certificate"; import { RETENTION } from "./logging"; @@ -228,7 +228,7 @@ export interface ApiGatewayWebSocketAuthorizerArgs { * } * ``` */ - function: Input; + function: Input; /** * The JWT payload version. * @default `"2.0"` @@ -751,7 +751,7 @@ export class ApiGatewayWebSocket extends Component implements Link.Linkable { */ public route( route: string, - handler: Input, + handler: Input, args: ApiGatewayWebSocketRouteArgs = {}, ) { const prefix = this.constructorName; diff --git a/platform/src/components/aws/apigatewayv1-lambda-route.ts b/platform/src/components/aws/apigatewayv1-lambda-route.ts index e713d89904..2ae11492e4 100644 --- a/platform/src/components/aws/apigatewayv1-lambda-route.ts +++ b/platform/src/components/aws/apigatewayv1-lambda-route.ts @@ -7,7 +7,7 @@ import { output, } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; -import { FunctionArgs } from "./function.js"; +import { Function, FunctionArgs } from "./function.js"; import { apigateway, lambda } from "@pulumi/aws"; import { ApiGatewayV1BaseRouteArgs, @@ -19,7 +19,7 @@ export interface Args extends ApiGatewayV1BaseRouteArgs { /** * The route function. */ - handler: Input; + handler: Input; /** * @internal */ diff --git a/platform/src/components/aws/apigatewayv1.ts b/platform/src/components/aws/apigatewayv1.ts index ea2d001335..04114940f1 100644 --- a/platform/src/components/aws/apigatewayv1.ts +++ b/platform/src/components/aws/apigatewayv1.ts @@ -14,7 +14,7 @@ import { } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { hashStringToPrettyString, physicalName, logicalName } from "../naming"; import { VisibleError } from "../error"; import { RETENTION } from "./logging"; @@ -382,7 +382,7 @@ export interface ApiGatewayV1AuthorizerArgs { * } * ``` */ - tokenFunction?: Input; + tokenFunction?: Input; /** * The Lambda request authorizer function. Takes the handler path or the function args. * @example @@ -392,7 +392,7 @@ export interface ApiGatewayV1AuthorizerArgs { * } * ``` */ - requestFunction?: Input; + requestFunction?: Input; /** * A list of user pools used as the authorizer. * @example @@ -966,7 +966,7 @@ export class ApiGatewayV1 extends Component implements Link.Linkable { */ public route( route: string, - handler: Input, + handler: Input, args: ApiGatewayV1RouteArgs = {}, ) { const { method, path } = this.parseRoute(route); diff --git a/platform/src/components/aws/apigatewayv2-lambda-route.ts b/platform/src/components/aws/apigatewayv2-lambda-route.ts index f511a27264..7034c69e54 100644 --- a/platform/src/components/aws/apigatewayv2-lambda-route.ts +++ b/platform/src/components/aws/apigatewayv2-lambda-route.ts @@ -6,7 +6,7 @@ import { output, } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { apigatewayv2, lambda } from "@pulumi/aws"; import { ApiGatewayV2BaseRouteArgs, @@ -20,7 +20,7 @@ export interface Args extends ApiGatewayV2BaseRouteArgs { * * Takes the handler path, the function args, or a function ARN. */ - handler: Input; + handler: Input; /** * The resources to link to the route function. */ diff --git a/platform/src/components/aws/apigatewayv2.ts b/platform/src/components/aws/apigatewayv2.ts index 4951a93268..87e4dce6cd 100644 --- a/platform/src/components/aws/apigatewayv2.ts +++ b/platform/src/components/aws/apigatewayv2.ts @@ -8,7 +8,7 @@ import { } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { hashStringToPrettyString, physicalName, logicalName } from "../naming"; import { VisibleError } from "../error"; import { DnsValidatedCertificate } from "./dns-validated-certificate"; @@ -452,7 +452,7 @@ export interface ApiGatewayV2AuthorizerArgs { * } * ``` */ - function: Input; + function: Input; /** * The JWT payload version. * @default `"2.0"` @@ -1111,7 +1111,7 @@ export class ApiGatewayV2 extends Component implements Link.Linkable { */ public route( rawRoute: string, - handler: Input, + handler: Input, args: ApiGatewayV2RouteArgs = {}, ) { const route = this.parseRoute(rawRoute); diff --git a/platform/src/components/aws/app-sync.ts b/platform/src/components/aws/app-sync.ts index 7c0b76973d..7c6cac11c7 100644 --- a/platform/src/components/aws/app-sync.ts +++ b/platform/src/components/aws/app-sync.ts @@ -3,7 +3,7 @@ import { ComponentResourceOptions, interpolate, output } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { logicalName } from "../naming"; import { VisibleError } from "../error"; import { AppSyncDataSource } from "./app-sync-data-source"; @@ -220,7 +220,7 @@ export interface AppSyncDataSourceArgs { * } * ``` */ - lambda?: Input; + lambda?: Input; /** * The ARN for the DynamoDB table. * @example diff --git a/platform/src/components/aws/auth.ts b/platform/src/components/aws/auth.ts index b4145f6431..639e2e353a 100644 --- a/platform/src/components/aws/auth.ts +++ b/platform/src/components/aws/auth.ts @@ -33,7 +33,7 @@ export interface AuthArgs { * } * ``` */ - authorizer?: Input; + authorizer?: Input; /** * The function that's running your OpenAuth server. * @@ -78,7 +78,7 @@ export interface AuthArgs { * Learn more on the [OpenAuth docs](https://openauth.js.org/docs/issuer/) on how to configure * the `issuer` function. */ - issuer?: Input; + issuer?: Input; /** * Set a custom domain for your Auth server. * diff --git a/platform/src/components/aws/bucket-lambda-subscriber.ts b/platform/src/components/aws/bucket-lambda-subscriber.ts index c27e29787f..025378b3cc 100644 --- a/platform/src/components/aws/bucket-lambda-subscriber.ts +++ b/platform/src/components/aws/bucket-lambda-subscriber.ts @@ -32,7 +32,7 @@ export interface Args extends BucketSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; } /** diff --git a/platform/src/components/aws/bucket.ts b/platform/src/components/aws/bucket.ts index 85d7d5bec4..1c928a4e27 100644 --- a/platform/src/components/aws/bucket.ts +++ b/platform/src/components/aws/bucket.ts @@ -9,7 +9,7 @@ import { hashStringToPrettyString, logicalName } from "../naming"; import { Component, Prettify, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { Duration, DurationDays, toSeconds } from "../duration"; import { VisibleError } from "../error"; import { parseBucketArn } from "./helpers/arn"; @@ -575,7 +575,7 @@ export interface BucketNotificationsArgs { * } * ``` */ - function?: Input; + function?: Input; /** * The Queue that'll be notified. * @@ -1370,7 +1370,7 @@ export class Bucket extends Component implements Link.Linkable { * ``` */ public subscribe( - subscriber: Input, + subscriber: Input, args?: BucketSubscriberArgs, ) { this.ensureNotSubscribed(); @@ -1438,7 +1438,7 @@ export class Bucket extends Component implements Link.Linkable { */ public static subscribe( bucketArn: Input, - subscriber: Input, + subscriber: Input, args?: BucketSubscriberArgs, ) { return output(bucketArn).apply((bucketArn) => { @@ -1457,27 +1457,33 @@ export class Bucket extends Component implements Link.Linkable { name: string, bucketName: Input, bucketArn: Input, - subscriber: Input, + subscriber: Input, args: BucketSubscriberArgs = {}, opts: ComponentResourceOptions = {}, ) { return all([bucketArn, subscriber, args]).apply( ([bucketArn, subscriber, args]) => { - const subscriberId = this.buildSubscriberId( - bucketArn, - typeof subscriber === "string" ? subscriber : subscriber.handler, - ); + const subscriberIdentity = + typeof subscriber === "string" + ? subscriber + : subscriber instanceof Function + ? subscriber.arn + : subscriber.handler; - return new BucketLambdaSubscriber( - `${name}Subscriber${subscriberId}`, - { - bucket: { name: bucketName, arn: bucketArn }, - subscriber, - subscriberId, - ...args, - }, - opts, - ); + return output(subscriberIdentity).apply((identity) => { + const subscriberId = this.buildSubscriberId(bucketArn, identity); + + return new BucketLambdaSubscriber( + `${name}Subscriber${subscriberId}`, + { + bucket: { name: bucketName, arn: bucketArn }, + subscriber, + subscriberId, + ...args, + }, + opts, + ); + }); }, ); } diff --git a/platform/src/components/aws/cognito-user-pool.ts b/platform/src/components/aws/cognito-user-pool.ts index e7e5d8caac..b1f232fcfd 100644 --- a/platform/src/components/aws/cognito-user-pool.ts +++ b/platform/src/components/aws/cognito-user-pool.ts @@ -34,7 +34,7 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - createAuthChallenge?: Input; + createAuthChallenge?: Input; /** * Triggered during events like user sign-up, password recovery, email/phone number * verification, and when an admin creates a user. Use this trigger to customize the @@ -42,7 +42,7 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - customEmailSender?: Input; + customEmailSender?: Input; /** * Triggered during events like user sign-up, password recovery, email/phone number * verification, and when an admin creates a user. Use this trigger to customize the @@ -50,14 +50,14 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - customMessage?: Input; + customMessage?: Input; /** * Triggered when an SMS message needs to be sent, such as for MFA or verification codes. * Use this trigger to customize the SMS provider. * * Takes the handler path, the function args, or a function ARN. */ - customSmsSender?: Input; + customSmsSender?: Input; /** * Triggered after each challenge response to determine the next action. Evaluates whether the * user has completed the authentication process or if additional challenges are needed. @@ -65,14 +65,14 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - defineAuthChallenge?: Input; + defineAuthChallenge?: Input; /** * Triggered after a successful authentication event. Use this to perform custom actions, * such as logging or modifying user attributes, after the user is authenticated. * * Takes the handler path, the function args, or a function ARN. */ - postAuthentication?: Input; + postAuthentication?: Input; /** * Triggered after a user is successfully confirmed; sign-up or email/phone number * verification. Use this to perform additional actions, like sending a welcome email or @@ -80,7 +80,7 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - postConfirmation?: Input; + postConfirmation?: Input; /** * Triggered before the authentication process begins. Use this to implement custom * validation or checks (like checking if the user is banned) before continuing @@ -88,21 +88,21 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - preAuthentication?: Input; + preAuthentication?: Input; /** * Triggered before the user sign-up process completes. Use this to perform custom * validation, auto-confirm users, or auto-verify attributes based on custom logic. * * Takes the handler path, the function args, or a function ARN. */ - preSignUp?: Input; + preSignUp?: Input; /** * Triggered before tokens are generated in the authentication process. Use this to * customize or add claims to the tokens that will be generated and returned to the user. * * Takes the handler path, the function args, or a function ARN. */ - preTokenGeneration?: Input; + preTokenGeneration?: Input; /** * The version of the preTokenGeneration trigger to use. Higher versions have access to * more information that support new features. @@ -116,7 +116,7 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - userMigration?: Input; + userMigration?: Input; /** * Triggered after the user responds to a custom authentication challenge. Use this to * verify the user's response to the challenge and determine whether to continue @@ -124,7 +124,7 @@ interface Triggers { * * Takes the handler path, the function args, or a function ARN. */ - verifyAuthChallengeResponse?: Input; + verifyAuthChallengeResponse?: Input; } export interface CognitoUserPoolArgs { diff --git a/platform/src/components/aws/dynamo-lambda-subscriber.ts b/platform/src/components/aws/dynamo-lambda-subscriber.ts index cdba40da44..eb4bcb8f22 100644 --- a/platform/src/components/aws/dynamo-lambda-subscriber.ts +++ b/platform/src/components/aws/dynamo-lambda-subscriber.ts @@ -1,6 +1,6 @@ import { ComponentResourceOptions, Input, output } from "@pulumi/pulumi"; import { Component, transform } from "../component"; -import { FunctionArgs } from "./function.js"; +import { Function, FunctionArgs } from "./function.js"; import { DynamoSubscriberArgs } from "./dynamo"; import { lambda } from "@pulumi/aws"; import { FunctionBuilder, functionBuilder } from "./helpers/function-builder"; @@ -18,7 +18,7 @@ export interface Args extends DynamoSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; /** * In early versions of SST, parent were forgotten to be set for resources in components. * This flag is used to disable the automatic setting of the parent to prevent breaking diff --git a/platform/src/components/aws/dynamo.ts b/platform/src/components/aws/dynamo.ts index 2ef515d8a3..21b7b07ab5 100644 --- a/platform/src/components/aws/dynamo.ts +++ b/platform/src/components/aws/dynamo.ts @@ -8,7 +8,7 @@ import { import { Component, outputId, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { hashStringToPrettyString, logicalName } from "../naming"; import { parseDynamoStreamArn } from "./helpers/arn"; import { DynamoLambdaSubscriber } from "./dynamo-lambda-subscriber"; @@ -670,7 +670,7 @@ export class Dynamo extends Component implements Link.Linkable { */ public subscribe( name: string, - subscriber: Input, + subscriber: Input, args?: DynamoSubscriberArgs, ): Output; /** @@ -679,7 +679,7 @@ export class Dynamo extends Component implements Link.Linkable { * back with the new `name` argument. */ public subscribe( - subscriber: Input, + subscriber: Input, args?: DynamoSubscriberArgs, ): Output; @@ -767,7 +767,7 @@ export class Dynamo extends Component implements Link.Linkable { public static subscribe( name: string, streamArn: Input, - subscriber: Input, + subscriber: Input, args?: DynamoSubscriberArgs, ): Output; /** @@ -777,7 +777,7 @@ export class Dynamo extends Component implements Link.Linkable { */ public static subscribe( streamArn: Input, - subscriber: Input, + subscriber: Input, args?: DynamoSubscriberArgs, ): Output; @@ -813,7 +813,7 @@ export class Dynamo extends Component implements Link.Linkable { subscriberName: string, name: string, streamArn: string | Output, - subscriber: Input, + subscriber: Input, args: DynamoSubscriberArgs = {}, opts: ComponentResourceOptions = {}, ) { @@ -834,32 +834,41 @@ export class Dynamo extends Component implements Link.Linkable { private static _subscribeV1( name: string, streamArn: string | Output, - subscriber: Input, + subscriber: Input, args: DynamoSubscriberArgs = {}, opts: ComponentResourceOptions = {}, ) { return all([name, subscriber, args]).apply(([name, subscriber, args]) => { - const suffix = logicalName( - hashStringToPrettyString( - [ - typeof streamArn === "string" ? streamArn : outputId, - JSON.stringify(args.filters ?? {}), - typeof subscriber === "string" ? subscriber : subscriber.handler, - ].join(""), - 6, - ), - ); + const subscriberIdentity = + typeof subscriber === "string" + ? subscriber + : subscriber instanceof Function + ? subscriber.arn + : subscriber.handler; - return new DynamoLambdaSubscriber( - `${name}Subscriber${suffix}`, - { - dynamo: { streamArn }, - subscriber, - disableParent: true, - ...args, - }, - opts, - ); + return output(subscriberIdentity).apply((identity) => { + const suffix = logicalName( + hashStringToPrettyString( + [ + typeof streamArn === "string" ? streamArn : outputId, + JSON.stringify(args.filters ?? {}), + identity, + ].join(""), + 6, + ), + ); + + return new DynamoLambdaSubscriber( + `${name}Subscriber${suffix}`, + { + dynamo: { streamArn }, + subscriber, + disableParent: true, + ...args, + }, + opts, + ); + }); }); } diff --git a/platform/src/components/aws/function-rollout.ts b/platform/src/components/aws/function-rollout.ts new file mode 100644 index 0000000000..9de89a51dd --- /dev/null +++ b/platform/src/components/aws/function-rollout.ts @@ -0,0 +1,522 @@ +import { codedeploy, iam, lambda } from "@pulumi/aws"; +import { + all, + ComponentResourceOptions, + interpolate, + Output, + output, +} from "@pulumi/pulumi"; +import { Component, Transform, transform } from "../component.js"; +import { DurationMinutes, toSeconds } from "../duration.js"; +import { VisibleError } from "../error.js"; +import type { Input } from "../input.js"; +import { Function, FunctionArgs } from "./function.js"; +import { functionBuilder } from "./helpers/function-builder.js"; +import { CodeDeployDeploymentWaiter } from "./providers/codedeploy-deployment-waiter.js"; +import { CodeDeployLambdaDeployment } from "./providers/codedeploy-lambda-deployment.js"; + +const CODEDEPLOY_EVENT_MAP = { + start: "DeploymentStart", + success: "DeploymentSuccess", + failure: "DeploymentFailure", + stop: "DeploymentStop", + rollback: "DeploymentRollback", + ready: "DeploymentReady", +} as const; + +type CodeDeployEvent = keyof typeof CODEDEPLOY_EVENT_MAP; + +export interface FunctionRolloutArgs { + function: Function; + alias: Input; + /** + * The rollout type when the function is updated. + * + * - `"canary"` — shifts a percentage of traffic to the new version, waits for the duration, then shifts 100%. + * - `"linear"` — shifts a percentage of traffic to the new version every duration until 100%. + * - `"all-at-once"` — shifts 100% of traffic to the new version immediately. Use `beforeTraffic` to run smoke tests that must pass before shifting. + */ + type: Input<"canary" | "linear" | "all-at-once">; + /** + * The percentage of traffic to shift per step. Only used for `canary` and `linear`. + * @default `10` + */ + percentage?: Input; + /** + * The time between each traffic shifting step. Only used for `canary` and `linear`. + * @default `"10 minutes"` + */ + duration?: Input; + /** + * A list of CloudWatch alarm names. If any alarm enters `ALARM` state during the + * deployment, traffic will roll back completely to the previous version. + */ + alarms?: Input[]>; + /** + * A function to invoke before any traffic shifts to the new version. Can be + * a handler path, `Function` instance, `FunctionArgs`, or ARN. If it reports + * failure, the deployment is aborted and all traffic stays on the previous + * version. + * + * @example + * + * ```ts title="sst.config.ts" + * new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * beforeTraffic: "src/before-traffic.handler", + * }, + * }); + * ``` + * + * For a complete example, see the [Lambda smoke test example](/docs/examples/#aws-lambda-smoke-test-function-url). + * + * If passing a `Function` reference, make sure to grant it the + * `codedeploy:PutLifecycleEventHookExecutionStatus` permission. + */ + beforeTraffic?: Input; + /** + * A function to invoke after all traffic has shifted to the new version. Can + * be a handler path, `Function` instance, `FunctionArgs`, or ARN. If it + * reports failure, the deployment is rolled back and traffic returns to the + * previous version. + * + * @example + * + * ```ts title="sst.config.ts" + * new sst.aws.Function("Function", { + * handler: "src/api.handler", + * rollout: { + * type: "all-at-once", + * afterTraffic: "src/after-traffic.handler", + * }, + * }); + * ``` + * + * For a complete example, see the [Lambda smoke test example](/docs/examples/#aws-lambda-smoke-test-function-url). + * + * If passing a `Function` reference, make sure to grant it the + * `codedeploy:PutLifecycleEventHookExecutionStatus` permission. + */ + afterTraffic?: Input; + /** + * Whether SST should wait for the CodeDeploy deployment to complete. + * @default `true` for `all-at-once`, `false` for `canary` and `linear` + */ + wait?: Input; + /** + * What to do when a new version is deployed while a previous rollout is still + * in progress. + * + * - `"cancel"` — stop the existing rollout where it is, leaving traffic split + * between the old and new version, then start the new rollout. + * - `"rollback"` — roll back the existing rollout so all traffic returns to the + * previous version, then start the new rollout. + * - `"fail"` — error out and don't deploy. + * + * @default `"cancel"` + */ + onConflict?: Input<"rollback" | "cancel" | "fail">; + /** + * Configure SNS notifications for deployment events. + * + * @example + * + * ```js + * { + * rollout: { + * type: "canary", + * notifications: [ + * { + * name: "OnFailure", + * events: ["failure", "rollback"], + * topic: myTopic.arn, + * }, + * ], + * } + * } + * ``` + */ + notifications?: Input< + Input<{ + /** + * A name for this notification trigger. + */ + name: Input; + /** + * The deployment events to notify on. + * + * - `"start"` — deployment started. + * - `"success"` — deployment completed successfully. + * - `"failure"` — deployment failed. + * - `"stop"` — deployment was stopped. + * - `"rollback"` — deployment was rolled back. + * - `"ready"` — traffic shifting is ready to proceed. + */ + events: Input[]>; + /** + * The ARN of the SNS topic to notify. + */ + topic: Input; + }>[] + >; + /** + * [Transform](/docs/components#transform) how the rollout creates its underlying resources. + */ + transform?: { + /** + * Transform the CodeDeploy Application resource. + */ + application?: Transform; + /** + * Transform the CodeDeploy Deployment Group resource. + */ + deploymentGroup?: Transform; + /** + * Transform the IAM Role resource used by CodeDeploy. + */ + role?: Transform; + /** + * Transform the before-traffic Function. + */ + beforeTrafficFunction?: Transform; + /** + * Transform the after-traffic Function. + */ + afterTrafficFunction?: Transform; + }; +} + +export class FunctionRollout extends Component { + private codedeployApp: codedeploy.Application; + private codedeployRole: iam.Role; + private deploymentGroup: Output; + private codedeployDeployment: CodeDeployLambdaDeployment; + private deploymentWaiter: CodeDeployDeploymentWaiter; + + constructor( + name: string, + args: FunctionRolloutArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + const parent = this; + + const strategy = output(args.type); + const percentage = output(args.percentage ?? 10); + const interval = output(args.duration ?? "10 minutes").apply((v) => + Math.round(toSeconds(v) / 60), + ); + const rolloutWait = output( + args.wait ?? strategy.apply((s) => s === "all-at-once"), + ); + const onConflict = output(args.onConflict ?? "cancel"); + const notifications = output(args.notifications ?? []); + const alarms = output(args.alarms ?? []); + + const beforeTrafficFnArn = buildTrafficFn( + `${name}BeforeTraffic`, + args.beforeTraffic, + args.transform?.beforeTrafficFunction, + ); + const afterTrafficFnArn = buildTrafficFn( + `${name}AfterTraffic`, + args.afterTraffic, + args.transform?.afterTrafficFunction, + ); + + const invokeResources = [ + beforeTrafficFnArn?.arn, + afterTrafficFnArn?.arn, + ].filter((i) => i != null); + + const codedeployApp = createCodedeployApp(); + const deploymentConfigName = createDeploymentConfigName(); + const codedeployRole = createCodedeployRole(); + const deploymentGroup = createDeploymentGroup(); + const codedeployDeployment = createCodedeployDeployment(); + const deploymentWaiter = createDeploymentWaiter(); + + this.codedeployApp = codedeployApp; + this.codedeployRole = codedeployRole; + this.deploymentGroup = deploymentGroup; + this.codedeployDeployment = codedeployDeployment; + this.deploymentWaiter = deploymentWaiter; + + all([deploymentWaiter.status, deploymentWaiter.deploymentId]).apply( + ([status, deploymentId]) => { + if (status === "Failed" || status === "Stopped") { + throw new VisibleError( + `Rollout for "${name}" failed. Update your function code and deploy again.\n\nFor more details, check the CodeDeploy deployment in the AWS console: ${deploymentId}`, + ); + } + }, + ); + + function createCodedeployApp() { + return new codedeploy.Application( + ...transform( + args.transform?.application, + `${name}CodeDeployApp`, + { + computePlatform: "Lambda", + }, + { parent }, + ), + ); + } + + function createDeploymentConfigName() { + const builtInConfigName = all([strategy, percentage, interval]).apply( + ([strategy, percentage, interval]): string | undefined => { + if (strategy === "all-at-once") + return "CodeDeployDefault.LambdaAllAtOnce"; + return resolveBuiltInLambdaConfig(strategy, percentage, interval); + }, + ); + + const customDeployConfig = all([strategy, percentage, interval]).apply( + ([strategy, percentage, interval]) => { + if (strategy === "all-at-once") return; + if (resolveBuiltInLambdaConfig(strategy, percentage, interval)) + return; + return new codedeploy.DeploymentConfig( + `${name}DeployConfig`, + { + computePlatform: "Lambda", + trafficRoutingConfig: { + type: + strategy === "canary" ? "TimeBasedCanary" : "TimeBasedLinear", + timeBasedCanary: + strategy === "canary" ? { interval, percentage } : undefined, + timeBasedLinear: + strategy === "linear" ? { interval, percentage } : undefined, + }, + }, + { parent }, + ); + }, + ); + + const customConfigName = customDeployConfig.apply( + (config) => config?.deploymentConfigName ?? "", + ); + const deploymentConfigName = all([ + builtInConfigName, + customConfigName, + ]).apply(([builtIn, custom]) => builtIn ?? custom); + + return deploymentConfigName; + } + + function resolveBuiltInLambdaConfig( + strategy: string, + percentage: number, + interval: number, + ): string | undefined { + if (strategy === "canary" && percentage === 10) { + const map: Record = { + 5: "CodeDeployDefault.LambdaCanary10Percent5Minutes", + 10: "CodeDeployDefault.LambdaCanary10Percent10Minutes", + 15: "CodeDeployDefault.LambdaCanary10Percent15Minutes", + 30: "CodeDeployDefault.LambdaCanary10Percent30Minutes", + }; + return map[interval]; + } + if (strategy === "linear" && percentage === 10) { + const map: Record = { + 1: "CodeDeployDefault.LambdaLinear10PercentEvery1Minute", + 2: "CodeDeployDefault.LambdaLinear10PercentEvery2Minutes", + 3: "CodeDeployDefault.LambdaLinear10PercentEvery3Minutes", + 10: "CodeDeployDefault.LambdaLinear10PercentEvery10Minutes", + }; + return map[interval]; + } + return undefined; + } + + function buildTrafficFn( + name: string, + input: Input | undefined, + transform: Transform | undefined, + ) { + if (!input) return undefined; + return functionBuilder( + name, + input, + { + link: [args.function], + permissions: [ + { + actions: ["codedeploy:PutLifecycleEventHookExecutionStatus"], + resources: ["*"], + }, + ], + _skipHint: true, + }, + transform, + { parent }, + ); + } + + function createCodedeployRole() { + return new iam.Role( + ...transform( + args.transform?.role, + `${name}CodeDeployRole`, + { + assumeRolePolicy: iam.assumeRolePolicyForPrincipal({ + Service: "codedeploy.amazonaws.com", + }), + inlinePolicies: [ + { + name: "CodeDeployLambdaPolicy", + policy: all([notifications]).apply( + ([notifications]) => + iam.getPolicyDocumentOutput({ + statements: [ + { + actions: ["lambda:GetAlias", "lambda:UpdateAlias"], + resources: [ + args.function.arn, + interpolate`${args.function.arn}:*`, + ], + }, + ...(invokeResources.length > 0 + ? [ + { + actions: ["lambda:InvokeFunction"], + resources: invokeResources, + }, + ] + : []), + { + actions: ["cloudwatch:DescribeAlarms"], + resources: ["*"], + }, + ...(notifications.length > 0 + ? [ + { + actions: ["sns:Publish"], + resources: notifications.map( + (item) => item.topic, + ), + }, + ] + : []), + ], + }).json, + ), + }, + ], + }, + { parent }, + ), + ); + } + + function createDeploymentGroup() { + return all([alarms]).apply( + ([alarms]) => + new codedeploy.DeploymentGroup( + ...transform( + args.transform?.deploymentGroup, + `${name}DeploymentGroup`, + { + deploymentGroupName: "", + appName: codedeployApp.name, + serviceRoleArn: codedeployRole.arn, + deploymentConfigName, + deploymentStyle: { + deploymentType: "BLUE_GREEN", + deploymentOption: "WITH_TRAFFIC_CONTROL", + }, + autoRollbackConfiguration: { + enabled: true, + events: ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"], + }, + alarmConfiguration: + alarms.length > 0 + ? { + enabled: true, + alarms, + } + : undefined, + triggerConfigurations: all([notifications]).apply(([n]) => + n.map((notification) => ({ + triggerEvents: notification.events.map( + (e) => CODEDEPLOY_EVENT_MAP[e], + ), + triggerName: notification.name, + triggerTargetArn: notification.topic, + })), + ), + }, + { parent }, + ), + ), + ); + } + + function createCodedeployDeployment() { + return new CodeDeployLambdaDeployment( + `${name}RolloutDeployment`, + { + applicationName: codedeployApp.name, + deploymentGroupName: deploymentGroup.deploymentGroupName, + functionName: args.function.name, + aliasName: output(args.alias).apply((a) => a.name), + targetVersion: args.function.nodes.function.version, + onConflict, + beforeTrafficFnArn: beforeTrafficFnArn?.arn, + afterTrafficFnArn: afterTrafficFnArn?.arn, + }, + { parent }, + ); + } + + function createDeploymentWaiter() { + return new CodeDeployDeploymentWaiter( + `${name}RolloutWaiter`, + { + deploymentId: codedeployDeployment.deploymentId, + wait: rolloutWait, + trigger: Date.now().toString(), + }, + { parent }, + ); + } + } + + /** + * The underlying [resources](/docs/components/#nodes) this component creates. + */ + public get nodes() { + return { + /** + * The CodeDeploy Application. + */ + application: this.codedeployApp, + /** + * The IAM Role used by CodeDeploy. + */ + role: this.codedeployRole, + /** + * The CodeDeploy Deployment Group. + */ + deploymentGroup: this.deploymentGroup, + /** + * The CodeDeploy deployment. + */ + deployment: this.codedeployDeployment, + /** + * The deployment waiter. + */ + waiter: this.deploymentWaiter, + }; + } +} +const __pulumiType = "sst:aws:FunctionRollout"; +// @ts-expect-error +FunctionRollout.__pulumiType = __pulumiType; diff --git a/platform/src/components/aws/function.ts b/platform/src/components/aws/function.ts index 8e2c76e788..06e77b07da 100644 --- a/platform/src/components/aws/function.ts +++ b/platform/src/components/aws/function.ts @@ -59,6 +59,7 @@ import { } from "./router.js"; import { KvRoutesUpdate } from "./providers/kv-routes-update.js"; import { KvKeys } from "./providers/kv-keys.js"; +import { FunctionRollout, FunctionRolloutArgs } from "./function-rollout.js"; /** * Helper type to define function ARN type @@ -254,6 +255,43 @@ interface FunctionUrlCorsArgs { maxAge?: Input; } +export type FunctionRolloutConfig = Prettify< + Omit & { + /** + * The name of the Lambda alias used for traffic shifting. + * @default `"live"` + */ + alias?: Input; + /** + * Configure a function URL that always points to the latest published version. + * Use this to test new versions before traffic shifts to them. + * + * When rollout is enabled, `url` points to the stable endpoint your users + * hit. Set `latestUrl` to create a separate URL for pre-deployment testing. + * + * @example + * + * ```ts title="sst.config.ts" + * new sst.aws.Function("Api", { + * handler: "src/api.handler", + * url: true, + * rollout: { + * type: "all-at-once", + * latestUrl: true, + * }, + * }); + * ``` + */ + latestUrl?: FunctionArgs["url"]; + } +>; + +export type FunctionRolloutDeferred = Prettify< + { + type?: never; + } & Pick +>; + export interface FunctionArgs { /** * Disable running this function [Live](/docs/live/) in `sst dev`. @@ -1309,6 +1347,13 @@ export interface FunctionArgs { /** * Enable versioning for the function. * + * :::caution + * If you connect this function to event sources like queues, topics, buckets, + * or API Gateway routes, pass the function directly or use `fn.targetArn`. + * Using `fn.arn` bypasses the alias and invokes `$LATEST` instead of the + * published version. + * ::: + * * :::note * Durable functions enable this by default. * ::: @@ -1322,6 +1367,100 @@ export interface FunctionArgs { * ``` */ versioning?: Input; + /** + * Enable CodeDeploy-managed traffic shifting for safe deployments. + * + * Configure how traffic shifts to new versions of this function on deploy. + * Supports `all-at-once`, `canary`, and `linear` strategies. Optionally run + * a before-traffic hook to validate the new version, set CloudWatch alarms + * to trigger automatic rollbacks, or use gradual traffic shifting to limit + * blast radius. + * + * :::caution + * If you connect this function to event sources like queues, topics, buckets, + * or API Gateway routes, pass the function directly or use `fn.targetArn`. + * Using `fn.arn` bypasses rollout and invokes the latest version before it + * has been validated. + * ::: + * + * :::note + * Enabling rollout automatically enables versioning. + * ::: + * + * :::note + * When running in dev mode, the deployed function code is a stub that doesn't + * change between deploys, so rollout is never triggered. + * ::: + * + * :::note + * If you enable rollout on an existing function that already has `versioning` + * or `durable` enabled, the first deploy establishes the baseline — no + * CodeDeploy deployment is triggered and any `beforeTraffic` or `afterTraffic` + * hooks will not run. Subsequent code changes will go through CodeDeploy + * traffic shifting as expected. + * ::: + * + * @example + * Canary deployment that shifts 10% of traffic for 5 minutes before going to 100%. + * + * ```js + * { + * rollout: { + * type: "canary", + * percentage: 10, + * duration: "5 minutes", + * } + * } + * ``` + * + * Linear deployment with alarms and lifecycle functions. + * + * ```js + * { + * rollout: { + * type: "linear", + * percentage: 10, + * duration: "1 minute", + * alarms: [errorAlarm.name, latencyAlarm.name], + * beforeTraffic: "src/hooks.beforeTraffic", + * afterTraffic: "src/hooks.afterTraffic", + * } + * } + * ``` + * + * If you need to pass additional properties to the before-traffic or after-traffic + * function, use `addRollout()` to configure the deployment after construction. + * Pass `rollout: true` or set options like `latestUrl` and `alias` that need + * to be created upfront. + * + * ```ts title="sst.config.ts" + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * url: true, + * rollout: true, + * }); + * + * const beforeTraffic = new sst.aws.Function("BeforeTraffic", { + * handler: "src/before-traffic.handler", + * link: [fn], + * environment: { + * DEPLOYMENT_ID: fn.nodes.rolloutDeployment!.deploymentId, + * }, + * permissions: [ + * { + * actions: ["codedeploy:PutLifecycleEventHookExecutionStatus"], + * resources: ["*"], + * }, + * ], + * }); + * + * fn.addRollout({ + * type: "all-at-once", + * beforeTraffic, + * }); + * ``` + */ + rollout?: FunctionRolloutConfig | boolean | FunctionRolloutDeferred; /** * A list of Lambda layer ARNs to add to the function. * @@ -1476,6 +1615,13 @@ export interface FunctionArgs { * This property is meant to be used internally by [Workflow](/docs/components/aws/workflow/). * Prefer the component if you want to use the [SDK](/docs/components/aws/workflow/#sdk) or if you are not very familiar with durable functions limitations. * ::: + * + * :::caution + * If you connect this function to event sources like queues, topics, buckets, + * or API Gateway routes, pass the function directly or use `fn.targetArn`. + * Using `fn.arn` bypasses the alias and invokes `$LATEST` instead of the + * published version. + * ::: */ durable?: | boolean @@ -1513,6 +1659,17 @@ export interface FunctionArgs { * when the `retries` property is set. */ eventInvokeConfig?: Transform; + /** + * Transform the rollout resources. Only applies when `rollout` is configured. + */ + rollout?: Prettify< + FunctionRolloutArgs["transform"] & { + /** + * Transform the stable Lambda Alias resource used for traffic shifting. + */ + alias?: Transform; + } + >; }; /** * @internal @@ -1706,12 +1863,21 @@ export interface FunctionArgs { */ export class Function extends Component implements Link.Linkable { private constructorName: string; + private constructorOpts: ComponentResourceOptions | undefined; private durable: boolean; + private hasRollout: boolean; private function: Output; private role: iam.Role; private logGroup: Output; + private latestUrlEndpoint: Output; + private aliasUrlEndpoint: Output; private urlEndpoint: Output; private eventInvokeConfig?: lambda.FunctionEventInvokeConfig; + private targetAlias: Output; + private latestAlias: Output; + private functionRolloutInstance?: Output; + private rolloutTransform?: NonNullable["rollout"]; + private dev: Output; private static readonly encryptionKey = lazy( () => @@ -1735,10 +1901,14 @@ export class Function extends Component implements Link.Linkable { ) { super(__pulumiType, name, args, opts); this.constructorName = name; + this.constructorOpts = opts; this.durable = Boolean(args.durable); + this.hasRollout = Boolean(args.rollout); + this.rolloutTransform = args.transform?.rollout; const parent = this; const dev = normalizeDev(); + this.dev = dev; const isContainer = all([args.python, dev]).apply( ([python, dev]) => !dev && !!python?.container, ); @@ -1760,7 +1930,11 @@ export class Function extends Component implements Link.Linkable { const streaming = normalizeStreaming(); const logging = normalizeLogging(); const volume = normalizeVolume(); - const url = normalizeUrl(); + const url = normalizeUrl(args.url); + const rolloutLatestUrl = normalizeUrl( + typeof args.rollout === "object" && args.rollout.latestUrl, + ); + const rollout = normalizeRollout(args.rollout); const copyFiles = normalizeCopyFiles(); const durable = normalizeDurable(); const policies = output(args.policies ?? []); @@ -1776,16 +1950,24 @@ export class Function extends Component implements Link.Linkable { const logGroup = createLogGroup(); const zipAsset = createZipAsset(); const fn = createFunction(); - const urlEndpoint = createUrl(); + this.function = fn; + this.role = role; + + const latestAlias = createLatestAlias(); + this.latestAlias = latestAlias; + + const rolloutResult = createRollout(); + this.targetAlias = output(rolloutResult?.targetAlias ?? this.latestAlias); + this.aliasUrlEndpoint = output(rolloutResult?.aliasUrlEndpoint); + + const latestUrlEndpoint = createLatestUrl(); createProvisioned(); const eventInvokeConfig = createEventInvokeConfig(); const links = linkData.apply((input) => input.map((item) => item.name)); - this.function = fn; - this.role = role; this.logGroup = logGroup; - this.urlEndpoint = urlEndpoint; + this.latestUrlEndpoint = latestUrlEndpoint; this.eventInvokeConfig = eventInvokeConfig; const buildInput = output({ @@ -1811,6 +1993,15 @@ export class Function extends Component implements Link.Linkable { await rpc.call("Runtime.AddTarget", input); }); + const urlEndpoint = this.hasRollout + ? output(this.aliasUrlEndpoint ?? undefined) + : this.latestUrlEndpoint; + this.urlEndpoint = urlEndpoint; + + if (rollout?.type !== undefined) { + parent.addRollout(rollout); + } + this.registerOutputs({ _live: unsecret( output(dev).apply((dev) => { @@ -1962,8 +2153,8 @@ export class Function extends Component implements Link.Linkable { })); } - function normalizeUrl() { - return output(args.url).apply((url) => { + function normalizeUrl(urlInput: FunctionArgs["url"]) { + return output(urlInput).apply((url) => { if (url === false || url === undefined) return; if (url === true) { url = {}; @@ -1997,6 +2188,7 @@ export class Function extends Component implements Link.Linkable { }; }); } + type NormalizedUrl = ReturnType; function normalizeCopyFiles() { return output(args.copyFiles ?? []).apply((copyFiles) => @@ -2059,15 +2251,20 @@ export class Function extends Component implements Link.Linkable { if (!args.durable) return; const config = args.durable === true ? {} : args.durable; return { - timeout: output(config.timeout).apply((v) => - toSeconds(v ?? "14 days"), - ), + timeout: output(config.timeout).apply((v) => toSeconds(v ?? "14 days")), retention: output(config.retention).apply((v) => toDays(v ?? "30 days"), ), }; } + function normalizeRollout( + rollout: FunctionArgs["rollout"], + ): FunctionRolloutConfig | FunctionRolloutDeferred | undefined { + if (typeof rollout === "boolean") return rollout ? {} : undefined; + return rollout; + } + function buildLinkData() { return output(args.link || []).apply((links) => Link.build(links)); } @@ -2534,7 +2731,9 @@ export class Function extends Component implements Link.Linkable { return new s3.BucketObjectv2( dev - ? `DevBridgeCode${logicalName(regionName)}${logicalName(path.basename(bundle))}` + ? `DevBridgeCode${logicalName(regionName)}${logicalName( + path.basename(bundle), + )}` : `${name}Code`, { key: dev @@ -2625,7 +2824,7 @@ export class Function extends Component implements Link.Linkable { layers: args.layers, tags: args.tags, publish: output(args.versioning).apply( - (v) => v ?? Boolean(args.durable), + (v) => v || Boolean(args.durable) || Boolean(args.rollout), ), reservedConcurrentExecutions: concurrency?.reserved, durableConfig: durable && { @@ -2695,189 +2894,222 @@ export class Function extends Component implements Link.Linkable { ); } - function createUrl() { - return url.apply((url) => { - if (url === undefined) return output(undefined); + function createUrl(opts: { + suffix?: string; + qualifier: Input | undefined; + url: NormalizedUrl; + }) { + const suffix = opts.suffix ?? ""; - const authorization = output(url.authorization ?? "none"); - const isOac = output(url.route?.routerProtection).apply( - (p) => p?.mode === "oac" || p?.mode === "oac-with-edge-signing", - ); - const isIam = all([isOac, authorization]).apply( - ([oac, authorization]) => oac || authorization === "iam", - ); + const urlEndpoint = all([opts.url, opts.qualifier]).apply( + ([url, qualifier]) => { + if (url === undefined) return undefined; - /** - * Lambda Function URLs only accept alias names in the explicit `qualifier` - * field. Durable functions with URLs therefore need an alias target here, - * even when the underlying function is still on `$LATEST`. - * See https://github.com/hashicorp/terraform-provider-aws/issues/31459 - */ - const qualifier = durable - ? new lambda.Alias(`${name}Durable`, { - functionName: fn.arn, - functionVersion: fn.version, - }).name - : undefined; - - const fnUrl = new lambda.FunctionUrl( - `${name}Url`, - { - functionName: durable ? fn.arn : fn.name, - qualifier, - authorizationType: isIam.apply((isIam) => - isIam ? "AWS_IAM" : "NONE", - ), - invokeMode: streaming.apply((streaming) => - streaming ? "RESPONSE_STREAM" : "BUFFERED", - ), - cors: url.cors, - }, - { parent }, - ); + const authorization = output(url.authorization ?? "none"); + const isOac = output(url.route?.routerProtection).apply( + (p) => p?.mode === "oac" || p?.mode === "oac-with-edge-signing", + ); + const isIam = all([isOac, authorization]).apply( + ([oac, authorization]) => oac || authorization === "iam", + ); - if (!url.route) { - authorization.apply((authorization) => { - if (authorization !== "none") return; + const fnUrl = new lambda.FunctionUrl( + `${name}${suffix}Url`, + { + functionName: fn.name, + qualifier, + authorizationType: isIam.apply((isIam) => + isIam ? "AWS_IAM" : "NONE", + ), + invokeMode: streaming.apply((streaming) => + streaming ? "RESPONSE_STREAM" : "BUFFERED", + ), + cors: url.cors, + }, + { parent }, + ); - new lambda.Permission( - `${name}PublicFunctionUrlAccess`, - { - action: "lambda:InvokeFunctionUrl", - function: fn.name, - principal: "*", - functionUrlAuthType: "NONE", - }, - { parent }, - ); - new lambda.Permission( - `${name}InvokeFunction`, - { - action: "lambda:InvokeFunction", - function: fn.name, - principal: "*", - invokedViaFunctionUrl: true, - }, - { parent }, - ); - }); - return fnUrl.functionUrl; - } + if (!url.route) { + authorization.apply((authorization) => { + if (authorization !== "none") return; - // Create permissions based on Router protection mode - all([isOac, authorization, url.route.routerDistributionArn]).apply( - ([oac, authorization, distributionArn]) => { - if (oac && distributionArn) { - new lambda.Permission( - `${name}CloudFrontFunctionUrlAccess`, - { - action: "lambda:InvokeFunctionUrl", - function: fn.name, - principal: "cloudfront.amazonaws.com", - sourceArn: distributionArn, - }, - { parent }, - ); - new lambda.Permission( - `${name}CloudFrontInvokeFunction`, - { - action: "lambda:InvokeFunction", - function: fn.name, - principal: "cloudfront.amazonaws.com", - sourceArn: distributionArn, - invokedViaFunctionUrl: true, - }, - { parent }, - ); - } else if (authorization === "none") { new lambda.Permission( - `${name}PublicFunctionUrlAccess`, + `${name}${suffix}PublicFunctionUrlAccess`, { action: "lambda:InvokeFunctionUrl", function: fn.name, + qualifier, principal: "*", functionUrlAuthType: "NONE", }, { parent }, ); new lambda.Permission( - `${name}PublicInvokeFunction`, + `${name}${suffix}InvokeFunction`, { action: "lambda:InvokeFunction", function: fn.name, + qualifier, principal: "*", invokedViaFunctionUrl: true, }, { parent }, ); - } - }, - ); + }); + return fnUrl.functionUrl; + } - // add router route - const routeNamespace = crypto - .createHash("md5") - .update(`${$app.name}-${$app.stage}-${name}`) - .digest("hex") - .substring(0, 4); - new KvKeys( - `${name}RouteKey`, - { - store: url.route.routerKvStoreArn, - namespace: routeNamespace, - entries: all([fnUrl.functionUrl, isOac, url.route]).apply( - ([fnUrlValue, oac, route]) => { - const timeouts = [ - "connectionTimeout" as const, - "readTimeout" as const, - "keepAliveTimeout" as const, - ].flatMap((k) => { - const value = route[k]; - return value ? [[k, toSeconds(value)]] : []; - }); - return { - metadata: JSON.stringify({ - host: new URL(fnUrlValue).host, - rewrite: route.rewrite, - origin: { - ...(oac - ? { - originAccessControlConfig: { - enabled: true, - signingBehavior: "always", - signingProtocol: "sigv4", - originType: "lambda", - }, - } - : {}), - connectionAttempts: route.connectionAttempts, - ...(timeouts.length - ? { timeouts: Object.fromEntries(timeouts) } - : {}), - }, - }), - }; - }, - ), - purge: false, - }, - { parent }, - ); - new KvRoutesUpdate( - `${name}RoutesUpdate`, - { - store: url.route.routerKvStoreArn, - namespace: url.route.routerKvNamespace, - key: "routes", - entry: url.route.apply((route) => - ["url", routeNamespace, route.hostPattern, route.pathPrefix].join( - ",", + // Create permissions based on Router protection mode + all([isOac, authorization, url.route.routerDistributionArn]).apply( + ([oac, authorization, distributionArn]) => { + if (oac && distributionArn) { + new lambda.Permission( + `${name}${suffix}CloudFrontFunctionUrlAccess`, + { + action: "lambda:InvokeFunctionUrl", + function: fn.name, + qualifier, + principal: "cloudfront.amazonaws.com", + sourceArn: distributionArn, + }, + { parent }, + ); + new lambda.Permission( + `${name}${suffix}CloudFrontInvokeFunction`, + { + action: "lambda:InvokeFunction", + function: fn.name, + qualifier, + principal: "cloudfront.amazonaws.com", + sourceArn: distributionArn, + invokedViaFunctionUrl: true, + }, + { parent }, + ); + } else if (authorization === "none") { + new lambda.Permission( + `${name}${suffix}PublicFunctionUrlAccess`, + { + action: "lambda:InvokeFunctionUrl", + function: fn.name, + qualifier, + principal: "*", + functionUrlAuthType: "NONE", + }, + { parent }, + ); + new lambda.Permission( + `${name}${suffix}PublicInvokeFunction`, + { + action: "lambda:InvokeFunction", + function: fn.name, + qualifier, + principal: "*", + invokedViaFunctionUrl: true, + }, + { parent }, + ); + } + }, + ); + + // add router route + const routeNamespace = crypto + .createHash("md5") + .update(`${$app.name}-${$app.stage}-${name}${suffix}`) + .digest("hex") + .substring(0, 4); + new KvKeys( + `${name}${suffix}RouteKey`, + { + store: url.route.routerKvStoreArn, + namespace: routeNamespace, + entries: all([fnUrl.functionUrl, isOac, url.route]).apply( + ([fnUrlValue, oac, route]) => { + const timeouts = [ + "connectionTimeout" as const, + "readTimeout" as const, + "keepAliveTimeout" as const, + ].flatMap((k) => { + const value = route[k]; + return value ? [[k, toSeconds(value)]] : []; + }); + return { + metadata: JSON.stringify({ + host: new URL(fnUrlValue).host, + rewrite: route.rewrite, + origin: { + ...(oac + ? { + originAccessControlConfig: { + enabled: true, + signingBehavior: "always", + signingProtocol: "sigv4", + originType: "lambda", + }, + } + : {}), + connectionAttempts: route.connectionAttempts, + ...(timeouts.length + ? { timeouts: Object.fromEntries(timeouts) } + : {}), + }, + }), + }; + }, ), - ), + purge: false, + }, + { parent }, + ); + new KvRoutesUpdate( + `${name}${suffix}RoutesUpdate`, + { + store: url.route.routerKvStoreArn, + namespace: url.route.routerKvNamespace, + key: "routes", + entry: [ + "url", + routeNamespace, + url.route.hostPattern, + url.route.pathPrefix, + ].join(","), + }, + { parent }, + ); + return url.route.routerUrl; + }, + ); + + return output(urlEndpoint); + } + + function createLatestUrl() { + if (parent.hasRollout) { + return createUrl({ + url: rolloutLatestUrl, + qualifier: parent.latestQualifier, + }); + } + + return createUrl({ + url, + qualifier: parent.latestQualifier, + }); + } + + function createLatestAlias() { + return parent.useQualifiedTarget.apply((useQualifiedTarget) => { + if (!useQualifiedTarget) return; + + return new lambda.Alias( + `${name}LatestAlias`, + { + functionName: fn.name, + functionVersion: fn.version, }, { parent }, ); - return url.route.routerUrl; }); } @@ -2907,6 +3139,47 @@ export class Function extends Component implements Link.Linkable { ); } + function createRollout() { + if (!rollout) return; + + all([fn.publish, dev]).apply(([publish, dev]) => { + if (dev) return; + if (!publish) + throw new VisibleError( + `Rollout requires versioning. Set "versioning: true" on the "${name}" function.`, + ); + }); + + const aliasName = output(rollout.alias ?? "live"); + const aliasTransform = args.transform?.rollout?.alias; + + const targetAlias = new lambda.Alias( + ...transform( + aliasTransform, + `${name}RolloutAlias`, + { + functionName: fn.arn, + functionVersion: fn.version, + name: aliasName, + }, + { + parent, + // Pulumi creates the alias once; CodeDeploy manages the version + // and routing config during traffic shifting and rollbacks. + ignoreChanges: ["functionVersion", "routingConfig"], + }, + ), + ); + + const aliasUrlEndpoint = createUrl({ + suffix: "LiveAlias", + qualifier: targetAlias.name, + url, + }); + + return { targetAlias, aliasUrlEndpoint }; + } + function createEventInvokeConfig() { if (args.retries === undefined) { return undefined; @@ -2947,6 +3220,23 @@ export class Function extends Component implements Link.Linkable { * The Function Event Invoke Config resource if retries are configured. */ eventInvokeConfig: this.eventInvokeConfig, + /** + * The Lambda Alias used for rollout traffic shifting. Available when + * `versioning`, `rollout`, or `durable` is enabled. + */ + targetAlias: this.targetAlias, + /** + * The Lambda Alias pointing to the latest published version. Available + * when `versioning`, `rollout`, or `durable` is enabled. + */ + latestAlias: this.latestAlias, + /** + * The rollout deployment. Use `.deploymentId` to get the CodeDeploy + * deployment ID. Available when `rollout` is configured. + */ + rolloutDeployment: this.functionRolloutInstance?.apply( + (i) => i?.nodes.deployment, + ), }; } @@ -2964,6 +3254,28 @@ export class Function extends Component implements Link.Linkable { }); } + /** + * The function URL that points to the latest deployed code. Useful for + * testing new versions before traffic shifts to them. + * + * Only available when `rollout.latestUrl` is set. + */ + public get latestUrl() { + if (!this.hasRollout) { + throw new VisibleError( + `"latestUrl" is only available when rollout is configured. Use "url" instead.`, + ); + } + return this.latestUrlEndpoint.apply((url) => { + if (!url) { + throw new VisibleError( + `Latest function URL is not enabled. Set "latestUrl" in the rollout config.`, + ); + } + return url; + }); + } + /** * The name of the Lambda function. */ @@ -2978,49 +3290,86 @@ export class Function extends Component implements Link.Linkable { return this.function.arn; } - /** @internal */ private get useQualifiedTarget() { - return this.function.publish.apply( - (publish) => (publish ?? false) || this.durable, + return this.function.publish.apply((publish) => Boolean(publish)); + } + + private getTargetArn(args: { + arn: Input; + useQualifiedTarget: Input; + alias: Input; + }) { + return all([args.useQualifiedTarget, args.alias]).apply( + ([useQualifiedTarget, alias]) => { + if (!useQualifiedTarget) return output(args.arn); + if (alias) return alias.arn; + throw new Error( + "Internal error: expected a Lambda alias to exist because versioning is enabled. This is a bug in SST — please report it at https://github.com/sst/sst/issues", + ); + }, ); } - /** @internal */ public get targetArn() { - return this.useQualifiedTarget.apply((useQualifiedTarget) => - useQualifiedTarget ? this.function.qualifiedArn : this.arn, - ); + return this.getTargetArn({ + useQualifiedTarget: this.useQualifiedTarget, + arn: this.arn, + alias: this.targetAlias, + }); + } + + public get latestTargetArn() { + return this.getTargetArn({ + useQualifiedTarget: this.useQualifiedTarget, + arn: this.arn, + alias: this.latestAlias, + }); } /** @internal */ public get qualifier() { - return this.targetArn.apply( + return output(this.targetArn).apply( + (arn) => splitQualifiedFunctionArn(arn).qualifier, + ); + } + + public get latestQualifier() { + return output(this.latestTargetArn).apply( (arn) => splitQualifiedFunctionArn(arn).qualifier, ); } /** @internal */ public get targetInvokeArn() { - return this.useQualifiedTarget.apply((useQualifiedTarget) => - useQualifiedTarget - ? this.function.qualifiedInvokeArn - : this.function.invokeArn, + return all([this.useQualifiedTarget, this.targetAlias]).apply( + ([useQualifiedTarget, targetAlias]) => { + if (!useQualifiedTarget) return this.function.invokeArn; + if (targetAlias) return targetAlias.invokeArn; + throw new Error( + "Internal error: expected a Lambda alias to exist because versioning is enabled. This is a bug in SST — please report it at https://github.com/sst/sst/issues", + ); + }, ); } /** @internal */ public get targetResponseStreamingInvokeArn() { - return this.useQualifiedTarget.apply((useQualifiedTarget) => - useQualifiedTarget - ? all([ - this.arn, - this.function.qualifiedArn, - this.function.responseStreamingInvokeArn, - ]).apply(([arn, qualifiedArn, responseStreamingInvokeArn]) => - responseStreamingInvokeArn.replace(arn, qualifiedArn), - ) - : this.function.responseStreamingInvokeArn, - ); + return this.useQualifiedTarget.apply((useQualifiedTarget) => { + if (!useQualifiedTarget) return this.function.responseStreamingInvokeArn; + + return all([ + this.arn, + this.targetAlias.apply((a) => a?.arn), + this.function.responseStreamingInvokeArn, + ]).apply(([arn, aliasArn, responseStreamingInvokeArn]) => { + if (!aliasArn) { + throw new Error( + "Internal error: expected a Lambda alias to exist because versioning is enabled. This is a bug in SST — please report it at https://github.com/sst/sst/issues", + ); + } + return responseStreamingInvokeArn.replace(arn, aliasArn); + }); + }); } /** @@ -3057,46 +3406,82 @@ export class Function extends Component implements Link.Linkable { ); } - /** @internal */ - static fromDefinition( - name: string, - definition: Input, - override: Pick, - argsTransform?: Transform, + /** + * Configure CodeDeploy-managed traffic shifting for this function. + * + * If you need to pass additional properties to the before-traffic or after-traffic + * function, use `addRollout()` to configure the deployment after construction. + * + * @example + * + * ```ts title="sst.config.ts" + * const fn = new sst.aws.Function("Function", { + * handler: "src/api.handler", + * url: true, + * rollout: true, + * }); + * + * const beforeTraffic = new sst.aws.Function("BeforeTraffic", { + * handler: "src/before-traffic.handler", + * link: [fn], + * environment: { + * DEPLOYMENT_ID: fn.nodes.rolloutDeployment!.deploymentId, + * }, + * permissions: [ + * { + * actions: ["codedeploy:PutLifecycleEventHookExecutionStatus"], + * resources: ["*"], + * }, + * ], + * }); + * + * fn.addRollout({ + * type: "all-at-once", + * beforeTraffic, + * }); + * ``` + */ + public addRollout( + rollout: Prettify>, opts?: ComponentResourceOptions, ) { - return output(definition).apply((definition) => { - if (typeof definition === "string") { - return new Function( - ...transform( - argsTransform, - name, - { handler: definition, ...override }, - opts || {}, - ), - ); - } else if (definition.handler) { - return new Function( - ...transform( - argsTransform, - name, - { - ...definition, - ...override, - permissions: all([ - definition.permissions, - override?.permissions, - ]).apply(([permissions, overridePermissions]) => [ - ...(permissions ?? []), - ...(overridePermissions ?? []), - ]), - }, - opts || {}, - ), + if (!this.hasRollout) { + throw new VisibleError( + `Cannot call "addRollout" on the "${this.constructorName}" function without passing "rollout" to the constructor first.`, + ); + } + if (this.functionRolloutInstance) { + throw new VisibleError( + `Rollout has already been configured for the "${this.constructorName}" function.`, + ); + } + + const parent = this; + const name = this.constructorName; + + parent.functionRolloutInstance = all([ + parent.dev, + parent.targetAlias, + ]).apply(([dev, targetAlias]) => { + if (dev) return; + if (!targetAlias) { + throw new Error( + "Internal error: expected a Lambda alias to exist because versioning is enabled. This is a bug in SST — please report it at https://github.com/sst/sst/issues", ); } - throw new Error(`Invalid function definition for the "${name}" Function`); + + return new FunctionRollout( + `${name}Rollout`, + { + function: parent, + alias: targetAlias, + ...rollout, + transform: parent.rolloutTransform, + }, + { provider: parent.constructorOpts?.provider, ...opts }, + ); }); + return parent.functionRolloutInstance; } /** @internal */ @@ -3105,9 +3490,11 @@ export class Function extends Component implements Link.Linkable { properties: { name: this.name, url: this.urlEndpoint, - ...(this.durable + qualifier: this.qualifier, + ...(this.hasRollout ? { - qualifier: this.qualifier, + latestUrl: this.latestUrlEndpoint, + latestQualifier: this.latestQualifier, } : {}), }, @@ -3127,7 +3514,15 @@ export class Function extends Component implements Link.Linkable { ] : []), ], - resources: [this.durable ? interpolate`${this.arn}:*` : this.arn], + resources: all([this.useQualifiedTarget]).apply( + ([useQualifiedTarget]) => { + return this.durable + ? [interpolate`${this.arn}:*`] + : useQualifiedTarget + ? [this.arn, interpolate`${this.arn}:*`] + : [this.arn]; + }, + ), }), ], }; diff --git a/platform/src/components/aws/helpers/subscriber.ts b/platform/src/components/aws/helpers/subscriber.ts index a3c8fa5ac2..bd454ac5f4 100644 --- a/platform/src/components/aws/helpers/subscriber.ts +++ b/platform/src/components/aws/helpers/subscriber.ts @@ -1,15 +1,17 @@ import { Input, output } from "@pulumi/pulumi"; -import { FunctionArgs, FunctionArn } from "../function.js"; +import { Function, FunctionArgs, FunctionArn } from "../function.js"; import { Queue } from "../queue"; export function isFunctionSubscriber( - subscriber?: Input, + subscriber?: Input, ) { if (!subscriber) return output(false); return output(subscriber).apply( (subscriber) => - typeof subscriber === "string" || typeof subscriber.handler === "string", + typeof subscriber === "string" || + subscriber instanceof Function || + typeof subscriber.handler === "string", ); } diff --git a/platform/src/components/aws/kinesis-stream-lambda-subscriber.ts b/platform/src/components/aws/kinesis-stream-lambda-subscriber.ts index 4d8770c41b..29701d36ae 100644 --- a/platform/src/components/aws/kinesis-stream-lambda-subscriber.ts +++ b/platform/src/components/aws/kinesis-stream-lambda-subscriber.ts @@ -2,7 +2,7 @@ import { lambda } from "@pulumi/aws"; import { output } from "@pulumi/pulumi"; import { Component, transform } from "../component"; import { Input } from "../input.js"; -import { FunctionArgs } from "./function.js"; +import { Function, FunctionArgs } from "./function.js"; import { KinesisStreamLambdaSubscriberArgs } from "./kinesis-stream.js"; import { FunctionBuilder, functionBuilder } from "./helpers/function-builder"; @@ -19,7 +19,7 @@ export interface Args extends KinesisStreamLambdaSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; } /** diff --git a/platform/src/components/aws/kinesis-stream.ts b/platform/src/components/aws/kinesis-stream.ts index 6e0c23e81a..7eba2cf101 100644 --- a/platform/src/components/aws/kinesis-stream.ts +++ b/platform/src/components/aws/kinesis-stream.ts @@ -4,7 +4,7 @@ import { Component, Transform, transform } from "../component.js"; import { Input } from "../input.js"; import { Link } from "../link.js"; import { hashStringToPrettyString, logicalName } from "../naming.js"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { KinesisStreamLambdaSubscriber } from "./kinesis-stream-lambda-subscriber.js"; import { parseKinesisStreamArn } from "./helpers/arn.js"; import { permission } from "./permission.js"; @@ -197,7 +197,7 @@ export class KinesisStream extends Component implements Link.Linkable { */ public subscribe( name: string, - subscriber: Input, + subscriber: Input, args?: KinesisStreamLambdaSubscriberArgs, ): Output; /** @@ -206,7 +206,7 @@ export class KinesisStream extends Component implements Link.Linkable { * back with the new `name` argument. */ public subscribe( - subscriber: Input, + subscriber: Input, args?: KinesisStreamLambdaSubscriberArgs, ): Output; public subscribe(nameOrSubscriber: any, subscriberOrArgs?: any, args?: any) { @@ -280,7 +280,7 @@ export class KinesisStream extends Component implements Link.Linkable { public static subscribe( name: string, streamArn: Input, - subscriber: Input, + subscriber: Input, args?: KinesisStreamLambdaSubscriberArgs, ): Output; /** @@ -290,7 +290,7 @@ export class KinesisStream extends Component implements Link.Linkable { */ public static subscribe( streamArn: Input, - subscriber: Input, + subscriber: Input, args?: KinesisStreamLambdaSubscriberArgs, ): Output; public static subscribe( @@ -325,7 +325,7 @@ export class KinesisStream extends Component implements Link.Linkable { subscriberName: string, name: string, streamArn: Input, - subscriber: Input, + subscriber: Input, args: KinesisStreamLambdaSubscriberArgs = {}, opts: ComponentResourceOptions = {}, ) { @@ -346,31 +346,36 @@ export class KinesisStream extends Component implements Link.Linkable { private static _subscribeV1( name: string, streamArn: Input, - subscriber: Input, + subscriber: Input, args: KinesisStreamLambdaSubscriberArgs = {}, opts: ComponentResourceOptions = {}, ) { return all([streamArn, subscriber, args]).apply( ([streamArn, subscriber, args]) => { - const suffix = logicalName( - hashStringToPrettyString( - [ - streamArn, - JSON.stringify(args.filters ?? {}), - typeof subscriber === "string" ? subscriber : subscriber.handler, - ].join(""), - 6, - ), - ); - return new KinesisStreamLambdaSubscriber( - `${name}Subscriber${suffix}`, - { - stream: { arn: streamArn }, - subscriber, - ...args, - }, - opts, - ); + const subscriberIdentity = + typeof subscriber === "string" + ? subscriber + : subscriber instanceof Function + ? subscriber.arn + : subscriber.handler; + + return output(subscriberIdentity).apply((identity) => { + const suffix = logicalName( + hashStringToPrettyString( + [streamArn, JSON.stringify(args.filters ?? {}), identity].join(""), + 6, + ), + ); + return new KinesisStreamLambdaSubscriber( + `${name}Subscriber${suffix}`, + { + stream: { arn: streamArn }, + subscriber, + ...args, + }, + opts, + ); + }); }, ); } diff --git a/platform/src/components/aws/opencontrol.ts b/platform/src/components/aws/opencontrol.ts index 519e23c38f..5fdbc10df3 100644 --- a/platform/src/components/aws/opencontrol.ts +++ b/platform/src/components/aws/opencontrol.ts @@ -43,7 +43,7 @@ export interface OpenControlArgs { * Learn more in the [OpenControl docs](https://opencontrol.ai) on how to * configure the `server` function. */ - server: Input; + server: Input; } /** * The `OpenControl` component has been deprecated. It should not be used for new projects. diff --git a/platform/src/components/aws/providers/codedeploy-deployment-waiter.ts b/platform/src/components/aws/providers/codedeploy-deployment-waiter.ts new file mode 100644 index 0000000000..fc58a133f2 --- /dev/null +++ b/platform/src/components/aws/providers/codedeploy-deployment-waiter.ts @@ -0,0 +1,28 @@ +import { CustomResourceOptions, Input, Output, dynamic } from "@pulumi/pulumi"; +import { rpc } from "../../rpc/rpc.js"; + +export interface CodeDeployDeploymentWaiterInputs { + deploymentId: Input; + wait: Input; + trigger: Input; +} + +export interface CodeDeployDeploymentWaiter { + deploymentId: Output; + status: Output; +} + +export class CodeDeployDeploymentWaiter extends dynamic.Resource { + constructor( + name: string, + args: CodeDeployDeploymentWaiterInputs, + opts?: CustomResourceOptions, + ) { + super( + new rpc.Provider("Aws.CodeDeployDeploymentWaiter"), + `${name}.sst.aws.CodeDeployDeploymentWaiter`, + { ...args, status: undefined }, + opts, + ); + } +} diff --git a/platform/src/components/aws/providers/codedeploy-lambda-deployment.ts b/platform/src/components/aws/providers/codedeploy-lambda-deployment.ts new file mode 100644 index 0000000000..4ec9c995d4 --- /dev/null +++ b/platform/src/components/aws/providers/codedeploy-lambda-deployment.ts @@ -0,0 +1,32 @@ +import { CustomResourceOptions, Input, Output, dynamic } from "@pulumi/pulumi"; +import { rpc } from "../../rpc/rpc.js"; + +export interface CodeDeployLambdaDeploymentInputs { + applicationName: Input; + deploymentGroupName: Input; + functionName: Input; + aliasName: Input; + targetVersion: Input; + onConflict: Input<"rollback" | "cancel" | "fail">; + beforeTrafficFnArn?: Input; + afterTrafficFnArn?: Input; +} + +export interface CodeDeployLambdaDeployment { + deploymentId: Output; +} + +export class CodeDeployLambdaDeployment extends dynamic.Resource { + constructor( + name: string, + args: CodeDeployLambdaDeploymentInputs, + opts?: CustomResourceOptions, + ) { + super( + new rpc.Provider("Aws.CodeDeployLambdaDeployment"), + `${name}.sst.aws.CodeDeployLambdaDeployment`, + { ...args, deploymentId: undefined }, + opts, + ); + } +} diff --git a/platform/src/components/aws/queue-lambda-subscriber.ts b/platform/src/components/aws/queue-lambda-subscriber.ts index 159a542e2b..5082f804a1 100644 --- a/platform/src/components/aws/queue-lambda-subscriber.ts +++ b/platform/src/components/aws/queue-lambda-subscriber.ts @@ -24,7 +24,7 @@ export interface Args extends QueueSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. diff --git a/platform/src/components/aws/queue.ts b/platform/src/components/aws/queue.ts index 8dbdcf262f..e949977338 100644 --- a/platform/src/components/aws/queue.ts +++ b/platform/src/components/aws/queue.ts @@ -8,7 +8,7 @@ import { import { Component, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { VisibleError } from "../error"; import { hashStringToPrettyString, logicalName } from "../naming"; import { parseQueueArn } from "./helpers/arn"; @@ -507,7 +507,7 @@ export class Queue extends Component implements Link.Linkable { * ``` */ public subscribe( - subscriber: Input, + subscriber: Input, args?: QueueSubscriberArgs, opts?: ComponentResourceOptions, ) { @@ -572,7 +572,7 @@ export class Queue extends Component implements Link.Linkable { */ public static subscribe( queueArn: Input, - subscriber: Input, + subscriber: Input, args?: QueueSubscriberArgs, opts?: ComponentResourceOptions, ) { @@ -590,7 +590,7 @@ export class Queue extends Component implements Link.Linkable { private static _subscribeFunction( name: string, queueArn: Input, - subscriber: Input, + subscriber: Input, args: QueueSubscriberArgs = {}, opts?: ComponentResourceOptions, ) { diff --git a/platform/src/components/aws/realtime-lambda-subscriber.ts b/platform/src/components/aws/realtime-lambda-subscriber.ts index ac473551da..be3e5216d3 100644 --- a/platform/src/components/aws/realtime-lambda-subscriber.ts +++ b/platform/src/components/aws/realtime-lambda-subscriber.ts @@ -25,7 +25,7 @@ export interface Args extends RealtimeSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; } /** diff --git a/platform/src/components/aws/realtime.ts b/platform/src/components/aws/realtime.ts index 0aae114a8f..1fcecadef6 100644 --- a/platform/src/components/aws/realtime.ts +++ b/platform/src/components/aws/realtime.ts @@ -1,8 +1,12 @@ -import { ComponentResourceOptions, Output, all } from "@pulumi/pulumi"; +import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; import { Function, FunctionArgs, FunctionArn } from "./function.js"; +import { + functionBuilder, + FunctionBuilder, +} from "./helpers/function-builder.js"; import { hashStringToPrettyString, logicalName } from "../naming"; import { RealtimeLambdaSubscriber } from "./realtime-lambda-subscriber"; import { iot, lambda } from "@pulumi/aws"; @@ -18,7 +22,7 @@ export interface RealtimeArgs { * } * ``` */ - authorizer: Input; + authorizer: Input; /** * [Transform](/docs/components#transform) how this subscription creates its underlying * resources. @@ -158,7 +162,7 @@ export interface RealtimeSubscriberArgs { export class Realtime extends Component implements Link.Linkable { private readonly constructorName: string; private constructorOpts: ComponentResourceOptions; - private readonly authHadler: Output; + private readonly authHandler: FunctionBuilder; private readonly iotAuthorizer: iot.Authorizer; private readonly iotEndpoint: Output; @@ -171,7 +175,7 @@ export class Realtime extends Component implements Link.Linkable { const parent = this; - const authHadler = createAuthorizerFunction(); + const authHandler = createAuthorizerFunction(); const iotAuthorizer = createAuthorizer(); createPermission(); @@ -181,11 +185,11 @@ export class Realtime extends Component implements Link.Linkable { { parent }, ).endpointAddress; this.constructorName = name; - this.authHadler = authHadler; + this.authHandler = authHandler; this.iotAuthorizer = iotAuthorizer; function createAuthorizerFunction() { - return Function.fromDefinition( + return functionBuilder( `${name}AuthorizerHandler`, args.authorizer, { @@ -209,7 +213,7 @@ export class Realtime extends Component implements Link.Linkable { `${name}Authorizer`, { signingDisabled: true, - authorizerFunctionArn: authHadler.arn, + authorizerFunctionArn: authHandler.targetArn, }, { parent }, ), @@ -221,7 +225,8 @@ export class Realtime extends Component implements Link.Linkable { `${name}Permission`, { action: "lambda:InvokeFunction", - function: authHadler.arn, + function: authHandler.arn, + qualifier: authHandler.qualifier.apply((q) => q!), principal: "iot.amazonaws.com", sourceArn: iotAuthorizer.arn, }, @@ -256,7 +261,7 @@ export class Realtime extends Component implements Link.Linkable { /** * The IoT authorizer function resource. */ - authHandler: this.authHadler, + authHandler: this.authHandler, }; } @@ -297,29 +302,32 @@ export class Realtime extends Component implements Link.Linkable { * ``` */ public subscribe( - subscriber: Input, + subscriber: Input, args: RealtimeSubscriberArgs, ) { return all([subscriber, args.filter]).apply(([subscriber, filter]) => { - const suffix = logicalName( - hashStringToPrettyString( - [ - filter, - typeof subscriber === "string" ? subscriber : subscriber.handler, - ].join(""), - 6, - ), - ); + const subscriberIdentity = + typeof subscriber === "string" + ? subscriber + : subscriber instanceof Function + ? subscriber.arn + : subscriber.handler; - return new RealtimeLambdaSubscriber( - `${this.constructorName}Subscriber${suffix}`, - { - iot: { name: this.constructorName }, - subscriber, - ...args, - }, - { provider: this.constructorOpts.provider }, - ); + return output(subscriberIdentity).apply((identity) => { + const suffix = logicalName( + hashStringToPrettyString([filter, identity].join(""), 6), + ); + + return new RealtimeLambdaSubscriber( + `${this.constructorName}Subscriber${suffix}`, + { + iot: { name: this.constructorName }, + subscriber, + ...args, + }, + { provider: this.constructorOpts.provider }, + ); + }); }); } diff --git a/platform/src/components/aws/sns-topic-lambda-subscriber.ts b/platform/src/components/aws/sns-topic-lambda-subscriber.ts index cd7f98431c..36ce9af07a 100644 --- a/platform/src/components/aws/sns-topic-lambda-subscriber.ts +++ b/platform/src/components/aws/sns-topic-lambda-subscriber.ts @@ -24,7 +24,7 @@ export interface Args extends SnsTopicSubscriberArgs { /** * The subscriber function. */ - subscriber: Input; + subscriber: Input; } /** diff --git a/platform/src/components/aws/sns-topic.ts b/platform/src/components/aws/sns-topic.ts index c62c613856..b0e7b27170 100644 --- a/platform/src/components/aws/sns-topic.ts +++ b/platform/src/components/aws/sns-topic.ts @@ -2,7 +2,7 @@ import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, outputId, Transform, transform } from "../component"; import { Link } from "../link"; import type { Input } from "../input"; -import { FunctionArgs, FunctionArn } from "./function.js"; +import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { hashStringToPrettyString, logicalName } from "../naming"; import { parseTopicArn } from "./helpers/arn"; import { SnsTopicLambdaSubscriber } from "./sns-topic-lambda-subscriber"; @@ -275,7 +275,7 @@ export class SnsTopic extends Component implements Link.Linkable { */ public subscribe( name: string, - subscriber: Input, + subscriber: Input, args?: SnsTopicSubscriberArgs, ): Output; /** @@ -284,7 +284,7 @@ export class SnsTopic extends Component implements Link.Linkable { * back with the new `name` argument. */ public subscribe( - subscriber: Input, + subscriber: Input, args?: SnsTopicSubscriberArgs, ): Output; @@ -353,7 +353,7 @@ export class SnsTopic extends Component implements Link.Linkable { public static subscribe( name: string, topicArn: Input, - subscriber: Input, + subscriber: Input, args?: SnsTopicSubscriberArgs, ): Output; /** @@ -363,7 +363,7 @@ export class SnsTopic extends Component implements Link.Linkable { */ public static subscribe( topicArn: Input, - subscriber: Input, + subscriber: Input, args?: SnsTopicSubscriberArgs, ): Output; @@ -399,7 +399,7 @@ export class SnsTopic extends Component implements Link.Linkable { subscriberName: string, name: string, topicArn: string | Output, - subscriber: Input, + subscriber: Input, args: SnsTopicSubscriberArgs = {}, opts: $util.ComponentResourceOptions = {}, ) { @@ -420,31 +420,40 @@ export class SnsTopic extends Component implements Link.Linkable { private static _subscribeFunctionV1( name: string, topicArn: string | Output, - subscriber: Input, + subscriber: Input, args: SnsTopicSubscriberArgs = {}, opts: $util.ComponentResourceOptions = {}, ) { return all([subscriber, args]).apply(([subscriber, args]) => { - const suffix = logicalName( - hashStringToPrettyString( - [ - typeof topicArn === "string" ? topicArn : outputId, - JSON.stringify(args.filter ?? {}), - typeof subscriber === "string" ? subscriber : subscriber.handler, - ].join(""), - 6, - ), - ); + const subscriberIdentity = + typeof subscriber === "string" + ? subscriber + : subscriber instanceof Function + ? subscriber.arn + : subscriber.handler; - return new SnsTopicLambdaSubscriber( - `${name}Subscriber${suffix}`, - { - topic: { arn: topicArn }, - subscriber, - ...args, - }, - opts, - ); + return output(subscriberIdentity).apply((identity) => { + const suffix = logicalName( + hashStringToPrettyString( + [ + typeof topicArn === "string" ? topicArn : outputId, + JSON.stringify(args.filter ?? {}), + identity, + ].join(""), + 6, + ), + ); + + return new SnsTopicLambdaSubscriber( + `${name}Subscriber${suffix}`, + { + topic: { arn: topicArn }, + subscriber, + ...args, + }, + opts, + ); + }); }); } diff --git a/platform/src/components/aws/ssr-site.ts b/platform/src/components/aws/ssr-site.ts index 3a659787b9..a2f1020dda 100644 --- a/platform/src/components/aws/ssr-site.ts +++ b/platform/src/components/aws/ssr-site.ts @@ -1036,6 +1036,7 @@ async function handler(event) { { action: "lambda:InvokeFunctionUrl", function: server.nodes.function.name, + qualifier: server.qualifier.apply((q) => q!), principal: "*", functionUrlAuthType: "NONE", }, @@ -1050,6 +1051,7 @@ async function handler(event) { { action: "lambda:InvokeFunctionUrl", function: server.nodes.function.name, + qualifier: server.qualifier.apply((q) => q!), principal: "cloudfront.amazonaws.com", sourceArn: distributionArn, }, @@ -1060,6 +1062,7 @@ async function handler(event) { { action: "lambda:InvokeFunction", function: server.nodes.function.name, + qualifier: server.qualifier.apply((q) => q!), principal: "cloudfront.amazonaws.com", sourceArn: distributionArn, invokedViaFunctionUrl: true, @@ -1077,6 +1080,7 @@ async function handler(event) { { action: "lambda:InvokeFunctionUrl", function: imgOptimizer.nodes.function.name, + qualifier: imgOptimizer.qualifier.apply((q) => q!), principal: "*", functionUrlAuthType: "NONE", }, @@ -1091,6 +1095,7 @@ async function handler(event) { { action: "lambda:InvokeFunctionUrl", function: imgOptimizer.nodes.function.name, + qualifier: imgOptimizer.qualifier.apply((q) => q!), principal: "cloudfront.amazonaws.com", sourceArn: distributionArn, }, @@ -1101,6 +1106,7 @@ async function handler(event) { { action: "lambda:InvokeFunction", function: imgOptimizer.nodes.function.name, + qualifier: imgOptimizer.qualifier.apply((q) => q!), principal: "cloudfront.amazonaws.com", sourceArn: distributionArn, invokedViaFunctionUrl: true, diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index 919f023003..cac2431e6e 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -147,6 +147,7 @@ export class Component extends ComponentResource { "aws:cognito/identityPoolRoleAttachment:IdentityPoolRoleAttachment", "aws:cognito/identityProvider:IdentityProvider", "aws:cognito/userPoolClient:UserPoolClient", + "aws:lambda/alias:Alias", "aws:lambda/eventSourceMapping:EventSourceMapping", "aws:lambda/functionEventInvokeConfig:FunctionEventInvokeConfig", "aws:lambda/functionUrl:FunctionUrl", @@ -213,6 +214,15 @@ export class Component extends ComponentResource { "aws:apigatewayv2/vpcLink:VpcLink": ["name", 128], "aws:appautoscaling/policy:Policy": ["name", 255], "aws:appsync/graphQLApi:GraphQLApi": ["name", 65536], + "aws:codedeploy/application:Application": ["name", 100], + "aws:codedeploy/deploymentConfig:DeploymentConfig": [ + "deploymentConfigName", + 100, + ], + "aws:codedeploy/deploymentGroup:DeploymentGroup": [ + "deploymentGroupName", + 100, + ], "aws:cloudwatch/eventBus:EventBus": ["name", 256], "aws:cloudwatch/eventTarget:EventTarget": ["targetId", 64], "aws:cloudwatch/eventRule:EventRule": ["name", 64], diff --git a/sdk/js/src/aws/rollout.ts b/sdk/js/src/aws/rollout.ts new file mode 100644 index 0000000000..013e32ffdb --- /dev/null +++ b/sdk/js/src/aws/rollout.ts @@ -0,0 +1,107 @@ +import { Context } from "aws-lambda"; +import { aws } from "./client.js"; + +/** + * The `rollout` client SDK is available through the following. + * + * @example + * ```js title="src/before-traffic.ts" + * import { rollout } from "sst/aws/rollout"; + * ``` + */ +export namespace rollout { + export interface Event { + /** + * The CodeDeploy deployment ID. + */ + deploymentId: string; + /** + * The lifecycle event hook execution ID. Pass this to `rollout.report` to + * report the status of the hook. + */ + lifecycleEventHookExecutionId: string; + } + + /** + * Creates a typed handler for a CodeDeploy lifecycle hook. + * + * @example + * ```ts title="src/before-traffic.ts" + * import { Resource } from "sst"; + * import { rollout } from "sst/aws/rollout"; + * + * export const handler = rollout.handler(async (event) => { + * const resp = await fetch(Resource.Function.latestUrl); + * await rollout.report(event, resp.ok ? "Succeeded" : "Failed"); + * }); + * ``` + */ + export function handler( + cb: (event: Event, context: Context) => Promise, + ) { + return async (rawEvent: RawEvent, context: Context) => { + if (!rawEvent.DeploymentId) { + throw new Error( + "Missing DeploymentId in event. This handler must be invoked by a CodeDeploy lifecycle hook.", + ); + } + if (!rawEvent.LifecycleEventHookExecutionId) { + throw new Error( + "Missing LifecycleEventHookExecutionId in event. This handler must be invoked by a CodeDeploy lifecycle hook.", + ); + } + await cb( + { + deploymentId: rawEvent.DeploymentId, + lifecycleEventHookExecutionId: + rawEvent.LifecycleEventHookExecutionId, + }, + context, + ); + }; + } + + /** + * Reports the status of a CodeDeploy lifecycle hook back to CodeDeploy. + * + * @example + * ```ts + * await rollout.report(event, "Succeeded"); + * ``` + */ + export async function report( + event: Event, + status: "Succeeded" | "Failed", + options?: { aws?: aws.Options }, + ) { + const res = await aws.fetch( + "codedeploy", + "/", + { + method: "POST", + headers: { + "X-Amz-Target": + "CodeDeploy_20141006.PutLifecycleEventHookExecutionStatus", + "Content-Type": "application/x-amz-json-1.1", + }, + body: JSON.stringify({ + deploymentId: event.deploymentId, + lifecycleEventHookExecutionId: event.lifecycleEventHookExecutionId, + status, + }), + }, + options, + ); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `Failed to report lifecycle status: ${res.status} ${body}`, + ); + } + } + + interface RawEvent { + DeploymentId: string; + LifecycleEventHookExecutionId: string; + } +}