Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
145 changes: 145 additions & 0 deletions .github/workflows/conformance-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Durable Execution Conformance Tests

# Full-integration conformance run for the .NET Durable Execution SDK: publishes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will configure all of this after this pr is merged. i ran the conformance test locally and it passes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think i may need to modify this slightly to work with the existing buildspec etc but ill do that in a follow up pr

# the .NET handlers, installs the language-agnostic runner from the
# aws-durable-execution-conformance-tests repo, then deploys + invokes +
# validates one SAM stack per suite. Suites are discovered from the
# template_<suite>.yaml files under the Conformance directory (see
# scripts/discover_suites.py) and each runs as its own parallel matrix job, so
# adding a suite only requires shipping its template + handlers.

on:
pull_request:
branches: [dev, master]
paths:
- "Libraries/src/Amazon.Lambda.DurableExecution/**"
- "Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Conformance/**"
- ".github/workflows/conformance-tests.yml"
workflow_dispatch:

concurrency:
group: ${{ github.head_ref || github.ref_name || github.run_id }}-conformance
cancel-in-progress: true

permissions:
contents: read
id-token: write # Required for AWS OIDC credentials

env:
CONFORMANCE_DIR: Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Conformance
RUNNER_PIP_SPEC: "git+https://github.com/aws/aws-durable-execution-conformance-tests.git@main#subdirectory=packages/aws-durable-execution-conformance-tests"

jobs:
discover_suites:
name: discover conformance suites
runs-on: ubuntu-latest
outputs:
suites: ${{ steps.discover.outputs.suites }}
steps:
- uses: actions/checkout@v4

- name: Discover suites from templates
id: discover
working-directory: ${{ env.CONFORMANCE_DIR }}
run: echo "suites=$(python3 scripts/discover_suites.py)" >> "$GITHUB_OUTPUT"

conformance:
name: conformance (${{ matrix.suite }})
needs: discover_suites
runs-on: ubuntu-latest
# Global lock per suite stack: runs from different branches/PRs share the
# persistent conformance-dotnet-<suite> stacks, so deploys to the same stack
# must never overlap. Queued (not cancelled) so every run still executes.
concurrency:
group: conformance-stack-${{ matrix.suite }}
cancel-in-progress: false
strategy:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.discover_suites.outputs.suites) }}
defaults:
run:
working-directory: Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Conformance
steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.14"

- name: Setup SAM CLI
uses: aws-actions/setup-sam@v2
with:
use-installer: true

- name: Publish conformance handlers
run: ./scripts/build_examples.sh ${{ matrix.suite }}

- name: Install conformance runner
run: pip install "${RUNNER_PIP_SPEC}"

- name: Get AWS Credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
with:
# SAM-capable deploy role (CloudFormation / S3 / IAM / Lambda / DynamoDB).
role-to-assume: ${{ secrets.CONFORMANCE_DEPLOY_ROLE_ARN }}
role-session-name: githubConformanceTest
aws-region: ${{ vars.CONFORMANCE_AWS_REGION || 'us-west-2' }}

- name: Inject Lambda execution role into template
env:
ROLE_ARN: ${{ secrets.CONFORMANCE_LAMBDA_EXECUTION_ROLE_ARN }}
run: |
if [ -z "$ROLE_ARN" ]; then
echo "CONFORMANCE_LAMBDA_EXECUTION_ROLE_ARN not set; template will create its own role."
exit 0
fi
# Point every function at the pre-existing execution role and drop the
# self-created DurableFunctionRole. Mutates only the CI checkout; the
# checked-in template stays self-contained for local runs.
python3 scripts/inject_execution_role.py \
--template template_${{ matrix.suite }}.yaml \
--role-arn "$ROLE_ARN"

- name: Compute stack-safe suite slug
run: |
# CloudFormation stack names allow only [a-zA-Z][-a-zA-Z0-9]*;
# suite names like wait_for_condition contain underscores.
echo "SUITE_SLUG=$(echo '${{ matrix.suite }}' | tr '_' '-')" >> "$GITHUB_ENV"

- name: Run conformance suite
# --fail-on failed+uncovered: the runner (and its test-requirements) are
# pinned to @main, so when upstream adds a new requirement to a suite it
# is pulled automatically. Without this, a new requirement with no .NET
# handler reports UNCOVERED and the run stays green — silently missing
# coverage. failed+uncovered turns that red so we notice and either add a
# handler or declare it under TestingMetadata.NotImplemented. Declared
# gaps report NOT_IMPLEMENTED, which never blocks.
run: |
python -m aws_durable_execution_conformance_tests.app \
--template template_${{ matrix.suite }}.yaml \
--language dotnet \
--suite ${{ matrix.suite }} \
--name conformance-dotnet-${SUITE_SLUG} \
--region ${{ vars.CONFORMANCE_AWS_REGION || 'us-west-2' }} \
--history-dir history-${{ matrix.suite }} \
--report junit \
--report-file report-${{ matrix.suite }} \
--fail-on failed+uncovered \
--no-cleanup

