diff --git a/cdk-dotnet-durablefunction-iotheartbeat/.gitignore b/cdk-dotnet-durablefunction-iotheartbeat/.gitignore new file mode 100644 index 000000000..a4609e758 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/.gitignore @@ -0,0 +1,342 @@ +# CDK asset staging directory +.cdk.staging +cdk.out + +# Created by https://www.gitignore.io/api/csharp + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + + +# End of https://www.gitignore.io/api/csharp \ No newline at end of file diff --git a/cdk-dotnet-durablefunction-iotheartbeat/README.md b/cdk-dotnet-durablefunction-iotheartbeat/README.md new file mode 100644 index 000000000..0d20794dd --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/README.md @@ -0,0 +1,366 @@ +# AWS Lambda Durable Function — IoT Device Heartbeat Monitor (Eternal Orchestration) + +This sample demonstrates the **eternal orchestration** (continue-as-new) pattern using **AWS Lambda Durable Functions** for .NET. A durable function runs indefinitely, monitoring heartbeat signals from an IoT device. If no heartbeat arrives within a timeout, it marks the device offline and sends an SNS alert. Periodically, the orchestration restarts with fresh history to prevent unbounded checkpoint growth. + +## Architecture + +``` +┌─────────────┐ ┌───────────────┐ ┌───────────────────────┐ +│ IoT Device │──────▶│ API Gateway │─────▶│ Heartbeat Receiver │──▶ DynamoDB +│ (periodic) │ HTTPS │ (API Key auth)│ │ (Lambda) │ (LastHeartbeatAt) +└─────────────┘ └───────────────┘ └───────────────────────┘ + x-api-key header │ + │ reads + ▼ +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ Heartbeat Monitor (Durable Function — Eternal) │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────────┐ │ +│ │ Cycle 1..N (loop until MaxCyclesBeforeRestart): │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │ │ +│ │ │ WaitAsync │───▶│ Check DynamoDB │───▶│ Online? → update │ │ │ +│ │ │ (timeout) │ │ for heartbeat │ │ Offline? → alert SNS │ │ │ +│ │ │ No compute! │ │ │ │ │ │ │ +│ │ └─────────────┘ └──────────────────┘ └───────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ After N cycles: self-invoke (continue-as-new) → fresh execution │ +└──────────────────────────────────────────────────────────────────────────────────┘ +``` + +## What It Demonstrates + +- **Eternal orchestration** — The workflow runs in an infinite loop, monitoring a device forever. Each cycle waits, checks, and either confirms online or alerts offline. +- **Continue-as-new** — After a configurable number of cycles (default 100), the function self-invokes with the same input, starting a fresh durable execution. This prevents the checkpoint history from growing unboundedly. +- **Durable timers (WaitAsync)** — Each cycle suspends execution for the heartbeat timeout period. The Lambda terminates — zero compute charges during the wait. +- **API key authentication** — Devices authenticate via an `x-api-key` header on API Gateway. The simplest form of device auth — no IAM roles, no user pools. +- **SNS alerting** — When a missed heartbeat is detected, the monitor sends an alert to an SNS topic. +- **CDK infrastructure** — API Gateway with API key, DynamoDB, SNS, and two Lambda functions. + +## Project Structure + +``` +├── cdk.json +├── src/ +│ ├── CdkDotnetDurablefunctionIotheartbeat.slnx +│ ├── .globalconfig +│ ├── Infra/ +│ │ ├── Program.cs +│ │ ├── InfraStack.cs +│ │ ├── GlobalSuppressions.cs +│ │ └── Infra.csproj +│ └── HeartbeatMonitor/ +│ ├── HeartbeatMonitorHandler.cs # Eternal durable workflow +│ ├── HeartbeatReceiverHandler.cs # Device heartbeat endpoint +│ ├── DeviceService.cs # DynamoDB + SNS operations +│ ├── Models.cs # Record types +│ └── HeartbeatMonitor.csproj +└── README.md +``` + +--- + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) (`npm install -g aws-cdk`) +- AWS account with credentials configured (`aws configure`) +- CDK bootstrapped (`cdk bootstrap`) + +--- + +## Build & Deploy + +```bash +dotnet build src/CdkDotnetDurablefunctionIotheartbeat.slnx -c Release +cdk deploy +``` + +The CDK automatically publishes the Lambda during synthesis. + +--- + +## Testing + +### Capture stack outputs + +```bash +STACK_NAME="CdkDotnetDurablefunctionIotheartbeatStack" + +API_URL=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='HeartbeatApiUrl'].OutputValue" \ + --output text) + +MONITOR_ARN=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='HeartbeatMonitorFunctionArn'].OutputValue" \ + --output text) + +TABLE_NAME=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='DeviceTableName'].OutputValue" \ + --output text) + +# Retrieve the API key value +API_KEY_ID=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='ApiKeyId'].OutputValue" \ + --output text) + +API_KEY=$(aws apigateway get-api-key \ + --api-key $API_KEY_ID \ + --include-value \ + --query 'value' --output text) +``` + +### Start monitoring a device + +```bash +aws lambda invoke \ + --function-name "$MONITOR_ARN:\$LATEST" \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{"DeviceId":"sensor-001","HeartbeatTimeoutSeconds":30,"MaxCyclesBeforeRestart":50}' \ + /tmp/invoke-response.json +``` + +The monitor starts and waits 30 seconds for the first heartbeat. + +### Send heartbeats (simulate a device) + +```bash +# Send a heartbeat (include x-api-key header for authentication) +curl -X POST "${API_URL%/}/heartbeat" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $API_KEY" \ + -d '{"DeviceId":"sensor-001"}' + +# Repeat periodically (every 20s) to keep the device "online" +while true; do + curl -s -X POST "${API_URL%/}/heartbeat" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $API_KEY" \ + -d '{"DeviceId":"sensor-001"}' + echo " - heartbeat sent at $(date)" + sleep 20 +done +``` + +### Stop sending heartbeats + +Stop the loop above. After 30 seconds (the timeout), the monitor will: +1. Check DynamoDB and find no recent heartbeat +2. Mark the device as "offline" +3. Send an SNS alert + +### Check device status + +```bash +aws dynamodb get-item \ + --table-name $TABLE_NAME \ + --key '{"DeviceId":{"S":"sensor-001"}}' +``` + +### Check durable execution status + +```bash +aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN +``` + +### Verify automatic restart (continue-as-new) + +The monitor automatically restarts after `MaxCyclesBeforeRestart` cycles (default 50) to prevent unbounded checkpoint growth. To observe this, start a monitor with a low cycle limit and short timeout: + +```bash +# Start a monitor with 5 cycles and 10-second timeout (restarts after ~50 seconds) +aws lambda invoke \ + --function-name "$MONITOR_ARN:\$LATEST" \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload '{"DeviceId":"sensor-restart-test","HeartbeatTimeoutSeconds":10,"MaxCyclesBeforeRestart":5}' \ + /tmp/invoke-response.json +``` + +Wait about 60 seconds, then list all executions: + +```bash +aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN \ + --query "DurableExecutions[].{Status:Status,Started:StartTimestamp}" \ + --output table +``` + +You should see multiple executions — the original completing with `SUCCEEDED` status (it reached the cycle limit and self-invoked) and a new one in `RUNNING` status (the fresh restart): + +``` +------------------------------------------- +| ListDurableExecutionsByFunction | ++-----------+-----------------------------+ +| Status | Started | ++-----------+-----------------------------+ +| RUNNING | 2026-07-15T09:15:50.000Z | ← new execution (continue-as-new) +| SUCCEEDED| 2026-07-15T09:14:59.000Z | ← original (completed 5 cycles) ++-----------+-----------------------------+ +``` + +To confirm the restart happened, check the completed execution's result: + +```bash +# Get the ARN of the completed execution +COMPLETED_ARN=$(aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN \ + --query "DurableExecutions[?Status=='SUCCEEDED'].DurableExecutionArn | [0]" \ + --output text) + +aws lambda get-durable-execution \ + --durable-execution-arn $COMPLETED_ARN \ + --query "OutputPayload" --output text +``` + +The output shows `"Reason":"cycle_limit_reached"` confirming it self-restarted: + +```json +{"DeviceId":"sensor-restart-test","Reason":"cycle_limit_reached","CompletedCycles":5,"RestartedAt":"..."} +``` + +Clean up the test device when done: + +```bash +# Stop all running executions for the test device +aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN \ + --query "DurableExecutions[?Status=='RUNNING'].DurableExecutionArn" \ + --output text \ + | tr '\t' '\n' \ + | while read -r arn; do + aws lambda stop-durable-execution --durable-execution-arn "$arn" + done +``` + +### Subscribe to alerts (optional) + +```bash +TOPIC_ARN=$(aws cloudformation describe-stacks \ + --stack-name $STACK_NAME \ + --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" \ + --output text) + +aws sns subscribe \ + --topic-arn $TOPIC_ARN \ + --protocol email \ + --notification-endpoint your@email.com +``` + +### Stop monitoring a device + +To stop an active heartbeat monitor, list the running executions and stop the one for your device: + +```bash +# List active executions +aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN \ + --query "DurableExecutions[?Status=='RUNNING'].[DurableExecutionArn]" \ + --output text + +# Stop a specific execution +EXECUTION_ARN="" +aws lambda stop-durable-execution \ + --durable-execution-arn $EXECUTION_ARN +``` + +Or stop all running executions for the monitor function: + +```bash +aws lambda list-durable-executions-by-function \ + --function-name $MONITOR_ARN \ + --query "DurableExecutions[?Status=='RUNNING'].DurableExecutionArn" \ + --output text \ + | tr '\t' '\n' \ + | while read -r arn; do + echo "Stopping: $arn" + aws lambda stop-durable-execution \ + --durable-execution-arn "$arn" + done +``` + +--- + +## How the Eternal Orchestration Pattern Works + +```csharp +private async Task Workflow(MonitorInput input, IDurableContext ctx) +{ + var cycleCount = 0; + + // Eternal loop — runs until cycle limit + while (cycleCount < input.MaxCyclesBeforeRestart) + { + cycleCount++; + + // Suspend for timeout period (no compute charges) + await ctx.WaitAsync(timeout, name: $"wait-cycle-{cycleCount}"); + + // Check DynamoDB for recent heartbeat + var lastHeartbeat = await ctx.StepAsync(..., name: $"check-heartbeat-{cycleCount}"); + + if (heartbeatReceived) + // Mark online + else + // Mark offline + send SNS alert + } + + // Continue-as-new: self-invoke with same input, fresh execution history + await ctx.StepAsync(async (_, ct) => + { + await LambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = $"{functionName}:$LATEST", + InvocationType = InvocationType.Event, + Payload = JsonSerializer.Serialize(input) + }, ct); + }, name: "continue-as-new"); +} +``` + +**Why continue-as-new?** Each durable execution accumulates checkpoint history. For an eternal workflow, this grows without bound. By self-invoking after N cycles, the new execution starts fresh with zero history while maintaining logical continuity. + +--- + +## Configuration + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `HeartbeatTimeoutSeconds` | 60 | How long to wait before checking for a heartbeat | +| `MaxCyclesBeforeRestart` | 100 | Cycles before performing continue-as-new | +| `DeviceId` | (required) | The device identifier to monitor | + +--- + +## Cleanup + +```bash +cdk destroy +``` + +--- + +## Useful Commands + +| Command | Description | +|---------|-------------| +| `dotnet build src/CdkDotnetDurablefunctionIotheartbeat.slnx` | Build the solution | +| `cdk synth` | Synthesize CloudFormation (auto-publishes Lambda) | +| `cdk deploy` | Deploy the stack | +| `cdk destroy` | Tear down all resources | + +--- + +## References + +- [Amazon.Lambda.DurableExecution SDK](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DurableExecution) +- [Wait documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/wait.md) +- [Steps documentation](https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/core/steps.md) +- [AWS CDK .NET Reference](https://docs.aws.amazon.com/cdk/api/v2/dotnet/) diff --git a/cdk-dotnet-durablefunction-iotheartbeat/cdk-dotnet-durablefunction-iotheartbeat.json b/cdk-dotnet-durablefunction-iotheartbeat/cdk-dotnet-durablefunction-iotheartbeat.json new file mode 100644 index 000000000..7ccd6b908 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/cdk-dotnet-durablefunction-iotheartbeat.json @@ -0,0 +1,63 @@ +{ + "title": "Lambda Durable Function — IoT Heartbeat Monitor with Eternal Orchestration (.NET)", + "description": "An eternal durable function that monitors IoT device heartbeats indefinitely, alerting via SNS when a device goes offline, and using continue-as-new to prevent unbounded history growth.", + "language": ".NET", + "level": "300", + "framework": "CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys a Lambda durable function that runs indefinitely using the eternal orchestration (continue-as-new) pattern. It monitors heartbeat signals from an IoT device by periodically checking DynamoDB for the latest heartbeat timestamp.", + "If no heartbeat arrives within a configurable timeout, the function marks the device offline and publishes an alert to SNS. Heartbeats are received via a separate Lambda behind API Gateway with API key authentication.", + "After a configurable number of cycles, the orchestration restarts with fresh execution history to prevent unbounded checkpoint growth — demonstrating the continue-as-new pattern for long-lived serverless workflows." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/cdk-dotnet-durablefunction-iotheartbeat", + "templateURL": "serverless-patterns/cdk-dotnet-durablefunction-iotheartbeat", + "projectFolder": "cdk-dotnet-durablefunction-iotheartbeat", + "templateFile": "cdk.json" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Durable Functions Documentation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" + }, + { + "text": "Build multi-step applications and AI workflows with Lambda durable functions", + "link": "https://aws.amazon.com/blogs/aws/build-multi-step-applications-and-ai-workflows-with-aws-lambda-durable-functions/" + }, + { + "text": "Amazon SNS", + "link": "https://docs.aws.amazon.com/sns/latest/dg/welcome.html" + } + ] + }, + "deploy": { + "text": [ + "cdk deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk destroy." + ] + }, + "authors": [ + { + "name": "Doug Perkes", + "image": "", + "bio": "Senior Solutions Architect at AWS, focused on .NET and serverless.", + "linkedin": "dougperkes", + "twitter": "" + } + ] +} diff --git a/cdk-dotnet-durablefunction-iotheartbeat/cdk.json b/cdk-dotnet-durablefunction-iotheartbeat/cdk.json new file mode 100644 index 000000000..809a467e4 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/cdk.json @@ -0,0 +1,20 @@ +{ + "app": "dotnet run --project src/Infra/Infra.csproj", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "src/*/obj", + "src/*/bin", + "src/*.sln*" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true + } +} diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/.globalconfig b/cdk-dotnet-durablefunction-iotheartbeat/src/.globalconfig new file mode 100644 index 000000000..b3b852224 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/.globalconfig @@ -0,0 +1,5 @@ +is_global = true + +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1711.severity = none +dotnet_diagnostic.CA1806.severity = none diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/CdkDotnetDurablefunctionIotheartbeat.slnx b/cdk-dotnet-durablefunction-iotheartbeat/src/CdkDotnetDurablefunctionIotheartbeat.slnx new file mode 100644 index 000000000..8a23e4c41 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/CdkDotnetDurablefunctionIotheartbeat.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/DeviceService.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/DeviceService.cs new file mode 100644 index 000000000..aea21bc12 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/DeviceService.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; + +namespace HeartbeatMonitor; + +/// +/// Handles device status persistence and alerting. +/// +internal sealed class DeviceService(IAmazonDynamoDB dynamoDb, IAmazonSimpleNotificationService sns, string tableName, string alertTopicArn) +{ + private readonly IAmazonDynamoDB _dynamoDb = dynamoDb; + private readonly IAmazonSimpleNotificationService _sns = sns; + private readonly string _tableName = tableName; + private readonly string _alertTopicArn = alertTopicArn; + + /// + /// Checks if a heartbeat has been received since the given time. + /// + public async Task GetLastHeartbeatAsync(string deviceId, CancellationToken ct) + { + var response = await _dynamoDb.GetItemAsync(new GetItemRequest + { + TableName = _tableName, + Key = new Dictionary + { + ["DeviceId"] = new() { S = deviceId } + }, + ProjectionExpression = "LastHeartbeatAt" + }, ct); + + if (response.Item.TryGetValue("LastHeartbeatAt", out var attr)) + { + return DateTime.Parse(attr.S, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + return null; + } + + /// + /// Updates the device status in DynamoDB. + /// + public async Task UpdateDeviceStatusAsync(string deviceId, string status, int missedHeartbeats, CancellationToken ct) + { + await _dynamoDb.UpdateItemAsync(new UpdateItemRequest + { + TableName = _tableName, + Key = new Dictionary + { + ["DeviceId"] = new() { S = deviceId } + }, + UpdateExpression = "SET #st = :status, MissedHeartbeats = :missed, UpdatedAt = :now", + ExpressionAttributeNames = new Dictionary + { + ["#st"] = "Status" + }, + ExpressionAttributeValues = new Dictionary + { + [":status"] = new() { S = status }, + [":missed"] = new() { N = missedHeartbeats.ToString(CultureInfo.InvariantCulture) }, + [":now"] = new() { S = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) } + } + }, ct); + } + + /// + /// Sends an alert that a device has gone offline. + /// + public async Task SendOfflineAlertAsync(string deviceId, int missedHeartbeats, CancellationToken ct) + { + var message = $"⚠️ Device '{deviceId}' is OFFLINE.\n" + + $"Missed heartbeats: {missedHeartbeats}\n" + + $"Time: {DateTime.UtcNow:O}"; + + await _sns.PublishAsync(new PublishRequest + { + TopicArn = _alertTopicArn, + Subject = $"Device Offline: {deviceId}", + Message = message + }, ct); + } + + /// + /// Records a heartbeat from a device (called by the receiver function). + /// + public async Task RecordHeartbeatAsync(string deviceId, CancellationToken ct) + { + await _dynamoDb.UpdateItemAsync(new UpdateItemRequest + { + TableName = _tableName, + Key = new Dictionary + { + ["DeviceId"] = new() { S = deviceId } + }, + UpdateExpression = "SET #st = :status, LastHeartbeatAt = :now, MissedHeartbeats = :zero", + ExpressionAttributeNames = new Dictionary + { + ["#st"] = "Status" + }, + ExpressionAttributeValues = new Dictionary + { + [":status"] = new() { S = "online" }, + [":now"] = new() { S = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) }, + [":zero"] = new() { N = "0" } + } + }, ct); + } +} diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitor.csproj b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitor.csproj new file mode 100644 index 000000000..af1e47e79 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitor.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + HeartbeatMonitor + HeartbeatMonitor + + + + + + + + + + + + + diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitorHandler.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitorHandler.cs new file mode 100644 index 000000000..5998718c5 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatMonitorHandler.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.Lambda; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Amazon.Lambda.Model; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.SimpleNotificationService; + +[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] + +namespace HeartbeatMonitor; + +/// +/// Eternal durable orchestration that monitors a device's heartbeat. +/// +/// Pattern: Runs in a loop, waiting for the heartbeat timeout each cycle. +/// If no heartbeat is received within the timeout, marks the device offline and alerts. +/// After MaxCyclesBeforeRestart cycles, performs "continue-as-new" by invoking itself +/// with fresh state to prevent unbounded checkpoint history growth. +/// +/// Flow per cycle: +/// 1. Wait for heartbeat timeout duration. +/// 2. Check DynamoDB for a recent heartbeat. +/// 3. If heartbeat received → mark online, reset missed count. +/// 4. If no heartbeat → increment missed count, mark offline, send SNS alert. +/// 5. If cycle limit reached → self-invoke (continue-as-new) and return. +/// +internal sealed class HeartbeatMonitorHandler +{ + private static readonly IAmazonDynamoDB DynamoDbClient = new AmazonDynamoDBClient(); + private static readonly IAmazonSimpleNotificationService SnsClient = new AmazonSimpleNotificationServiceClient(); + private static readonly IAmazonLambda LambdaClient = new AmazonLambdaClient(); + + private static readonly string TableName = + System.Environment.GetEnvironmentVariable("DEVICE_TABLE_NAME") + ?? throw new InvalidOperationException("DEVICE_TABLE_NAME environment variable is not set."); + + private static readonly string AlertTopicArn = + System.Environment.GetEnvironmentVariable("ALERT_TOPIC_ARN") + ?? throw new InvalidOperationException("ALERT_TOPIC_ARN environment variable is not set."); + + private readonly DeviceService _deviceService = new(DynamoDbClient, SnsClient, TableName, AlertTopicArn); + + public static async Task Main() + { + var handler = new HeartbeatMonitorHandler(); + var serializer = new DefaultLambdaJsonSerializer(); + using var wrapper = HandlerWrapper.GetHandlerWrapper( + handler.Handler, serializer); + using var bootstrap = new LambdaBootstrap(wrapper); + await bootstrap.RunAsync(); + } + + public Task Handler( + DurableExecutionInvocationInput input, ILambdaContext context) + => DurableFunction.WrapAsync(Workflow, input, context); + + private async Task Workflow(MonitorInput input, IDurableContext ctx) + { + var timeout = TimeSpan.FromSeconds(input.HeartbeatTimeoutSeconds); + var cycleCount = 0; + var missedHeartbeats = 0; + + // ────────────────────────────────────────────────────────────────── + // Initialize: Mark device as being monitored and record start time. + // lastHeartbeatAt MUST be inside a step to be deterministic on replay. + // ────────────────────────────────────────────────────────────────── + var lastHeartbeatAt = await ctx.StepAsync( + async (_, ct) => + { + await _deviceService.UpdateDeviceStatusAsync(input.DeviceId, "monitoring_started", 0, ct); + return DateTime.UtcNow; + }, + name: "init-monitoring", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + // ────────────────────────────────────────────────────────────────── + // Eternal loop: monitor heartbeats until cycle limit is reached + // ────────────────────────────────────────────────────────────────── + while (cycleCount < input.MaxCyclesBeforeRestart) + { + cycleCount++; + + // Wait for the heartbeat timeout period (Lambda suspends — no charges) + await ctx.WaitAsync(timeout, name: $"wait-cycle-{cycleCount}"); + + // Check if a heartbeat was received during the wait + var heartbeatTime = await ctx.StepAsync( + async (_, ct) => await _deviceService.GetLastHeartbeatAsync(input.DeviceId, ct), + name: $"check-heartbeat-{cycleCount}", + config: new StepConfig { RetryStrategy = RetryStrategy.Transient }); + + if (heartbeatTime.HasValue && heartbeatTime.Value > lastHeartbeatAt) + { + // Heartbeat received — device is alive + lastHeartbeatAt = heartbeatTime.Value; + missedHeartbeats = 0; + + await ctx.StepAsync( + async (_, ct) => await _deviceService.UpdateDeviceStatusAsync( + input.DeviceId, "online", 0, ct), + name: $"mark-online-{cycleCount}", + config: new StepConfig { RetryStrategy = RetryStrategy.Transient }); + } + else + { + // No heartbeat — device may be offline + missedHeartbeats++; + + await ctx.StepAsync( + async (_, ct) => + { + await _deviceService.UpdateDeviceStatusAsync( + input.DeviceId, "offline", missedHeartbeats, ct); + await _deviceService.SendOfflineAlertAsync( + input.DeviceId, missedHeartbeats, ct); + }, + name: $"alert-offline-{cycleCount}", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + } + } + + // ────────────────────────────────────────────────────────────────── + // Continue-as-new: self-invoke with fresh state to reset history. + // This prevents unbounded checkpoint log growth. + // ────────────────────────────────────────────────────────────────── + await ctx.StepAsync( + async (_, ct) => + { + var payload = JsonSerializer.Serialize(input); + var functionName = System.Environment.GetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME")!; + + await LambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = $"{functionName}:$LATEST", + InvocationType = InvocationType.Event, + Payload = payload + }, ct); + }, + name: "continue-as-new", + config: new StepConfig { RetryStrategy = RetryStrategy.Default }); + + return new MonitorResult( + DeviceId: input.DeviceId, + Reason: "cycle_limit_reached", + CompletedCycles: cycleCount, + RestartedAt: DateTime.UtcNow); + } +} diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatReceiverHandler.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatReceiverHandler.cs new file mode 100644 index 000000000..2cf6e49d5 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/HeartbeatReceiverHandler.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using Amazon.SimpleNotificationService; + +namespace HeartbeatMonitor; + +/// +/// Receives heartbeat signals from IoT devices via API Gateway. +/// Devices authenticate with an API key (x-api-key header). +/// +/// Request: POST /heartbeat with JSON body {"DeviceId": "sensor-001"} +/// Response: 200 OK with confirmation +/// +internal sealed class HeartbeatReceiverHandler +{ + private static readonly IAmazonDynamoDB DynamoDbClient = new AmazonDynamoDBClient(); + private static readonly IAmazonSimpleNotificationService SnsClient = new AmazonSimpleNotificationServiceClient(); + + private static readonly string TableName = + System.Environment.GetEnvironmentVariable("DEVICE_TABLE_NAME") + ?? throw new InvalidOperationException("DEVICE_TABLE_NAME environment variable is not set."); + + private static readonly string AlertTopicArn = + System.Environment.GetEnvironmentVariable("ALERT_TOPIC_ARN") ?? ""; + + private readonly DeviceService _deviceService = new(DynamoDbClient, SnsClient, TableName, AlertTopicArn); + + public async Task Handler( + APIGatewayProxyRequest request, ILambdaContext context) + { + HeartbeatRequest? heartbeat = null; + + if (!string.IsNullOrEmpty(request.Body)) + { + heartbeat = JsonSerializer.Deserialize(request.Body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + + if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.DeviceId)) + { + return new APIGatewayProxyResponse + { + StatusCode = 400, + Body = JsonSerializer.Serialize(new HeartbeatResponse("error", "DeviceId is required", DateTime.UtcNow)), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + + await _deviceService.RecordHeartbeatAsync(heartbeat.DeviceId, CancellationToken.None); + + context.Logger.LogInformation($"Heartbeat received from device: {heartbeat.DeviceId}"); + + return new APIGatewayProxyResponse + { + StatusCode = 200, + Body = JsonSerializer.Serialize(new HeartbeatResponse("ok", $"Heartbeat recorded for {heartbeat.DeviceId}", DateTime.UtcNow)), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } +} + +internal sealed record HeartbeatRequest(string DeviceId); +internal sealed record HeartbeatResponse(string Status, string Message, DateTime Timestamp); diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/Models.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/Models.cs new file mode 100644 index 000000000..7b7f15a44 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/HeartbeatMonitor/Models.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HeartbeatMonitor; + +/// +/// Input to the heartbeat monitor workflow. +/// Contains the device ID to monitor and configuration for timeouts. +/// +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Deserialized by Lambda runtime")] +internal sealed record MonitorInput( + string DeviceId, + int HeartbeatTimeoutSeconds = 60, + int MaxCyclesBeforeRestart = 100); + +/// +/// State carried across monitoring cycles within a single execution. +/// +internal sealed record MonitorState( + string DeviceId, + int CycleCount, + int MissedHeartbeats, + string Status, + DateTime LastHeartbeatAt, + DateTime StartedAt); + +/// +/// A heartbeat signal stored in DynamoDB by the receiver. +/// +internal sealed record HeartbeatRecord( + string DeviceId, + DateTime Timestamp, + string Status); + +/// +/// Result returned when the monitor restarts (continue-as-new). +/// +internal sealed record MonitorResult( + string DeviceId, + string Reason, + int CompletedCycles, + DateTime RestartedAt); diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/GlobalSuppressions.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/GlobalSuppressions.cs new file mode 100644 index 000000000..26233fcb5 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/GlobalSuppressions.cs @@ -0,0 +1 @@ +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Potential Code Quality Issues", "RECS0026:Possible unassigned object created by 'new'", Justification = "Constructs add themselves to the scope in which they are created")] diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Infra.csproj b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Infra.csproj new file mode 100644 index 000000000..7707ef69e --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Infra.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + Infra + + + + + + + + diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/InfraStack.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/InfraStack.cs new file mode 100644 index 000000000..66c1faeab --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/InfraStack.cs @@ -0,0 +1,202 @@ +using System.Diagnostics; +using Amazon.CDK; +using Amazon.CDK.AWS.APIGateway; +using Amazon.CDK.AWS.DynamoDB; +using Amazon.CDK.AWS.Lambda; +using Amazon.CDK.AWS.SNS; +using Constructs; + +namespace Infra; + +internal sealed class InfraStack : Stack +{ + internal InfraStack(Construct scope, string id, IStackProps? props = null) + : base(scope, id, props) + { + // ────────────────────────────────────────────────────────────────── + // DynamoDB Table — device registry and status + // ────────────────────────────────────────────────────────────────── + var deviceTable = new Table(this, "DeviceTable", new TableProps + { + PartitionKey = new Amazon.CDK.AWS.DynamoDB.Attribute + { + Name = "DeviceId", + Type = AttributeType.STRING + }, + BillingMode = BillingMode.PAY_PER_REQUEST, + RemovalPolicy = RemovalPolicy.DESTROY + }); + + // ────────────────────────────────────────────────────────────────── + // SNS Topic — device-offline alerts + // ────────────────────────────────────────────────────────────────── + var alertTopic = new Topic(this, "DeviceOfflineAlertTopic", new TopicProps + { + DisplayName = "IoT Device Offline Alerts" + }); + + // ────────────────────────────────────────────────────────────────── + // Lambda: Heartbeat Monitor — eternal durable orchestration + // ────────────────────────────────────────────────────────────────── + var publishOutputPath = PublishLambdaProject("src/HeartbeatMonitor/HeartbeatMonitor.csproj"); + + var monitorFunction = new Function(this, "HeartbeatMonitorFunction", new FunctionProps + { + Runtime = Runtime.DOTNET_10, + Handler = "HeartbeatMonitor::HeartbeatMonitor.HeartbeatMonitorHandler::Handler", + Code = Code.FromAsset(publishOutputPath), + MemorySize = 256, + Timeout = Duration.Seconds(900), + DurableConfig = new DurableConfig + { + ExecutionTimeout = Duration.Days(365), + RetentionPeriod = Duration.Days(3) + }, + Environment = new Dictionary + { + ["DEVICE_TABLE_NAME"] = deviceTable.TableName, + ["ALERT_TOPIC_ARN"] = alertTopic.TopicArn + }, + Description = "Durable Function: IoT heartbeat monitor (eternal orchestration)" + }); + + deviceTable.GrantReadWriteData(monitorFunction); + alertTopic.GrantPublish(monitorFunction); + + // Allow the monitor to self-invoke for continue-as-new. + monitorFunction.AddToRolePolicy(new Amazon.CDK.AWS.IAM.PolicyStatement(new Amazon.CDK.AWS.IAM.PolicyStatementProps + { + Actions = ["lambda:InvokeFunction"], + Resources = [FormatArn(new ArnComponents + { + Service = "lambda", + Resource = "function", + ResourceName = "*", + ArnFormat = ArnFormat.COLON_RESOURCE_NAME + })] + })); + + // ────────────────────────────────────────────────────────────────── + // Lambda: Heartbeat Receiver — API for devices to send heartbeats + // ────────────────────────────────────────────────────────────────── + var receiverFunction = new Function(this, "HeartbeatReceiverFunction", new FunctionProps + { + Runtime = Runtime.DOTNET_10, + Handler = "HeartbeatMonitor::HeartbeatMonitor.HeartbeatReceiverHandler::Handler", + Code = Code.FromAsset(publishOutputPath), + MemorySize = 256, + Timeout = Duration.Seconds(30), + Environment = new Dictionary + { + ["DEVICE_TABLE_NAME"] = deviceTable.TableName + }, + Description = "Receives heartbeat signals from IoT devices" + }); + + deviceTable.GrantReadWriteData(receiverFunction); + + // ────────────────────────────────────────────────────────────────── + // API Gateway — REST API with API Key authentication + // Simplest device auth: devices include x-api-key header + // ────────────────────────────────────────────────────────────────── + var api = new RestApi(this, "HeartbeatApi", new RestApiProps + { + RestApiName = "IoT Heartbeat API", + Description = "API for IoT devices to send heartbeat signals", + ApiKeySourceType = ApiKeySourceType.HEADER + }); + + var heartbeatResource = api.Root.AddResource("heartbeat"); + heartbeatResource.AddMethod("POST", new LambdaIntegration(receiverFunction), new MethodOptions + { + ApiKeyRequired = true + }); + + // Create a usage plan and API key for device authentication + var usagePlan = api.AddUsagePlan("DeviceUsagePlan", new UsagePlanProps + { + Name = "DeviceHeartbeatPlan", + Description = "Usage plan for IoT device heartbeats", + Throttle = new ThrottleSettings + { + RateLimit = 100, + BurstLimit = 200 + } + }); + + usagePlan.AddApiStage(new UsagePlanPerApiStage + { + Api = api, + Stage = api.DeploymentStage + }); + + var apiKey = api.AddApiKey("DeviceApiKey", new ApiKeyOptions + { + ApiKeyName = "iot-device-heartbeat-key", + Description = "API key for IoT devices to authenticate heartbeat requests" + }); + + usagePlan.AddApiKey(apiKey); + + // ────────────────────────────────────────────────────────────────── + // Outputs + // ────────────────────────────────────────────────────────────────── + _ = new CfnOutput(this, "DeviceTableName", new CfnOutputProps + { + Value = deviceTable.TableName, + Description = "DynamoDB table for device registry" + }); + + _ = new CfnOutput(this, "AlertTopicArn", new CfnOutputProps + { + Value = alertTopic.TopicArn, + Description = "SNS topic for offline device alerts" + }); + + _ = new CfnOutput(this, "HeartbeatMonitorFunctionArn", new CfnOutputProps + { + Value = monitorFunction.FunctionArn, + Description = "ARN of the heartbeat monitor durable function" + }); + + _ = new CfnOutput(this, "HeartbeatApiUrl", new CfnOutputProps + { + Value = api.Url, + Description = "API Gateway URL for device heartbeats" + }); + + _ = new CfnOutput(this, "ApiKeyId", new CfnOutputProps + { + Value = apiKey.KeyId, + Description = "API Key ID (use 'aws apigateway get-api-key --api-key --include-value' to get the value)" + }); + } + + private static string PublishLambdaProject(string projectPath) + { + var publishDir = Path.Combine( + Path.GetDirectoryName(projectPath)!, + "bin", "Release", "net10.0", "publish"); + + var process = Process.Start(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"publish \"{projectPath}\" -c Release -r linux-x64 --self-contained false -o \"{publishDir}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }) ?? throw new InvalidOperationException("Failed to start dotnet publish process."); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"dotnet publish failed for '{projectPath}' (exit code {process.ExitCode}):\n{stderr}\n{stdout}"); + } + + return publishDir; + } +} diff --git a/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Program.cs b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Program.cs new file mode 100644 index 000000000..270dfa8f1 --- /dev/null +++ b/cdk-dotnet-durablefunction-iotheartbeat/src/Infra/Program.cs @@ -0,0 +1,20 @@ +using Amazon.CDK; + +namespace Infra; + +sealed class Program +{ + public static void Main() + { + var app = new App(); + new InfraStack(app, "CdkDotnetDurablefunctionIotheartbeatStack", new StackProps + { + Env = new Amazon.CDK.Environment + { + Account = System.Environment.GetEnvironmentVariable("CDK_DEFAULT_ACCOUNT"), + Region = System.Environment.GetEnvironmentVariable("CDK_DEFAULT_REGION"), + } + }); + app.Synth(); + } +}