Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions examples/aws-lambda-rollout/infra/alarms.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
return aws
.getArnOutput({ arn: targetArn })
.resource.apply((r) => r.split(":").slice(1).join(":"));
}
36 changes: 36 additions & 0 deletions examples/aws-lambda-rollout/infra/topics.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
5 changes: 5 additions & 0 deletions examples/aws-lambda-rollout/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "aws-lambda-rollout",
"private": true,
"type": "module"
}
64 changes: 64 additions & 0 deletions examples/aws-lambda-rollout/scripts/test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Result[]>();
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<number, number> = {};
for (const r of failed) {
statusCounts[r.status] = (statusCounts[r.status] || 0) + 1;
}
console.log(` Status codes:`, statusCounts);
}
console.log();
}
}
9 changes: 9 additions & 0 deletions examples/aws-lambda-rollout/src/api.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
};
}
84 changes: 84 additions & 0 deletions examples/aws-lambda-rollout/sst.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/// <reference path="./.sst/platform/config.d.ts" />

/**
* ## 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,
};
},
});
11 changes: 11 additions & 0 deletions examples/aws-lambda-rollout/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
7 changes: 7 additions & 0 deletions examples/aws-lambda-smoke-test-function-url/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "aws-lambda-smoke-test-function-url",
"private": true,
"dependencies": {
"sst": "^4"
}
}
19 changes: 19 additions & 0 deletions examples/aws-lambda-smoke-test-function-url/src/api.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
}
24 changes: 24 additions & 0 deletions examples/aws-lambda-smoke-test-function-url/src/before-traffic.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
Loading
Loading