- name: Upload conformance report
if: always()
uses: actions/upload-artifact@v4
with:
name: conformance-report-${{ matrix.suite }}
path: |
${{ env.CONFORMANCE_DIR }}/report-${{ matrix.suite }}.xml
${{ env.CONFORMANCE_DIR }}/history-${{ matrix.suite }}/
if-no-files-found: warn
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
<None Remove="TestFunction\**" />
<Compile Remove="TestFunctions\**" />
<None Remove="TestFunctions\**" />
<!-- Conformance handler projects are standalone Lambda executables built via
scripts/build_examples.sh; keep their sources out of the test assembly. -->
<Compile Remove="Conformance\**" />
<None Remove="Conformance\**" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Published handler artifacts produced by scripts/build_examples.sh
publish/

# Conformance runner output
history-*/
report-*.xml
report-*.json

# SAM build/deploy scratch
.aws-sam/
samconfig.toml

# .NET build output
**/bin/
**/obj/
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Durable Execution Conformance Tests (.NET)

This directory wires the .NET Durable Execution SDK into the language-neutral
[`aws-durable-execution-conformance-tests`](https://github.com/aws/aws-durable-execution-conformance-tests)
runner. The runner is a Python tool that deploys a SAM template, invokes each
mapped Lambda, and validates the durable execution **result** and **event
history** against language-agnostic requirement specs.

## How it works

- Each requirement (e.g. `1-1`) has a YAML spec in the runner's
`test-requirements/<suite>/` directory describing the expected result and
execution history.
- For every requirement we implement, there is a small handler project under
`<suite>/<HandlerName>/` (executable model: `Main` + `LambdaBootstrap`,
`AssemblyName=bootstrap`). Each project references the in-repo SDK directly.
- `template_<suite>.yaml` maps each function to its requirement id(s) via
`TestingMetadata.TestDescription: ["1-1"]` and deploys it on the `dotnet8`
managed runtime.
- The runner reads `TestingMetadata`, deploys the template, invokes each
function (sync or async depending on the requirement), then asserts.

Handlers are published ahead of time into `publish/<HandlerName>/`; the SAM
template's `BuildMethod: makefile` copies the pre-built `bootstrap` into the
deploy artifact.

## Layout

```
Conformance/
├── README.md
├── template_step.yaml # one template per suite; functions -> requirement ids
├── scripts/
│ ├── build_examples.sh # dotnet publish each handler -> publish/<Fn>/
│ ├── discover_suites.py # emits the CI matrix (suites with template + handlers)
│ └── inject_execution_role.py# CI: point functions at a pre-existing role
└── step/ # one dir per suite; one subdir per handler
├── StepBasic/ # 1-1
├── StepWithName/ # 1-2
└── ... # 1-3 .. 1-20
```

## Coverage

All nine suites are implemented (one handler project per requirement id):

| Suite | Ids | Handlers |
|-------|-----|----------|
| `step` | 1-1 .. 1-20 | 20 |
| `wait` | 2-1 .. 2-5 | 5 |
| `child` | 3-1 .. 3-13, 3-15 .. 3-18 | 17 |
| `callback` | 4-1 .. 4-19 | 19 |
| `invoke` | 5-1 .. 5-15 | 15 (+2 target functions, +1 tenancy alias) |
| `wait_for_condition` | 6-1 .. 6-13 | 13 |
| `wait_for_callback` | 7-1 .. 7-15 | 15 |
| `parallel` | 8-1 .. 8-22 (8-15 n/a) | 21 |
| `map` | 9-1 .. 9-18 (9-14 n/a) | 17 |

A few requirement ids have no .NET handler because the SDK intentionally lacks
the feature they exercise (e.g. per-item / whole-result serdes slots in `map`);
those are documented in the relevant `template_<suite>.yaml` and reported as
`NOT_IMPLEMENTED` (non-blocking) rather than silently omitted.

### Handlers that need extra resources

- **Retry-across-invocation tests** (`step` 1-11/1-13/1-14/1-15/1-18, `child`
3-7/3-12) count attempts across separate invocations, which the replay model
cannot hold in memory, so they use the `AttemptsTable` DynamoDB table declared
in the template (`AWSSDK.DynamoDBv2`).
- **`invoke` targets** — the suite deploys two callee functions
(`InvokeEchoTarget`, `InvokeFailTarget`) that the workflow handlers invoke via
`AWSSDK.Lambda`; ARNs are wired through env vars with `Fn::GetAtt`. The
tenancy test (5-8) reuses the echo target's binary under a second logical id
(`InvokeEchoTargetTenant`, `PER_TENANT` isolation) — `build_examples.sh`
produces that publish dir by aliasing (there is no separate source project).

## Prerequisites

- .NET 8 SDK
- Python 3.14+ and the conformance runner:
```bash
pip install "git+https://github.com/aws/aws-durable-execution-conformance-tests.git@main#subdirectory=packages/aws-durable-execution-conformance-tests"
```
- [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
- AWS credentials for an account allowed to deploy + invoke (CloudFormation, IAM,
Lambda, DynamoDB). Prefix commands with `unset AWS_PROFILE` to use `[default]`.

## Running locally

From this directory (swap `step` for any suite name):

```bash
# 1. Publish the suite's handlers into publish/<Fn>/
# (omit the arg to publish every suite)
./scripts/build_examples.sh step

# 2. Deploy + invoke + validate the suite
unset AWS_PROFILE
python -m aws_durable_execution_conformance_tests.app \
--template template_step.yaml \
--language dotnet \
--suite step \
--name conformance-dotnet-step \
--region us-east-1 \
--history-dir history-step \
--report console
```

> On Windows, set `PYTHONUTF8=1` — the runner prints `✅`/`❌`, which crashes the
> summary printer under the default cp1252 console encoding.

The checked-in template is self-contained (it creates its own
`DurableFunctionRole`). CI instead injects a pre-existing execution role with
`scripts/inject_execution_role.py`.

## CI

`.github/workflows/conformance-tests.yml` runs one matrix job per discovered
suite: publish handlers → install the runner → assume the deploy role via OIDC →
inject the execution role → run the suite → upload the JUnit report. It requires
the repository secret `CONFORMANCE_DEPLOY_ROLE_ARN` (a SAM-capable deploy role;
provisioned by the `aws-dotnet-ci` CDK), and optionally
`CONFORMANCE_LAMBDA_EXECUTION_ROLE_ARN` (a pre-created Lambda execution role) and
the `CONFORMANCE_AWS_REGION` variable (defaults to `us-west-2`).

### Coverage gate (keeping up with upstream)

The runner and its `test-requirements/` are pinned to
[`aws-durable-execution-conformance-tests@main`](https://github.com/aws/aws-durable-execution-conformance-tests),
so **new upstream requirements are pulled automatically** on every run. CI runs
with `--fail-on failed+uncovered`, so a newly-added requirement that has no .NET
handler reports `UNCOVERED` and **turns the run red** — that's the signal to add
a handler (or declare it `NotImplemented`). Without that flag the default only
blocks on `FAILED`, and missing coverage would pass silently. Requirements
declared under `TestingMetadata.NotImplemented` report `NOT_IMPLEMENTED`, which
never blocks — so intentional SDK gaps stay green while genuinely-new
requirements fail loudly.

## Adding a suite

1. Add `<suite>/<HandlerName>/` handler projects (one per requirement).
2. Add `template_<suite>.yaml` mapping each function to its requirement id(s).
3. Declare any intentional gaps under a function's
`TestingMetadata.NotImplemented` (reported `NOT_IMPLEMENTED`, non-blocking).

`discover_suites.py` picks it up automatically, so it becomes a new CI matrix job.

## When CI goes red on a new upstream requirement

`--fail-on failed+uncovered` means an `UNCOVERED` requirement fails the run.
When that happens, for the reported id (e.g. a new `1-21`):

1. Read its spec in the runner's `test-requirements/<suite>/<id>.yaml`.
2. Either **add a handler** — a new `<suite>/<Name>/` project + a resource in
`template_<suite>.yaml` with `TestDescription: ["<id>"]` — or, if the .NET SDK
genuinely can't satisfy it, **declare it** under any function's
`TestingMetadata.NotImplemented` with a reason.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code was all approved already in the internal git farm repo by alex


<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<AssemblyName>bootstrap</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../../../../src/Amazon.Lambda.Core/Amazon.Lambda.Core.csproj" />
<ProjectReference Include="../../../../../src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj" />
<ProjectReference Include="../../../../../src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj" />
<ProjectReference Include="../../../../../src/Amazon.Lambda.Serialization.SystemTextJson/Amazon.Lambda.Serialization.SystemTextJson.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 4-9: CreateCallback then wait then await callback
using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;

namespace CallbackAfterWait;

public class Function
{
public static async Task Main(string[] args)
{
var handler = new Function();
var serializer = new DefaultLambdaJsonSerializer();
using var handlerWrapper = HandlerWrapper.GetHandlerWrapper<DurableExecutionInvocationInput, DurableExecutionInvocationOutput>(handler.Handler, serializer);
using var bootstrap = new LambdaBootstrap(handlerWrapper);
await bootstrap.RunAsync();
}

public Task<DurableExecutionInvocationOutput> Handler(
DurableExecutionInvocationInput input, ILambdaContext context)
=> DurableFunction.WrapAsync<string, string>(Workflow, input, context);

private async Task<string> Workflow(string input, IDurableContext context)
{
var callback = await context.CreateCallbackAsync<string>(name: input);
await context.WaitAsync(TimeSpan.FromSeconds(5));
var result = await callback.GetResultAsync();
return result;
}
}
Loading