From 5719ff98e3aa13d57ab11b5c76e9d322111d0196 Mon Sep 17 00:00:00 2001 From: Frank Chen <65260095+zhongkechen@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:30:49 -0600 Subject: [PATCH 1/2] feat: add plugin interface (#371) --- .../cli.py | 34 +- .../examples-catalog.json | 11 + .../src/plugin/execution_with_otel.py | 53 +++ .../template.yaml | 18 + .../test/plugin/test_otel_plugin.py | 24 + .../pyproject.toml | 2 + .../__init__.py | 16 + .../context_extractors.py | 40 ++ .../deterministic_id_generator.py | 109 +++++ .../plugin.py | 436 ++++++++++++++++++ .../tests/test_context_extractors.py | 66 +++ .../tests/test_deterministic_id_generator.py | 134 ++++++ .../tests/test_plugin.py | 225 +++++++++ .../plugin.py | 5 +- 14 files changed, 1162 insertions(+), 11 deletions(-) create mode 100644 packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py create mode 100644 packages/aws-durable-execution-sdk-python-examples/test/plugin/test_otel_plugin.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/context_extractors.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/plugin.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/tests/test_context_extractors.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py create mode 100644 packages/aws-durable-execution-sdk-python-otel/tests/test_plugin.py diff --git a/packages/aws-durable-execution-sdk-python-examples/cli.py b/packages/aws-durable-execution-sdk-python-examples/cli.py index 4cf75cf9..ff2e8394 100755 --- a/packages/aws-durable-execution-sdk-python-examples/cli.py +++ b/packages/aws-durable-execution-sdk-python-examples/cli.py @@ -5,6 +5,7 @@ import logging import os import shutil +import subprocess import sys import time import zipfile @@ -35,6 +36,7 @@ def build_examples(): build_dir = Path(__file__).parent / "build" src_dir = Path(__file__).parent / "src" + packages_dir = Path(__file__).parent.parent logger.info("Building examples...") @@ -57,15 +59,29 @@ def build_examples(): logger.exception("Failed to copy testing library") return False - # Copy SDK source from the main SDK package - testing_src = ( - Path(__file__).parent.parent - / "aws-durable-execution-sdk-python" - / "src" - / "aws_durable_execution_sdk_python" - ) - logger.info("Copying SDK from %s", testing_src) - shutil.copytree(testing_src, build_dir / "aws_durable_execution_sdk_python") + # Install local packages so their runtime dependencies are included in + # the Lambda deployment package. + runtime_packages = [ + packages_dir / "aws-durable-execution-sdk-python", + packages_dir / "aws-durable-execution-sdk-python-otel", + ] + try: + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "--target", + str(build_dir), + *[str(package) for package in runtime_packages], + ], + check=True, + ) + except subprocess.CalledProcessError: + logger.exception("Failed to install runtime dependencies") + return False # Copy example functions logger.info("Copying examples from %s", src_dir) diff --git a/packages/aws-durable-execution-sdk-python-examples/examples-catalog.json b/packages/aws-durable-execution-sdk-python-examples/examples-catalog.json index fe8ccfd7..d921bce8 100644 --- a/packages/aws-durable-execution-sdk-python-examples/examples-catalog.json +++ b/packages/aws-durable-execution-sdk-python-examples/examples-catalog.json @@ -613,6 +613,17 @@ "ExecutionTimeout": 300 }, "path": "./src/plugin/execution_with_plugin.py" + }, + { + "name": "Otel Plugin", + "description": "Test Otel plugin", + "handler": "execution_with_otel.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/plugin/execution_with_otel.py" } ] } diff --git a/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py b/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py new file mode 100644 index 00000000..6bceefff --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-examples/src/plugin/execution_with_otel.py @@ -0,0 +1,53 @@ +"""Demonstrates handler execution without any durable operations.""" + +from typing import Any + +from opentelemetry import trace + +from aws_durable_execution_sdk_python import StepContext +from aws_durable_execution_sdk_python.config import Duration, StepConfig, StepSemantics +from aws_durable_execution_sdk_python.context import ( + DurableContext, + durable_step, + durable_with_child_context, +) +from aws_durable_execution_sdk_python.execution import durable_execution + +from aws_durable_execution_sdk_python_otel import DurableExecutionOtelPlugin + + +# use default provider +tracer_provider = trace.get_tracer_provider() +otel = DurableExecutionOtelPlugin(tracer_provider) + + +@durable_step +def add_numbers(_step_context: StepContext, a: int, b: int) -> int: + return a + b + + +@durable_with_child_context +def add_numbers_in_child(child_context: DurableContext, a: int, b: int): + result: int = child_context.step( + add_numbers(a, b), + name=f"step-{b}", + ) + child_context.wait( + Duration.from_seconds(1), + name=f"wait-{b}", + ) + return result + + +@durable_execution(plugins=[otel]) +def handler(_event: Any, context: DurableContext) -> int: + result = 0 + for i in range(3): + result += context.run_in_child_context( + add_numbers_in_child(6, i), + name=f"context-{i}", + ) + return context.step( + add_numbers(result, 2), + name="final-step", + ) diff --git a/packages/aws-durable-execution-sdk-python-examples/template.yaml b/packages/aws-durable-execution-sdk-python-examples/template.yaml index bf91637f..d56b1af8 100644 --- a/packages/aws-durable-execution-sdk-python-examples/template.yaml +++ b/packages/aws-durable-execution-sdk-python-examples/template.yaml @@ -995,6 +995,24 @@ "ExecutionTimeout": 300 } } + }, + "ExecutionWithOtel": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "build/", + "Handler": "execution_with_otel.handler", + "Description": "Test Otel plugin", + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + } + } } } } \ No newline at end of file diff --git a/packages/aws-durable-execution-sdk-python-examples/test/plugin/test_otel_plugin.py b/packages/aws-durable-execution-sdk-python-examples/test/plugin/test_otel_plugin.py new file mode 100644 index 00000000..8599f599 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-examples/test/plugin/test_otel_plugin.py @@ -0,0 +1,24 @@ +"""Tests for step example.""" + +import pytest +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from src.plugin import execution_with_otel +from test.conftest import deserialize_operation_payload + + +@pytest.mark.example +@pytest.mark.durable_execution( + handler=execution_with_otel.handler, + lambda_function_name="Otel Plugin", +) +def test_plugin(durable_runner): + """Test basic step example.""" + with durable_runner: + result = durable_runner.run(input="{}", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert deserialize_operation_payload(result.result) == 23 + + step_result = result.get_step("final-step") + assert deserialize_operation_payload(step_result.result) == 23 diff --git a/packages/aws-durable-execution-sdk-python-otel/pyproject.toml b/packages/aws-durable-execution-sdk-python-otel/pyproject.toml index bd9a1231..72d64176 100644 --- a/packages/aws-durable-execution-sdk-python-otel/pyproject.toml +++ b/packages/aws-durable-execution-sdk-python-otel/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "aws-durable-execution-sdk-python>=1.5.0", "opentelemetry-api>=1.20.0", "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp", + "opentelemetry-propagator-aws-xray", ] [project.urls] diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py index 63b1b9cc..7ba31caa 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py @@ -1,8 +1,24 @@ """OpenTelemetry instrumentation for AWS Lambda Durable Executions Python SDK.""" from aws_durable_execution_sdk_python_otel.__about__ import __version__ +from aws_durable_execution_sdk_python_otel.context_extractors import ( + ContextExtractor, + w3c_client_context_extractor, + xray_context_extractor, +) +from aws_durable_execution_sdk_python_otel.deterministic_id_generator import ( + DeterministicIdGenerator, +) +from aws_durable_execution_sdk_python_otel.plugin import ( + DurableExecutionOtelPlugin, +) __all__ = [ "__version__", + "ContextExtractor", + "DeterministicIdGenerator", + "DurableExecutionOtelPlugin", + "w3c_client_context_extractor", + "xray_context_extractor", ] diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/context_extractors.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/context_extractors.py new file mode 100644 index 00000000..79029fe5 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/context_extractors.py @@ -0,0 +1,40 @@ +"""Context extractors for propagating trace context into durable executions.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Callable + +from opentelemetry import context as otel_context, propagate + + +if TYPE_CHECKING: + from opentelemetry.context import Context + + from aws_durable_execution_sdk_python.plugin import InvocationStartInfo + +ContextExtractor = Callable[["InvocationStartInfo"], "Context"] + + +def xray_context_extractor(info: "InvocationStartInfo") -> "Context": + """Read the X-Ray trace header from the _X_AMZN_TRACE_ID environment variable. + + The durable execution backend propagates the same Root trace ID to every + invocation, so all invocations share one traceId. + """ + trace_header = os.environ.get("_X_AMZN_TRACE_ID") + if not trace_header: + return otel_context.get_current() + return propagate.extract( + carrier={"X-Amzn-Trace-Id": trace_header}, + context=otel_context.get_current(), + ) + + +def w3c_client_context_extractor(info: "InvocationStartInfo") -> "Context": + """Read W3C traceparent from context.clientContext.custom.traceparent. + + Requires the backend clientContext propagation to be enabled. + This extractor is a placeholder for when backend propagation is supported. + """ + return otel_context.get_current() diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py new file mode 100644 index 00000000..14753bec --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py @@ -0,0 +1,109 @@ +"""Deterministic ID generator for OpenTelemetry spans in durable executions.""" + +from __future__ import annotations + +import hashlib +import os +import re +from datetime import datetime, UTC + +from opentelemetry.sdk.trace import IdGenerator, RandomIdGenerator + +HASH_LENGTH = 16 +HASHED_ID_PATTERN = re.compile(r"^[0-9a-f]{16}$") + + +def _parse_xray_root_trace_id(trace_header: str | None) -> str | None: + """Parse the Root trace ID from an X-Ray trace header string. + + The header format is: + Root=1-<8 hex>-<24 hex>;Parent=<16 hex>;Sampled=0|1 + + Returns the root value (e.g. "1-5759e988-bd862e3fe1be46a994272793") + or None if the header is missing or malformed. + """ + if not trace_header: + return None + match = re.search(r"Root=(1-[0-9a-fA-F]{8}-[0-9a-fA-F]{24})", trace_header) + return match.group(1) if match else None + + +def _xray_trace_id_to_otel(xray_trace_id: str) -> int: + """Convert an X-Ray trace ID to the W3C/OpenTelemetry 32-char hex format. + + X-Ray format: "1-<8hex>-<24hex>" (36 chars with prefix and dashes) + OTel format: "<8hex><24hex>" (32 lowercase hex chars) + """ + otel_id = xray_trace_id.replace("1-", "", 1).replace("-", "").lower() + return int(otel_id, 16) + + +def _to_otel_trace_id(execution_arn: str, start_timestamp: datetime | None) -> int: + """Build an OTel-compatible trace ID (128 bits) + + First attempts to read the trace ID from the _X_AMZN_TRACE_ID environment + variable that Lambda populates on each invocation. This ties the durable + execution spans to the same trace that X-Ray is already tracking. + + Falls back to generating a deterministic trace ID from the execution ARN + and timestamp when the environment variable is not set (e.g. in tests or + non-Lambda environments). + """ + env_trace_id = _parse_xray_root_trace_id(os.environ.get("_X_AMZN_TRACE_ID")) + if env_trace_id: + return _xray_trace_id_to_otel(env_trace_id) + + # Fallback: deterministic ID from execution ARN + timestamp + time_part = format(int((start_timestamp or datetime.now(UTC)).timestamp()), "08x") + hash_part = hashlib.blake2b(execution_arn.encode()).hexdigest()[:24] # noqa: S324 + return int(f"{time_part}{hash_part}", 16) + + +def operation_id_to_span_id(operation_id: str) -> int: + """Derive a deterministic span ID (64 bits) from an operation ID.""" + hashed_operation_id = hashlib.blake2b(operation_id.encode()).hexdigest()[:16] + return int(hashed_operation_id, 16) + + +class DeterministicIdGenerator(IdGenerator): + """An ID generator that produces deterministic span IDs when a pending + operation ID is set, and random IDs otherwise. + + Trace IDs are deterministic when an execution ARN is set, ensuring all + invocations of the same durable execution share a single trace. + + Trace IDs embed a real timestamp so they satisfy the X-Ray format + requirement (first 8 hex chars = Unix epoch seconds). + """ + + def __init__(self) -> None: + self._next_span_id: int | None = None + self._execution_trace_id: int | None = None + self._random_id_generator = RandomIdGenerator() + + def set_next_span_id(self, span_id: int | None) -> None: + """Set the operation ID to use for the next span's ID. + + After one span is created, it resets to random. + """ + self._next_span_id = span_id + + def set_trace_id( + self, execution_arn: str, start_timestamp: datetime | None + ) -> None: + """Compute and cache the deterministic trace ID for this execution. + + Args: + execution_arn: The durable execution ARN (used for the hash portion). + start_timestamp: start time of invocation + """ + self._execution_trace_id = _to_otel_trace_id(execution_arn, start_timestamp) + + def generate_trace_id(self) -> int: + """Generate a 128-bit trace ID.""" + return self._execution_trace_id or self._random_id_generator.generate_trace_id() + + def generate_span_id(self) -> int: + """Generate a 64-bit span ID.""" + span_id, self._next_span_id = self._next_span_id, None + return span_id or self._random_id_generator.generate_span_id() diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/plugin.py new file mode 100644 index 00000000..a2dda254 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/plugin.py @@ -0,0 +1,436 @@ +"""OpenTelemetry instrumentation plugin for AWS Durable Execution SDK.""" + +from __future__ import annotations + +import datetime +import logging +import threading +from typing import TYPE_CHECKING, Any + +from opentelemetry import trace, context +from opentelemetry.context import Context +from opentelemetry.sdk.trace.sampling import TraceIdRatioBased +from opentelemetry.trace import ( + Tracer, + StatusCode, + SpanContext, + Span, + TracerProvider, + Link, + TraceFlags, +) + +from aws_durable_execution_sdk_python.lambda_service import OperationType +from aws_durable_execution_sdk_python.plugin import ( + DurableInstrumentationPlugin, + InvocationEndInfo, + InvocationStartInfo, + OperationEndInfo, + OperationStartInfo, + UserFunctionStartInfo, + UserFunctionEndInfo, + UserFunctionOutcome, +) +from aws_durable_execution_sdk_python_otel.context_extractors import ( + ContextExtractor, + xray_context_extractor, +) +from aws_durable_execution_sdk_python_otel.deterministic_id_generator import ( + DeterministicIdGenerator, + operation_id_to_span_id, +) + +if TYPE_CHECKING: + pass + + +logger = logging.getLogger(__name__) + + +def _to_otel_timestamp(dt: datetime.datetime | None) -> int | None: + """Convert a datetime to OTel timestamp (nanoseconds since epoch), or None.""" + if dt is None: + dt = datetime.datetime.now(datetime.UTC) + return int(dt.timestamp() * 1_000_000_000) + + +class DurableExecutionOtelPlugin(DurableInstrumentationPlugin): + """OpenTelemetry instrumentation plugin for durable executions. + + The plugin creates spans for Lambda invocations, durable operations, and + user-function attempts. Trace IDs are derived from the durable execution ARN + and execution start time so each replay or resumed invocation contributes to + the same trace. + + Operation IDs are converted into deterministic span IDs. The first observed + span for an operation uses that deterministic ID; later continuation spans + use newly generated span IDs and link back to the deterministic span ID so + trace viewers can relate retries and replay-created terminal spans to the + original logical operation. + + Args: + trace_provider: OpenTelemetry tracer provider used to create spans. + context_extractor: Optional extractor for upstream context. Defaults to + AWS X-Ray header extraction. + sampling_rate: Ratio used by ``TraceIdRatioBased`` sampling. + instrument_name: Instrumentation scope name registered with the tracer. + """ + + DEFAULT_INSTRUMENT_NAME = "aws-durable-execution-sdk-python" + + def __init__( + self, + trace_provider: TracerProvider, + context_extractor: ContextExtractor | None = None, + sampling_rate: float = 1.0, + instrument_name: str = DEFAULT_INSTRUMENT_NAME, + ) -> None: + """Initialize the plugin with an OpenTelemetry tracer provider. + + The provided tracer provider is configured with this plugin's + deterministic ID generator and sampling strategy so spans for a durable + execution share stable trace and logical operation identifiers. + """ + self._context_extractor: ContextExtractor = ( + context_extractor or xray_context_extractor + ) + + self._id_generator: DeterministicIdGenerator = DeterministicIdGenerator() + + self._provider = trace_provider + self._provider.id_generator = self._id_generator + self._provider.sampler = TraceIdRatioBased(sampling_rate) + self._tracer: Tracer = self._provider.get_tracer(instrument_name) + + # per invocation status: + self._execution_arn = "" + self._extracted_context: Context | None = None + # Maps operation ID (None for root) to the active span. + self._operation_spans: dict[str | None, Span] = {} + self._operation_spans_lock = threading.RLock() + + def _set_span(self, operation_id: str | None, span: Span) -> None: + """Register the active span for an operation ID.""" + with self._operation_spans_lock: + self._operation_spans[operation_id] = span + + def _delete_span(self, operation_id: str | None) -> None: + """Remove the active span for an operation ID if one is stored.""" + with self._operation_spans_lock: + self._operation_spans.pop(operation_id, None) + + def _get_span(self, operation_id: str | None) -> Span | None: + """Return the active span for an operation ID, if present.""" + with self._operation_spans_lock: + return self._operation_spans.get(operation_id) + + # ------------------------------------------------------------------ + # Context resolution + # ------------------------------------------------------------------ + def _resolve_parent_span(self, parent_id: str | None = None) -> Span: + """Resolve the active parent span for a durable operation. + + ``parent_id`` is ``None`` for root-level durable operations beneath the + invocation span. For child operations, the parent operation must already + have an active span in the current invocation. + + Raises: + ValueError: If the requested parent span is not active. + """ + + # Check if we already have a context for this parent + existing_span = self._get_span(parent_id) + if existing_span is not None: + return existing_span + + raise ValueError("No parent span found") + + def _start_span( + self, + operation_id: str | None, + name: str, + attributes: dict[str, str], + start_time: datetime.datetime | None = None, + parent_span: Span | None = None, + existed: bool = False, + ) -> Span: + """Start and store a span for an invocation or durable operation. + + Args: + operation_id: Durable operation ID. ``None`` is used for the root + invocation span. + name: Span display name. + attributes: Span attributes. + start_time: Optional durable start timestamp. + parent_span: Active parent span. When omitted, the extracted + upstream context is used as the parent. + existed: Whether the logical operation already had a previous span. + Continuation spans link back to the deterministic span ID for + the operation while using a fresh generated span ID. + + Returns: + The started OpenTelemetry span. + """ + logger.info( + "starting a span: operation_id=%s, name=%s, parent_span=%s", + operation_id, + name, + parent_span, + ) + with self._operation_spans_lock: + if existed: + if not operation_id: + raise ValueError("operation id is required") + span_id = operation_id_to_span_id(operation_id) + links = [ + Link( + context=SpanContext( + trace_id=self._id_generator.generate_trace_id(), + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + ] + self._id_generator.set_next_span_id(None) + else: + links = [] + + self._id_generator.set_next_span_id( + operation_id_to_span_id(operation_id) if operation_id else None + ) + if parent_span is None: + # root span + parent_context = self._extracted_context + else: + parent_context = trace.set_span_in_context( + parent_span, self._extracted_context + ) + span = self._tracer.start_span( + name=name, + attributes=attributes, + start_time=_to_otel_timestamp(start_time), + context=parent_context, + links=links, + ) + self._operation_spans[operation_id] = span + + logger.info("started a span: %s", span) + return span + + def _end_span( + self, operation_id: str | None, end_timestamp: datetime.datetime | None = None + ): + """End and unregister the active span for an operation ID. + + Args: + operation_id: Durable operation ID, or ``None`` for the invocation + span. + end_timestamp: Optional durable end timestamp to use as the span end + time. When omitted, OpenTelemetry uses the current time. + """ + logger.info("ending a span for operation: %s", operation_id) + with self._operation_spans_lock: + span = self._operation_spans.pop(operation_id, None) + if span: + # the span is not going to be populated if it has the same end_time and start_time + end_time = _to_otel_timestamp(end_timestamp) if end_timestamp else None + span.end(end_time=end_time) + logger.info("ended otel span: %s", span) + + # ------------------------------------------------------------------ + # Plugin lifecycle callbacks + # ------------------------------------------------------------------ + def on_invocation_start(self, info: InvocationStartInfo) -> None: + """Called at the start of each invocation. Creates the invocation span.""" + logger.info("Invocation started: %s", info) + self._execution_arn = info.execution_arn or "" + self._extracted_context = self._context_extractor(info) + self._id_generator.set_trace_id(self._execution_arn, info.start_time) + + self._start_span( + operation_id=None, + name=f"invocation", + attributes=self._extract_attributes(info), + ) + + def on_invocation_end(self, info: InvocationEndInfo) -> None: + """Called at the end of each invocation. Ends the invocation span and flushes.""" + logger.info(f"Invocation ended: {info}") + end_time = info.end_time + # end all pending spans + with self._operation_spans_lock: + operation_ids = list(self._operation_spans.keys()) + for operation_id in operation_ids: + if operation_id: + self._end_span(operation_id, end_time) + + # end the invocation span + self._end_span(None, end_time) + + # Clear all per-invocation state to prevent leaks across warm Lambda reuses + self._execution_arn = "" + self._extracted_context = None + with self._operation_spans_lock: + self._operation_spans = {} + + # Flush before Lambda freeze + if hasattr(self._provider, "force_flush"): + self._provider.force_flush() + + def on_operation_start(self, info: OperationStartInfo) -> None: + """Called when an operation begins. Creates a span for the operation.""" + logger.info(f"Operation started: {info}") + if info.operation_type in [OperationType.CONTEXT, OperationType.STEP]: + # Context and Step operations are tracked using on_user_function_start + return + parent_span = self._resolve_parent_span(info.parent_id) + attributes = self._extract_attributes(info) + + self._start_span( + operation_id=info.operation_id, + name=info.name or info.operation_id, + attributes=attributes, + start_time=info.start_time, + parent_span=parent_span, + ) + + def on_operation_end(self, info: OperationEndInfo) -> None: + """Called when an operation reaches a terminal durable status. + + Non-user-function operations are started by ``on_operation_start``. If + an operation end is observed without a matching in-memory span, this + invocation is completing an operation that began earlier, so a short + continuation span is created and linked to the deterministic logical + operation span before being ended. + """ + logger.info(f"Operation ended: {info}") + if info.operation_type in [OperationType.CONTEXT, OperationType.STEP]: + # Context and Step operations are tracked using on_user_function_end + return + span = self._get_span(info.operation_id) + if not span: + # the span was not started in the current invocation, so we need to + # create a new one that links to the previous one + parent_span = self._resolve_parent_span(info.parent_id) + attributes = self._extract_attributes(info) + span = self._start_span( + operation_id=info.operation_id, + name=info.name or info.operation_id, + attributes=attributes, + start_time=datetime.datetime.now(datetime.UTC), + parent_span=parent_span, + existed=True, + ) + + if info.error: + span.set_status(StatusCode.ERROR, info.error.message or "") + span.record_exception( + Exception(info.error.message or info.error.type or "Unknown error") + ) + else: + span.set_status(StatusCode.OK) + + end_timestamp = info.end_time + if end_timestamp is not None and end_timestamp == info.start_time: + end_timestamp += datetime.timedelta(microseconds=1) + self._end_span(info.operation_id, end_timestamp) + + def on_user_function_start(self, info: UserFunctionStartInfo) -> None: + """Called when a context or step operation starts user code. + + This callback runs inside the thread that executes user code so the + started span can be attached to the OpenTelemetry context for any + instrumentation used by that code. Attempts after the first are emitted + as continuation spans linked to the logical operation span. + + Args: + info: Information about the operation attempt. + """ + logger.info("User function started: %s", info) + # Context and Step operations are tracked using on_user_function_start + if info.operation_type not in [OperationType.CONTEXT, OperationType.STEP]: + raise RuntimeError( + "on_user_function_start should only be called for CONTEXT and STEP operations" + ) + parent_span = self._resolve_parent_span(info.parent_id) + attributes = self._extract_attributes(info) + span = self._start_span( + operation_id=info.operation_id, + name=info.name or info.operation_id, + attributes=attributes, + start_time=info.start_time, + parent_span=parent_span, + existed=info.attempt != 1, + ) + context.attach(trace.set_span_in_context(span, self._extracted_context)) + + def on_user_function_end(self, info: UserFunctionEndInfo) -> None: + """Called when a context or step operation finishes user code. + + This callback records the final attempt status, captures exceptions for + failed attempts, and ends the span that was attached in + ``on_user_function_start``. + + Args: + info: Information about the operation attempt. + """ + logger.info("User function ended: %s", info) + if info.operation_type not in [OperationType.CONTEXT, OperationType.STEP]: + raise RuntimeError( + "on_user_function_end should only be called for CONTEXT and STEP operations" + ) + # key = f"{info.operation_id}-{int(info.start_time.timestamp())}" + span = self._get_span(info.operation_id) + if not span: + raise RuntimeError( + "on_user_function_end called without matching on_user_function_start" + ) + + span.set_attributes(self._extract_attributes(info)) + if info.outcome is UserFunctionOutcome.FAILED: + span.set_status(StatusCode.ERROR, info.error.message if info.error else "") + span.record_exception( + Exception( + (info.error.message or info.error.type) + if info.error + else "Unknown error" + ) + ) + elif info.outcome is UserFunctionOutcome.SUCCEEDED: + span.set_status(StatusCode.OK) + else: + # PENDING + span.set_status(StatusCode.UNSET, "PENDING") + + end_timestamp = info.end_time + if end_timestamp is not None and end_timestamp == info.start_time: + end_timestamp += datetime.timedelta(microseconds=1) + self._end_span(info.operation_id, end_timestamp) + # We don't call context.detach because the next operation will override it anyway + + def _extract_attributes(self, info: Any) -> dict[str, str]: + """Extract durable execution fields as OpenTelemetry span attributes. + + Args: + info: Invocation, operation, or user-function callback payload. + + Returns: + A dictionary of durable execution attributes suitable for a span. + """ + attributes: dict[str, str] = { + "durable.execution.arn": self._execution_arn, + } + + if hasattr(info, "operation_id") and info.operation_id is not None: + attributes["durable.operation.id"] = info.operation_id + if hasattr(info, "operation_type") and info.operation_type is not None: + attributes["durable.operation.type"] = info.operation_type.value + if hasattr(info, "name") and info.name is not None: + attributes["durable.operation.name"] = info.name + if hasattr(info, "attempt") and info.attempt is not None: + attributes["durable.attempt.number"] = info.attempt + if hasattr(info, "outcome") and info.outcome is not None: + attributes["durable.attempt.outcome"] = info.outcome.value + + return attributes diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_context_extractors.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_context_extractors.py new file mode 100644 index 00000000..d150af92 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_context_extractors.py @@ -0,0 +1,66 @@ +"""Tests for trace context extraction helpers.""" + +from __future__ import annotations + +from opentelemetry.context import Context + +from aws_durable_execution_sdk_python_otel import context_extractors + + +def test_xray_context_extractor_returns_current_context_without_trace_header( + monkeypatch, +): + """Verify absent X-Ray trace headers leave the active context unchanged.""" + current_context = Context({"durable": "current"}) + monkeypatch.delenv("_X_AMZN_TRACE_ID", raising=False) + monkeypatch.setattr( + context_extractors.otel_context, + "get_current", + lambda: current_context, + ) + + assert context_extractors.xray_context_extractor(object()) is current_context + + +def test_xray_context_extractor_extracts_trace_header_from_environment( + monkeypatch, +): + """Verify X-Ray trace headers are passed through OpenTelemetry propagation.""" + trace_header = ( + "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1" + ) + current_context = Context({"durable": "current"}) + extracted_context = Context({"durable": "extracted"}) + extract_calls = [] + monkeypatch.setenv("_X_AMZN_TRACE_ID", trace_header) + monkeypatch.setattr( + context_extractors.otel_context, + "get_current", + lambda: current_context, + ) + + def extract(*, carrier, context): + extract_calls.append({"carrier": carrier, "context": context}) + return extracted_context + + monkeypatch.setattr(context_extractors.propagate, "extract", extract) + + assert context_extractors.xray_context_extractor(object()) is extracted_context + assert extract_calls == [ + { + "carrier": {"X-Amzn-Trace-Id": trace_header}, + "context": current_context, + } + ] + + +def test_w3c_client_context_extractor_returns_current_context(monkeypatch): + """Verify the placeholder W3C extractor leaves the active context unchanged.""" + current_context = Context({"durable": "current"}) + monkeypatch.setattr( + context_extractors.otel_context, + "get_current", + lambda: current_context, + ) + + assert context_extractors.w3c_client_context_extractor(object()) is current_context diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py new file mode 100644 index 00000000..3f4e53f7 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py @@ -0,0 +1,134 @@ +"""Tests for deterministic OpenTelemetry ID generation.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from aws_durable_execution_sdk_python_otel.deterministic_id_generator import ( + HASHED_ID_PATTERN, + DeterministicIdGenerator, + _parse_xray_root_trace_id, + _to_otel_trace_id, + _xray_trace_id_to_otel, + operation_id_to_span_id, +) + + +def test_parse_xray_root_trace_id_returns_root_from_header(): + """Verify X-Ray Root trace ID parsing ignores other header fields.""" + trace_header = ( + "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1" + ) + + assert ( + _parse_xray_root_trace_id(trace_header) == "1-5759e988-bd862e3fe1be46a994272793" + ) + + +def test_parse_xray_root_trace_id_returns_none_for_missing_or_malformed_header(): + """Verify absent or malformed X-Ray headers are ignored.""" + assert _parse_xray_root_trace_id(None) is None + assert _parse_xray_root_trace_id("") is None + assert _parse_xray_root_trace_id("Parent=53995c3f42cd8ad8;Sampled=1") is None + assert ( + _parse_xray_root_trace_id( + "Root=1-5759e988-not-enough-hex;Parent=53995c3f42cd8ad8" + ) + is None + ) + + +def test_xray_trace_id_to_otel_removes_xray_prefix_and_normalizes_case(): + """Verify X-Ray trace IDs are converted into OTel-compatible integers.""" + trace_id = "1-5759E988-BD862E3FE1BE46A994272793" + + assert _xray_trace_id_to_otel(trace_id) == int( + "5759e988bd862e3fe1be46a994272793", 16 + ) + + +def test_to_otel_trace_id_uses_xray_root_header_when_available(monkeypatch): + """Verify Lambda's X-Ray trace header takes precedence over fallback IDs.""" + monkeypatch.setenv( + "_X_AMZN_TRACE_ID", + "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1", + ) + start_timestamp = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) + + assert _to_otel_trace_id("different-execution-arn", start_timestamp) == int( + "5759e988bd862e3fe1be46a994272793", 16 + ) + + +def test_to_otel_trace_id_falls_back_to_timestamp_and_execution_arn(monkeypatch): + """Verify fallback trace IDs are deterministic for the same execution.""" + monkeypatch.delenv("_X_AMZN_TRACE_ID", raising=False) + execution_arn = "arn:aws:lambda:us-west-2:123456789012:function:workflow:$LATEST" + start_timestamp = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) + + assert _to_otel_trace_id(execution_arn, start_timestamp) == int( + "65937d253aa8c3f7ffe36c50d65b1a6d", 16 + ) + + +def test_operation_id_to_span_id_returns_deterministic_64_bit_id(): + """Verify operation IDs map to stable 64-bit span IDs.""" + assert operation_id_to_span_id("my-operation") == int("ab1f94a6d3c668f3", 16) + + +def test_deterministic_id_generator_returns_cached_trace_id(monkeypatch): + """Verify trace IDs are cached after being set for an execution.""" + monkeypatch.delenv("_X_AMZN_TRACE_ID", raising=False) + generator = DeterministicIdGenerator() + + generator.set_trace_id( + "arn:aws:lambda:us-west-2:123456789012:function:workflow:$LATEST", + datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC), + ) + + assert generator.generate_trace_id() == int("65937d253aa8c3f7ffe36c50d65b1a6d", 16) + + +def test_deterministic_id_generator_falls_back_to_random_trace_id(monkeypatch): + """Verify trace IDs are random until an execution trace ID is set.""" + expected_trace_id = int("1" * 32, 16) + generator = DeterministicIdGenerator() + monkeypatch.setattr( + generator._random_id_generator, + "generate_trace_id", + lambda: expected_trace_id, + ) + + assert generator.generate_trace_id() == expected_trace_id + + +def test_deterministic_id_generator_uses_next_span_id_once(monkeypatch): + """Verify a configured span ID only applies to the next generated span.""" + deterministic_span_id = int("2" * 16, 16) + random_span_id = int("3" * 16, 16) + generator = DeterministicIdGenerator() + monkeypatch.setattr( + generator._random_id_generator, + "generate_span_id", + lambda: random_span_id, + ) + + generator.set_next_span_id(deterministic_span_id) + + assert generator.generate_span_id() == deterministic_span_id + assert generator.generate_span_id() == random_span_id + + +def test_deterministic_id_generator_accepts_cleared_next_span_id(monkeypatch): + """Verify clearing the next span ID preserves random span generation.""" + expected_span_id = int("4" * 16, 16) + generator = DeterministicIdGenerator() + monkeypatch.setattr( + generator._random_id_generator, + "generate_span_id", + lambda: expected_span_id, + ) + + generator.set_next_span_id(None) + + assert generator.generate_span_id() == expected_span_id diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_plugin.py new file mode 100644 index 00000000..5fb8a430 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_plugin.py @@ -0,0 +1,225 @@ +"""Tests for the OpenTelemetry durable execution plugin.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime + +from opentelemetry.context import Context +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from aws_durable_execution_sdk_python.lambda_service import ( + InvocationStatus, + OperationStatus, + OperationType, +) +from aws_durable_execution_sdk_python.plugin import ( + InvocationEndInfo, + InvocationStartInfo, + OperationEndInfo, + OperationStartInfo, + UserFunctionEndInfo, + UserFunctionOutcome, + UserFunctionStartInfo, +) +from aws_durable_execution_sdk_python_otel.deterministic_id_generator import ( + operation_id_to_span_id, +) +from aws_durable_execution_sdk_python_otel.plugin import DurableExecutionOtelPlugin + + +START_TIME = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) +END_TIME = datetime(2024, 1, 2, 3, 4, 6, tzinfo=UTC) +EXECUTION_ARN = "arn:aws:lambda:us-west-2:123456789012:function:workflow:$LATEST" + + +def _create_plugin() -> tuple[DurableExecutionOtelPlugin, InMemorySpanExporter]: + """Create a plugin wired to an in-memory span exporter.""" + exporter = InMemorySpanExporter() + trace_provider = TracerProvider() + trace_provider.add_span_processor(SimpleSpanProcessor(exporter)) + plugin = DurableExecutionOtelPlugin( + trace_provider=trace_provider, + context_extractor=lambda _: Context(), + ) + return plugin, exporter + + +def _invocation_start_info() -> InvocationStartInfo: + """Create standard invocation start info for tests.""" + return InvocationStartInfo( + request_id="request-1", + execution_arn=EXECUTION_ARN, + start_time=START_TIME, + is_first_invocation=True, + ) + + +def _invocation_end_info() -> InvocationEndInfo: + """Create standard invocation end info for tests.""" + return InvocationEndInfo( + request_id="request-1", + execution_arn=EXECUTION_ARN, + start_time=START_TIME, + is_first_invocation=True, + status=InvocationStatus.SUCCEEDED, + end_time=END_TIME, + error=None, + ) + + +def test_invocation_start_and_end_emit_invocation_span(): + """Verify invocation lifecycle callbacks create and finish the root span.""" + plugin, exporter = _create_plugin() + + plugin.on_invocation_start(_invocation_start_info()) + assert plugin._get_span(None) is not None + + plugin.on_invocation_end(_invocation_end_info()) + + spans = exporter.get_finished_spans() + assert [span.name for span in spans] == ["invocation"] + assert spans[0].attributes["durable.execution.arn"] == EXECUTION_ARN + assert plugin._get_span(None) is None + + +def test_operation_callbacks_emit_child_span_with_deterministic_span_id(): + """Verify non-user-function operations are traced beneath the invocation.""" + plugin, exporter = _create_plugin() + plugin.on_invocation_start(_invocation_start_info()) + operation_id = "wait-1" + + plugin.on_operation_start( + OperationStartInfo( + operation_id=operation_id, + operation_type=OperationType.WAIT, + sub_type=None, + name="wait-for-signal", + parent_id=None, + start_time=START_TIME, + ) + ) + plugin.on_operation_end( + OperationEndInfo( + operation_id=operation_id, + operation_type=OperationType.WAIT, + sub_type=None, + name="wait-for-signal", + parent_id=None, + start_time=START_TIME, + status=OperationStatus.SUCCEEDED, + end_time=END_TIME, + error=None, + ) + ) + plugin.on_invocation_end(_invocation_end_info()) + + spans_by_name = {span.name: span for span in exporter.get_finished_spans()} + wait_span = spans_by_name["wait-for-signal"] + invocation_span = spans_by_name["invocation"] + assert wait_span.context.span_id == operation_id_to_span_id(operation_id) + assert wait_span.parent.span_id == invocation_span.context.span_id + assert wait_span.attributes["durable.operation.id"] == operation_id + assert wait_span.attributes["durable.operation.type"] == OperationType.WAIT.value + + +def test_operation_end_without_start_emits_continuation_span_with_link(): + """Verify completed existing operations link to their logical operation span.""" + plugin, exporter = _create_plugin() + plugin.on_invocation_start(_invocation_start_info()) + operation_id = "wait-existing" + random_span_id = int("1234567890abcdef", 16) + plugin._id_generator._random_id_generator.generate_span_id = lambda: random_span_id + + plugin.on_operation_end( + OperationEndInfo( + operation_id=operation_id, + operation_type=OperationType.WAIT, + sub_type=None, + name="existing-wait", + parent_id=None, + start_time=START_TIME, + status=OperationStatus.SUCCEEDED, + end_time=END_TIME, + error=None, + ) + ) + + span = exporter.get_finished_spans()[0] + assert span.name == "existing-wait" + assert span.context.span_id == random_span_id + assert span.links[0].context.span_id == operation_id_to_span_id(operation_id) + + +def test_user_function_callbacks_emit_attempt_span_attributes(): + """Verify user-function end refreshes all extractable span attributes.""" + plugin, exporter = _create_plugin() + plugin.on_invocation_start(_invocation_start_info()) + operation_id = "step-1" + + start_info = UserFunctionStartInfo( + operation_id=operation_id, + operation_type=OperationType.STEP, + sub_type=None, + name="fetch-user", + parent_id=None, + start_time=START_TIME, + is_replay_children=False, + attempt=1, + ) + plugin.on_user_function_start(start_info) + active_span = plugin._get_span(operation_id) + assert active_span is not None + active_span.set_attributes( + { + "durable.operation.name": "stale-name", + "durable.attempt.number": 99, + } + ) + plugin.on_user_function_end( + UserFunctionEndInfo( + operation_id=operation_id, + operation_type=OperationType.STEP, + sub_type=None, + name="fetch-user", + parent_id=None, + start_time=START_TIME, + is_replay_children=False, + attempt=1, + outcome=UserFunctionOutcome.SUCCEEDED, + end_time=END_TIME, + error=None, + ) + ) + + span = exporter.get_finished_spans()[0] + assert span.name == "fetch-user" + assert span.context.span_id == operation_id_to_span_id(operation_id) + assert span.attributes["durable.execution.arn"] == EXECUTION_ARN + assert span.attributes["durable.operation.id"] == operation_id + assert span.attributes["durable.operation.type"] == OperationType.STEP.value + assert span.attributes["durable.operation.name"] == "fetch-user" + assert span.attributes["durable.attempt.number"] == 1 + assert ( + span.attributes["durable.attempt.outcome"] + == UserFunctionOutcome.SUCCEEDED.value + ) + + +def test_span_registry_helpers_can_be_called_from_multiple_threads(): + """Verify active span registry helpers are safe under concurrent access.""" + plugin, _ = _create_plugin() + + def update_span(index: int) -> None: + operation_id = f"operation-{index}" + plugin._set_span(operation_id, object()) # type: ignore[arg-type] + assert plugin._get_span(operation_id) is not None + plugin._delete_span(operation_id) + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(update_span, range(100))) + + with plugin._operation_spans_lock: + assert plugin._operation_spans == {} diff --git a/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin.py b/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin.py index 1a7ecd27..0deff94b 100644 --- a/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin.py +++ b/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin.py @@ -318,6 +318,7 @@ def on_operation_action(self, update: OperationUpdate): Args: update: the operation update that is checkpointed """ + # todo: this could be called more than once for step when it's retried if update.action is OperationAction.START: # we handle only START action here because on_operation_update may not be able to see a STARTED update # when START is checkpointed in batch with terminal status updates. @@ -330,7 +331,7 @@ def on_operation_action(self, update: OperationUpdate): parent_id=update.parent_id, start_time=datetime.datetime.now(datetime.UTC), ), - sync=False, + sync=True, ) def on_operation_update(self, operation: Operation | None): @@ -357,7 +358,7 @@ def on_operation_update(self, operation: Operation | None): status=operation.status, error=self._extract_error(operation), ), - sync=False, + sync=True, ) @staticmethod From c5095ad826a69f281766e2bd48041fed0fc840f2 Mon Sep 17 00:00:00 2001 From: Frank Chen Date: Thu, 4 Jun 2026 14:52:37 -0700 Subject: [PATCH 2/2] use SAM to deploy examples --- .github/workflows/deploy-examples.yml | 172 +- .github/workflows/integration-tests.yml | 57 +- .gitignore | 3 +- .../cli.py | 260 +-- .../scripts/build_deployment_artifacts.py | 79 + .../scripts/generate_sam_template.py | 152 +- .../template.yaml | 1633 ++++++++++++++++- 7 files changed, 2067 insertions(+), 289 deletions(-) create mode 100644 packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index c90de6ad..3aba8cad 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -11,33 +11,16 @@ on: env: AWS_REGION: us-west-2 + SAM_CLI_TELEMETRY: 0 permissions: id-token: write contents: read jobs: - setup: - runs-on: ubuntu-latest - outputs: - examples: ${{ steps.get-examples.outputs.examples }} - steps: - - uses: actions/checkout@v6 - - - name: Get examples from catalog - id: get-examples - working-directory: ./packages/aws-durable-execution-sdk-python-examples - run: | - echo "examples=$(jq -c '.examples | map(select(.integration == true))' examples-catalog.json)" >> $GITHUB_OUTPUT - integration-test: - needs: setup runs-on: ubuntu-latest - name: ${{ matrix.example.name }} - strategy: - matrix: - example: ${{ fromJson(needs.setup.outputs.examples) }} - fail-fast: false + name: Deploy and test examples steps: - uses: actions/checkout@v6 @@ -57,80 +40,99 @@ jobs: - name: Install Hatch run: pip install hatch - - name: Build examples - run: | - hatch run -- examples:pip install -e packages/aws-durable-execution-sdk-python packages/aws-durable-execution-sdk-python-otel - hatch run examples:build - - name: Deploy Lambda function - ${{ matrix.example.name }} - id: deploy + - name: Set up SAM CLI + uses: aws-actions/setup-sam@v2 + + - name: Deploy and test integration examples env: AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} LAMBDA_ENDPOINT: "https://lambda.us-west-2.amazonaws.com" KMS_KEY_ARN: ${{ secrets.KMS_KEY_ARN }} run: | - # Build function name - EXAMPLE_NAME_CLEAN=$(echo "${{ matrix.example.name }}" | sed 's/ //g') if [ "${{ github.event_name }}" = "pull_request" ]; then - FUNCTION_NAME="${EXAMPLE_NAME_CLEAN}-Python-PR-${{ github.event.number }}" + STACK_NAME="PythonExamples-PR-${{ github.event.number }}" else - FUNCTION_NAME="${EXAMPLE_NAME_CLEAN}-Python" + STACK_NAME="PythonExamples" fi - - # Clean up existing function if present to avoid conflicts - echo "Cleaning up existing function if present..." - aws lambda delete-function \ - --function-name "$FUNCTION_NAME" \ - --endpoint-url "$LAMBDA_ENDPOINT" \ - --region "$AWS_REGION" 2>/dev/null || echo "No existing function to clean up" - - # Give AWS time to process the deletion - sleep 5 - - echo "Deploying ${{ matrix.example.name }} as $FUNCTION_NAME" - hatch run examples:deploy "${{ matrix.example.name }}" --function-name "$FUNCTION_NAME" - - # $LATEST is also a qualified version - QUALIFIED_FUNCTION_NAME="${FUNCTION_NAME}:\$LATEST" - - # Store both names for later steps - echo "FUNCTION_NAME=$FUNCTION_NAME" >> $GITHUB_ENV - echo "QUALIFIED_FUNCTION_NAME=$QUALIFIED_FUNCTION_NAME" >> $GITHUB_ENV - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "DEPLOYED_FUNCTION_NAME=$FUNCTION_NAME" >> $GITHUB_OUTPUT - echo "QUALIFIED_FUNCTION_NAME=$QUALIFIED_FUNCTION_NAME" >> $GITHUB_OUTPUT - - - name: Run Integration Tests - ${{ matrix.example.name }} - env: - AWS_REGION: ${{ env.AWS_REGION }} - LAMBDA_ENDPOINT: "https://lambda.us-west-2.amazonaws.com" - QUALIFIED_FUNCTION_NAME: ${{ env.QUALIFIED_FUNCTION_NAME }} - LAMBDA_FUNCTION_TEST_NAME: ${{ matrix.example.name }} - run: | - echo "Running integration tests for ${{ matrix.example.name }}" - echo "Function name: ${{ steps.deploy.outputs.DEPLOYED_FUNCTION_NAME }}" - echo "Qualified function name: ${QUALIFIED_FUNCTION_NAME}" - echo "AWS Region: ${AWS_REGION}" - echo "Lambda Endpoint: ${LAMBDA_ENDPOINT}" - - # Convert example name to test name: "Hello World" -> "test_hello_world" - TEST_NAME="test_$(echo "${{ matrix.example.name }}" | tr '[:upper:]' '[:lower:]' | tr ' ' '_')" - echo "Test name: ${TEST_NAME}" - - # Run integration tests from repo root - hatch run test:examples-integration - - # Wait for function to be ready - echo "Waiting for function to be active..." - aws lambda wait function-active --function-name "$QUALIFIED_FUNCTION_NAME" --endpoint-url "$LAMBDA_ENDPOINT" --region "$AWS_REGION" - - # - name: Cleanup Lambda function + SOURCE_DIR="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/source" + TEMPLATE_FILE="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/template.generated.json" + BUILD_DIR="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/build" + OUTPUTS_FILE="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/stack-outputs.json" + + echo "::group::Deploy all examples" + + python packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py "$SOURCE_DIR" + python packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py \ + --code-uri "$SOURCE_DIR" \ + --output "$TEMPLATE_FILE" + + sam build \ + --template-file "$TEMPLATE_FILE" \ + --build-dir "$BUILD_DIR" \ + --use-container + + PARAMETER_OVERRIDES=("LambdaEndpoint=$LAMBDA_ENDPOINT") + if [ -n "${KMS_KEY_ARN:-}" ]; then + PARAMETER_OVERRIDES+=("KmsKeyArn=$KMS_KEY_ARN") + fi + + sam deploy \ + --template-file "$BUILD_DIR/template.yaml" \ + --stack-name "$STACK_NAME" \ + --capabilities CAPABILITY_IAM \ + --resolve-s3 \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --region "$AWS_REGION" \ + --parameter-overrides "${PARAMETER_OVERRIDES[@]}" + + aws cloudformation describe-stacks \ + --stack-name "$STACK_NAME" \ + --region "$AWS_REGION" \ + --query 'Stacks[0].Outputs' \ + > "$OUTPUTS_FILE" + + echo "::endgroup::" + + while IFS= read -r EXAMPLE; do + EXAMPLE_NAME="$(echo "$EXAMPLE" | jq -r '.name')" + OUTPUT_KEY="$(echo "$EXAMPLE" | jq -r '.handler | sub("\\.handler$"; "") | split("_") | map((.[0:1] | ascii_upcase) + .[1:]) | join("") + "FunctionName"')" + FUNCTION_NAME="$(jq -r --arg key "$OUTPUT_KEY" '.[] | select(.OutputKey == $key) | .OutputValue' "$OUTPUTS_FILE")" + QUALIFIED_FUNCTION_NAME="${FUNCTION_NAME}:\$LATEST" + + if [ -z "$FUNCTION_NAME" ] || [ "$FUNCTION_NAME" = "null" ]; then + echo "No deployed function output found for $EXAMPLE_NAME ($OUTPUT_KEY)" + exit 1 + fi + + echo "::group::Test $EXAMPLE_NAME" + + echo "Waiting for function to be active..." + aws lambda wait function-active \ + --function-name "$QUALIFIED_FUNCTION_NAME" \ + --endpoint-url "$LAMBDA_ENDPOINT" \ + --region "$AWS_REGION" + + echo "Function name: $FUNCTION_NAME" + echo "Qualified function name: $QUALIFIED_FUNCTION_NAME" + echo "AWS Region: $AWS_REGION" + echo "Lambda Endpoint: $LAMBDA_ENDPOINT" + + TEST_NAME="test_$(echo "$EXAMPLE_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '_')" + echo "Test name: $TEST_NAME" + + QUALIFIED_FUNCTION_NAME="$QUALIFIED_FUNCTION_NAME" \ + LAMBDA_FUNCTION_TEST_NAME="$EXAMPLE_NAME" \ + hatch run test:examples-integration + + echo "::endgroup::" + done < <(jq -c '.examples[] | select(.integration == true)' packages/aws-durable-execution-sdk-python-examples/examples-catalog.json) + + # - name: Cleanup SAM stack # if: always() - # env: - # LAMBDA_ENDPOINT: ${{ secrets.LAMBDA_ENDPOINT_BETA }} # run: | - # echo "Deleting function: $FUNCTION_NAME" - # aws lambda delete-function \ - # --function-name "$FUNCTION_NAME" \ - # --endpoint-url "$LAMBDA_ENDPOINT" \ - # --region "${{ env.AWS_REGION }}" || echo "Function already deleted or doesn't exist" + # sam delete \ + # --stack-name "PythonExamples-PR-${{ github.event.number }}" \ + # --region "${{ env.AWS_REGION }}" \ + # --no-prompts || echo "Stack cleanup failed or already deleted" diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 1b3176c6..83c668a6 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -54,6 +54,7 @@ jobs: if: github.event_name == 'pull_request' env: AWS_REGION: us-west-2 + SAM_CLI_TELEMETRY: 0 steps: - name: Checkout Language SDK (this PR) @@ -82,6 +83,9 @@ jobs: - name: Install Hatch run: python -m pip install hatch==1.16.5 + - name: Set up SAM CLI + uses: aws-actions/setup-sam@v2 + - name: Get integration examples id: get-examples working-directory: language-sdk/packages/aws-durable-execution-sdk-python-examples @@ -104,17 +108,54 @@ jobs: INVOKE_ACCOUNT_ID: ${{ secrets.INVOKE_ACCOUNT_ID }} KMS_KEY_ARN: ${{ secrets.KMS_KEY_ARN }} run: | - echo "Building examples..." hatch run -- examples:pip install -e packages/testing-sdk - hatch run examples:build # Get first integration example for testing EXAMPLE_NAME=$(echo '${{ steps.get-examples.outputs.examples }}' | jq -r '.[0].name') EXAMPLE_NAME_CLEAN=$(echo "$EXAMPLE_NAME" | sed 's/ //g') FUNCTION_NAME="${EXAMPLE_NAME_CLEAN}-LanguageSDK-PR-${{ github.event.number }}" + STACK_NAME="${FUNCTION_NAME}-stack" + SOURCE_DIR="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/source" + TEMPLATE_FILE="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/template.generated.json" + BUILD_DIR="$PWD/packages/aws-durable-execution-sdk-python-examples/.aws-sam/build" + + # Remove a legacy directly-created Lambda only when no SAM stack owns it. + if ! aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "${{ env.AWS_REGION }}" >/dev/null 2>&1; then + echo "Cleaning up legacy function if present..." + aws lambda delete-function \ + --function-name "$FUNCTION_NAME" \ + --endpoint-url "$LAMBDA_ENDPOINT" \ + --region "${{ env.AWS_REGION }}" 2>/dev/null || echo "No legacy function to clean up" + sleep 5 + fi echo "Deploying example: $EXAMPLE_NAME as $FUNCTION_NAME" - hatch run examples:deploy "$EXAMPLE_NAME" --function-name "$FUNCTION_NAME" + python packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py "$SOURCE_DIR" + python packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py \ + --example-name "$EXAMPLE_NAME" \ + --function-name "$FUNCTION_NAME" \ + --code-uri "$SOURCE_DIR" \ + --output "$TEMPLATE_FILE" + + sam build \ + --template-file "$TEMPLATE_FILE" \ + --build-dir "$BUILD_DIR" \ + --use-container + + PARAMETER_OVERRIDES=("LambdaEndpoint=$LAMBDA_ENDPOINT") + if [ -n "${KMS_KEY_ARN:-}" ]; then + PARAMETER_OVERRIDES+=("KmsKeyArn=$KMS_KEY_ARN") + fi + + sam deploy \ + --template-file "$BUILD_DIR/template.yaml" \ + --stack-name "$STACK_NAME" \ + --capabilities CAPABILITY_IAM \ + --resolve-s3 \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --region "${{ env.AWS_REGION }}" \ + --parameter-overrides "${PARAMETER_OVERRIDES[@]}" QUALIFIED_FUNCTION_NAME="$FUNCTION_NAME:\$LATEST" @@ -155,8 +196,8 @@ jobs: cat /tmp/executions.json # Cleanup - echo "Cleaning up function: $FUNCTION_NAME" - aws lambda delete-function \ - --function-name "$FUNCTION_NAME" \ - --endpoint-url "$LAMBDA_ENDPOINT" \ - --region "${{ env.AWS_REGION }}" || echo "Function cleanup failed or already deleted" + echo "Cleaning up stack: $STACK_NAME" + sam delete \ + --stack-name "$STACK_NAME" \ + --region "${{ env.AWS_REGION }}" \ + --no-prompts || echo "Stack cleanup failed or already deleted" diff --git a/.gitignore b/.gitignore index 1aa19977..c9186757 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ dist/ .kiro/ **/build/ +**/.aws-sam/ **/*.zip -.env \ No newline at end of file +.env diff --git a/packages/aws-durable-execution-sdk-python-examples/cli.py b/packages/aws-durable-execution-sdk-python-examples/cli.py index ff2e8394..ad1500cc 100755 --- a/packages/aws-durable-execution-sdk-python-examples/cli.py +++ b/packages/aws-durable-execution-sdk-python-examples/cli.py @@ -4,6 +4,7 @@ import json import logging import os +import re import shutil import subprocess import sys @@ -35,62 +36,14 @@ def build_examples(): """Build examples with SDK dependencies.""" build_dir = Path(__file__).parent / "build" - src_dir = Path(__file__).parent / "src" - packages_dir = Path(__file__).parent.parent - - logger.info("Building examples...") - - # Clean and create build directory - if build_dir.exists(): - logger.info("Cleaning existing build directory") - shutil.rmtree(build_dir) - build_dir.mkdir() - - # Copy testing library from current environment - try: - import aws_durable_execution_sdk_python_testing - - sdk_path = Path(aws_durable_execution_sdk_python_testing.__file__).parent - logger.info("Copying SDK from %s", sdk_path) - shutil.copytree( - sdk_path, build_dir / "aws_durable_execution_sdk_python_testing" - ) - except (ImportError, OSError): - logger.exception("Failed to copy testing library") - return False - - # Install local packages so their runtime dependencies are included in - # the Lambda deployment package. - runtime_packages = [ - packages_dir / "aws-durable-execution-sdk-python", - packages_dir / "aws-durable-execution-sdk-python-otel", - ] - try: + build_script = Path(__file__).parent / "scripts" / "build_deployment_artifacts.py" + return ( subprocess.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--upgrade", - "--target", - str(build_dir), - *[str(package) for package in runtime_packages], - ], - check=True, - ) - except subprocess.CalledProcessError: - logger.exception("Failed to install runtime dependencies") - return False - - # Copy example functions - logger.info("Copying examples from %s", src_dir) - for file_path in src_dir.rglob("*"): - if file_path.is_file(): - shutil.copy2(file_path, build_dir / file_path.name) - - logger.info("Build completed successfully") - return True + [sys.executable, str(build_script), str(build_dir)], + check=False, + ).returncode + == 0 + ) def create_kms_key(kms_client, account_id): @@ -260,7 +213,7 @@ def create_deployment_package(example_name: str) -> Path: def get_aws_config(): """Get AWS configuration from environment.""" - config = { + return { "region": os.getenv("AWS_REGION", "us-west-2"), "lambda_endpoint": os.getenv( "LAMBDA_ENDPOINT", "https://lambda.us-west-2.amazonaws.com" @@ -269,12 +222,6 @@ def get_aws_config(): "kms_key_arn": os.getenv("KMS_KEY_ARN"), } - if not config["account_id"]: - msg = "Missing AWS_ACCOUNT_ID" - raise ValueError(msg) - - return config - def get_lambda_client(): """Get configured Lambda client.""" @@ -311,8 +258,94 @@ def retry_on_resource_conflict(func, *args, max_retries=5, **kwargs): return None -def deploy_function(example_name: str, function_name: str | None = None): - """Deploy function to AWS Lambda.""" +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _run_command(command: list[str], *, cwd: Path) -> None: + logger.info("Running: %s", " ".join(command)) + subprocess.run(command, cwd=cwd, check=True) + + +def _stack_name_for_function(function_name: str) -> str: + stack_name = re.sub(r"[^A-Za-z0-9-]", "-", f"{function_name}-stack") + stack_name = re.sub(r"-+", "-", stack_name).strip("-") + if not stack_name or not stack_name[0].isalpha(): + stack_name = f"Durable-{stack_name}" + return stack_name[:128] + + +def _write_sam_template( + *, + example_name: str | None = None, + function_name: str | None = None, +) -> Path: + examples_dir = Path(__file__).parent + sam_dir = examples_dir / ".aws-sam" + sam_dir.mkdir(exist_ok=True) + source_dir = sam_dir / "source" + template_path = sam_dir / "template.generated.json" + generator = examples_dir / "scripts" / "generate_sam_template.py" + source_builder = examples_dir / "scripts" / "build_deployment_artifacts.py" + + _run_command( + [sys.executable, str(source_builder), str(source_dir)], + cwd=_repo_root(), + ) + + command = [ + sys.executable, + str(generator), + "--output", + str(template_path), + "--code-uri", + str(source_dir.resolve()), + ] + if example_name: + command.extend(["--example-name", example_name]) + if function_name: + command.extend(["--function-name", function_name]) + + _run_command(command, cwd=_repo_root()) + return template_path + + +def sam_build( + *, + example_name: str | None = None, + function_name: str | None = None, + use_container: bool = True, +) -> Path: + """Build examples with SAM.""" + examples_dir = Path(__file__).parent + template_path = _write_sam_template( + example_name=example_name, + function_name=function_name, + ) + build_dir = examples_dir / ".aws-sam" / "build" + + command = [ + "sam", + "build", + "--template-file", + str(template_path), + "--build-dir", + str(build_dir), + ] + if use_container: + command.append("--use-container") + + _run_command(command, cwd=_repo_root()) + return build_dir / "template.yaml" + + +def deploy_function( + example_name: str, + function_name: str | None = None, + stack_name: str | None = None, + use_container: bool = True, +): + """Deploy function to AWS Lambda using SAM.""" catalog = load_catalog() example_config = None @@ -329,52 +362,39 @@ def deploy_function(example_name: str, function_name: str | None = None): if not function_name: function_name = f"{example_name.replace(' ', '')}-Python" - handler_file = example_config["handler"].replace(".handler", "") - zip_path = create_deployment_package(handler_file) config = get_aws_config() - lambda_client = get_lambda_client() - - role_arn = ( - f"arn:aws:iam::{config['account_id']}:role/DurableFunctionsIntegrationTestRole" + stack_name = stack_name or _stack_name_for_function(function_name) + built_template = sam_build( + example_name=example_name, + function_name=function_name, + use_container=use_container, ) - function_config = { - "FunctionName": function_name, - "Runtime": "python3.13", - "Role": role_arn, - "Handler": example_config["handler"], - "Description": example_config["description"], - "Timeout": 60, - "MemorySize": 128, - "Environment": { - "Variables": {"AWS_ENDPOINT_URL_LAMBDA": config["lambda_endpoint"]} - }, - "DurableConfig": example_config["durableConfig"], - "LoggingConfig": example_config.get("loggingConfig", {}), - } - + parameter_overrides = [f"LambdaEndpoint={config['lambda_endpoint']}"] if config["kms_key_arn"]: - function_config["KMSKeyArn"] = config["kms_key_arn"] - - with open(zip_path, "rb") as f: - zip_content = f.read() - - try: - lambda_client.get_function(FunctionName=function_name) - retry_on_resource_conflict( - lambda_client.update_function_code, - FunctionName=function_name, - ZipFile=zip_content, - max_retries=8, - ) - retry_on_resource_conflict( - lambda_client.update_function_configuration, **function_config - ) - - except lambda_client.exceptions.ResourceNotFoundException: - lambda_client.create_function(**function_config, Code={"ZipFile": zip_content}) + parameter_overrides.append(f"KmsKeyArn={config['kms_key_arn']}") + + command = [ + "sam", + "deploy", + "--template-file", + str(built_template), + "--stack-name", + stack_name, + "--capabilities", + "CAPABILITY_IAM", + "--resolve-s3", + "--no-confirm-changeset", + "--no-fail-on-empty-changeset", + "--region", + config["region"], + "--parameter-overrides", + *parameter_overrides, + ] + _run_command(command, cwd=_repo_root()) logger.info("Function deployed successfully! %s", function_name) + logger.info("SAM stack: %s", stack_name) return True @@ -448,7 +468,16 @@ def main(): subparsers.add_parser("bootstrap", help="Bootstrap account with necessary IAM role") # Build command - subparsers.add_parser("build", help="Build examples with dependencies") + build_parser = subparsers.add_parser("build", help="Build examples with SAM") + build_parser.add_argument("--example-name", help="Build one example") + build_parser.add_argument( + "--function-name", help="Set FunctionName in generated template" + ) + build_parser.add_argument( + "--no-use-container", + action="store_true", + help="Run SAM build without a Lambda-compatible build container", + ) # List command subparsers.add_parser("list", help="List available examples") @@ -457,6 +486,14 @@ def main(): deploy_parser = subparsers.add_parser("deploy", help="Deploy an example") deploy_parser.add_argument("example_name", help="Name of example to deploy") deploy_parser.add_argument("--function-name", help="Custom function name") + deploy_parser.add_argument( + "--stack-name", help="Custom SAM/CloudFormation stack name" + ) + deploy_parser.add_argument( + "--no-use-container", + action="store_true", + help="Run SAM build without a Lambda-compatible build container", + ) # Invoke command invoke_parser = subparsers.add_parser("invoke", help="Invoke a deployed function") @@ -485,11 +522,20 @@ def main(): if args.command == "bootstrap": bootstrap_account() elif args.command == "build": - build_examples() + sam_build( + example_name=args.example_name, + function_name=args.function_name, + use_container=not args.no_use_container, + ) elif args.command == "list": list_examples() elif args.command == "deploy": - deploy_function(args.example_name, args.function_name) + deploy_function( + args.example_name, + args.function_name, + args.stack_name, + use_container=not args.no_use_container, + ) elif args.command == "invoke": invoke_function(args.function_name, args.payload) elif args.command == "policy": diff --git a/packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py b/packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py new file mode 100644 index 00000000..5bf9fd3f --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-examples/scripts/build_deployment_artifacts.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import shutil +import sys +from pathlib import Path + + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _copy_package(package_dir: Path, target_dir: Path) -> None: + """Copy the files needed for pip to install a local package.""" + if target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True) + + for file_name in ("pyproject.toml", "README.md"): + shutil.copy2(package_dir / file_name, target_dir / file_name) + + shutil.copytree( + package_dir / "src", + target_dir / "src", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.egg-info"), + ) + + +def prepare_sam_source(source_dir: Path) -> bool: + """Prepare a SAM Python source directory for Lambda builds.""" + repo_root = _repo_root() + examples_dir = repo_root / "packages" / "aws-durable-execution-sdk-python-examples" + src_dir = examples_dir / "src" + + if source_dir.exists(): + shutil.rmtree(source_dir) + source_dir.mkdir(parents=True) + + runtime_packages = [ + repo_root / "packages" / "aws-durable-execution-sdk-python", + repo_root / "packages" / "aws-durable-execution-sdk-python-otel", + ] + + logger.info("Copying local runtime packages into %s", source_dir) + for package in runtime_packages: + _copy_package(package, source_dir / package.name) + + requirements = "\n".join(f"./{package.name}" for package in runtime_packages) + (source_dir / "requirements.txt").write_text(f"{requirements}\n") + + logger.info("Copying examples from %s", src_dir) + for file_path in src_dir.rglob("*"): + if ( + file_path.is_file() + and "__pycache__" not in file_path.parts + and file_path.suffix != ".pyc" + ): + shutil.copy2(file_path, source_dir / file_path.name) + + logger.info("SAM source prepared successfully") + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare SAM source artifacts") + parser.add_argument("source_dir", type=Path) + args = parser.parse_args() + + if not prepare_sam_source(args.source_dir): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py b/packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py index c4e631ce..57946da9 100644 --- a/packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py +++ b/packages/aws-durable-execution-sdk-python-examples/scripts/generate_sam_template.py @@ -1,21 +1,45 @@ #!/usr/bin/env python3 +import argparse import json +import re from pathlib import Path -import json - -def load_catalog(): +def load_catalog() -> dict: """Load examples catalog.""" catalog_path = Path(__file__).parent.parent / "examples-catalog.json" with open(catalog_path) as f: return json.load(f) -def generate_sam_template(): - """Generate SAM template for all examples.""" +def logical_id_for_example(example: dict) -> str: + """Create a stable CloudFormation logical ID for an example.""" + handler_base = example["handler"].replace(".handler", "") + words = re.split(r"[^A-Za-z0-9]+", handler_base) + return "".join(word[:1].upper() + word[1:] for word in words if word) + + +def get_example(catalog: dict, example_name: str) -> dict: + for example in catalog["examples"]: + if example["name"] == example_name: + return example + + msg = f"Example not found: {example_name}" + raise ValueError(msg) + + +def generate_sam_template( + *, + example_name: str | None = None, + function_name: str | None = None, + code_uri: str = ".aws-sam/source", +) -> dict: + """Generate a SAM template for all examples, or for one selected example.""" catalog = load_catalog() + examples = ( + [get_example(catalog, example_name)] if example_name else catalog["examples"] + ) template = { "AWSTemplateFormatVersion": "2010-09-09", @@ -34,7 +58,14 @@ def generate_sam_template(): "LambdaEndpoint": { "Type": "String", "Default": "https://lambda.us-west-2.amazonaws.com", - } + }, + "KmsKeyArn": { + "Type": "String", + "Default": "", + }, + }, + "Conditions": { + "HasKmsKeyArn": {"Fn::Not": [{"Fn::Equals": [{"Ref": "KmsKeyArn"}, ""]}]} }, "Resources": { "DurableFunctionRole": { @@ -51,19 +82,20 @@ def generate_sam_template(): ], }, "ManagedPolicyArns": [ - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy" ], "Policies": [ { - "PolicyName": "DurableExecutionPolicy", + "PolicyName": "DurableExecutionExamplesKmsPolicy", "PolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ - "lambda:CheckpointDurableExecution", - "lambda:GetDurableExecutionState", + "kms:CreateGrant", + "kms:Decrypt", + "kms:Encrypt", ], "Resource": "*", } @@ -74,33 +106,97 @@ def generate_sam_template(): }, } }, + "Outputs": {}, } - for example in catalog["examples"]: - # Convert handler name to PascalCase (e.g., hello_world -> HelloWorld) - handler_base = example["handler"].replace(".handler", "") - function_name = "".join(word.capitalize() for word in handler_base.split("_")) - template["Resources"][function_name] = { - "Type": "AWS::Serverless::Function", - "Properties": { - "CodeUri": "build/", - "Handler": example["handler"], - "Description": example["description"], - "Role": {"Fn::GetAtt": ["DurableFunctionRole", "Arn"]}, + for example in examples: + logical_id = logical_id_for_example(example) + properties = { + "CodeUri": code_uri, + "Handler": example["handler"], + "Description": example["description"], + "Role": {"Fn::GetAtt": ["DurableFunctionRole", "Arn"]}, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + {"Ref": "KmsKeyArn"}, + {"Ref": "AWS::NoValue"}, + ] }, } + if function_name: + properties["FunctionName"] = function_name + if "durableConfig" in example: - template["Resources"][function_name]["Properties"]["DurableConfig"] = ( - example["durableConfig"] - ) + properties["DurableConfig"] = example["durableConfig"] + + if "loggingConfig" in example: + properties["LoggingConfig"] = example["loggingConfig"] + + template["Resources"][logical_id] = { + "Type": "AWS::Serverless::Function", + "Properties": properties, + } + template["Outputs"][f"{logical_id}FunctionName"] = { + "Value": {"Ref": logical_id} + } + template["Outputs"][f"{logical_id}QualifiedFunctionName"] = { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + {"FunctionName": {"Ref": logical_id}}, + ] + } + } + + return template + - template_path = Path(__file__).parent.parent / "template.yaml" - with open(template_path, "w") as f: +def write_sam_template( + *, + output_path: Path, + example_name: str | None = None, + function_name: str | None = None, + code_uri: str = ".aws-sam/source", +) -> None: + template = generate_sam_template( + example_name=example_name, + function_name=function_name, + code_uri=code_uri, + ) + with open(output_path, "w") as f: json.dump(template, f, sort_keys=False, indent=2) + f.write("\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate SAM template for examples") + parser.add_argument("--example-name", help="Generate a template for one example") + parser.add_argument( + "--function-name", help="Set FunctionName on the generated function" + ) + parser.add_argument( + "--code-uri", + default=".aws-sam/source", + help="CodeUri to use for generated functions", + ) + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).parent.parent / "template.yaml", + ) + args = parser.parse_args() + + write_sam_template( + output_path=args.output, + example_name=args.example_name, + function_name=args.function_name, + code_uri=args.code_uri, + ) - print(f"Generated SAM template at {template_path}") + print(f"Generated SAM template at {args.output}") if __name__ == "__main__": - generate_sam_template() + main() diff --git a/packages/aws-durable-execution-sdk-python-examples/template.yaml b/packages/aws-durable-execution-sdk-python-examples/template.yaml index d56b1af8..6df022fd 100644 --- a/packages/aws-durable-execution-sdk-python-examples/template.yaml +++ b/packages/aws-durable-execution-sdk-python-examples/template.yaml @@ -19,6 +19,24 @@ "LambdaEndpoint": { "Type": "String", "Default": "https://lambda.us-west-2.amazonaws.com" + }, + "KmsKeyArn": { + "Type": "String", + "Default": "" + } + }, + "Conditions": { + "HasKmsKeyArn": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "KmsKeyArn" + }, + "" + ] + } + ] } }, "Resources": { @@ -38,19 +56,20 @@ ] }, "ManagedPolicyArns": [ - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy" ], "Policies": [ { - "PolicyName": "DurableExecutionPolicy", + "PolicyName": "DurableExecutionExamplesKmsPolicy", "PolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ - "lambda:CheckpointDurableExecution", - "lambda:GetDurableExecutionState" + "kms:CreateGrant", + "kms:Decrypt", + "kms:Encrypt" ], "Resource": "*" } @@ -63,7 +82,7 @@ "HelloWorld": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "hello_world.handler", "Description": "A simple hello world example with no durable operations", "Role": { @@ -72,6 +91,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -81,7 +111,7 @@ "Step": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "step.handler", "Description": "Basic usage of context.step() to checkpoint a simple operation", "Role": { @@ -90,6 +120,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -99,7 +140,7 @@ "StepWithName": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "step_with_name.handler", "Description": "Step operation with explicit name parameter", "Role": { @@ -108,6 +149,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -117,7 +169,7 @@ "StepWithRetry": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "step_with_retry.handler", "Description": "Usage of context.step() with retry configuration for fault tolerance", "Role": { @@ -126,6 +178,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -135,7 +198,7 @@ "Wait": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait.handler", "Description": "Basic usage of context.wait() to pause execution", "Role": { @@ -144,6 +207,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -153,7 +227,7 @@ "MultipleWait": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "multiple_wait.handler", "Description": "Usage of demonstrating multiple sequential wait operations.", "Role": { @@ -162,6 +236,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -171,7 +256,7 @@ "Callback": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback.handler", "Description": "Basic usage of context.create_callback() to create a callback for external systems", "Role": { @@ -180,6 +265,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -189,7 +285,7 @@ "WaitForCallback": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -198,6 +294,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -207,7 +314,7 @@ "WaitForCallbackAnonymous": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_anonymous.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -216,6 +323,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -225,7 +343,7 @@ "WaitForCallbackHeartbeat": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_heartbeat.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -234,6 +352,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -243,7 +372,7 @@ "WaitForCallbackChild": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_child.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -252,6 +381,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -261,7 +401,7 @@ "WaitForCallbackMixedOps": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_mixed_ops.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -270,6 +410,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -279,7 +430,7 @@ "WaitForCallbackMultipleInvocations": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_multiple_invocations.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -288,6 +439,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -297,7 +459,7 @@ "WaitForCallbackSubmitterFailureCatchable": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_submitter_failure_catchable.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -306,6 +468,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -315,7 +488,7 @@ "WaitForCallbackSubmitterFailure": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_submitter_failure.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -324,6 +497,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -333,7 +517,7 @@ "WaitForCallbackSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_serdes.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -342,6 +526,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -351,7 +546,7 @@ "WaitForCallbackNested": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_callback_nested.handler", "Description": "Usage of context.wait_for_callback() to wait for external system responses", "Role": { @@ -360,6 +555,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -369,7 +575,7 @@ "RunInChildContext": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "run_in_child_context.handler", "Description": "Usage of context.run_in_child_context() to execute operations in isolated contexts", "Role": { @@ -378,6 +584,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -387,7 +604,7 @@ "Parallel": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel.handler", "Description": "Executing multiple durable operations in parallel", "Role": { @@ -396,6 +613,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -405,7 +633,7 @@ "MapOperations": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_operations.handler", "Description": "Processing collections using map-like durable operations", "Role": { @@ -414,6 +642,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -423,7 +662,7 @@ "MapOperationsFlat": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_operations_flat.handler", "Description": "Processing collections using map-like durable operations in FLAT mode", "Role": { @@ -432,6 +671,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -441,7 +691,7 @@ "MapWithLargeScale": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_large_scale.handler", "Description": "Processing collections using map-like durable operations in large scale", "Role": { @@ -450,6 +700,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -459,7 +720,7 @@ "BlockExample": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "block_example.handler", "Description": "Nested child contexts demonstrating block operations", "Role": { @@ -468,6 +729,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -477,7 +749,7 @@ "LoggerExample": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "logger_example.handler", "Description": "Demonstrating logger usage and enrichment in DurableContext", "Role": { @@ -486,16 +758,31 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 + }, + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON" } } }, "StepsWithRetry": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "steps_with_retry.handler", "Description": "Multiple steps with retry logic in a polling pattern", "Role": { @@ -504,6 +791,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -513,7 +811,7 @@ "WaitForCondition": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "wait_for_condition.handler", "Description": "Polling pattern that waits for a condition to be met", "Role": { @@ -522,6 +820,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -531,7 +840,7 @@ "RunInChildContextLargeData": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "run_in_child_context_large_data.handler", "Description": "Usage of context.run_in_child_context() to execute operations in isolated contexts with large data", "Role": { @@ -540,6 +849,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -549,7 +869,7 @@ "SimpleExecution": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "simple_execution.handler", "Description": "Simple execution without durable execution", "Role": { @@ -558,6 +878,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -567,7 +898,7 @@ "MapWithMaxConcurrency": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_max_concurrency.handler", "Description": "Map operation with maxConcurrency limit", "Role": { @@ -576,6 +907,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -585,7 +927,7 @@ "MapWithMinSuccessful": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_min_successful.handler", "Description": "Map operation with min_successful completion config", "Role": { @@ -594,6 +936,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -603,7 +956,7 @@ "MapWithFailureTolerance": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_failure_tolerance.handler", "Description": "Map operation with failure tolerance", "Role": { @@ -612,6 +965,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -621,7 +985,7 @@ "MapCompletion": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_completion.handler", "Description": "Reproduces issue where map with minSuccessful loses failure count", "Role": { @@ -630,6 +994,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -639,7 +1014,7 @@ "ParallelWithMaxConcurrency": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_max_concurrency.handler", "Description": "Parallel operation with maxConcurrency limit", "Role": { @@ -648,6 +1023,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -657,7 +1043,7 @@ "ParallelWithWait": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_wait.handler", "Description": "Parallel operation with wait operations in branches", "Role": { @@ -666,6 +1052,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -675,7 +1072,7 @@ "ParallelWithFailureTolerance": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_failure_tolerance.handler", "Description": "Parallel operation with failure tolerance", "Role": { @@ -684,6 +1081,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -693,7 +1101,7 @@ "MapWithCustomSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_custom_serdes.handler", "Description": "Map operation with custom item-level serialization", "Role": { @@ -702,6 +1110,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -711,7 +1130,7 @@ "MapWithBatchSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_batch_serdes.handler", "Description": "Map operation with custom batch-level serialization", "Role": { @@ -720,6 +1139,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -729,7 +1159,7 @@ "ParallelWithCustomSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_custom_serdes.handler", "Description": "Parallel operation with custom item-level serialization", "Role": { @@ -738,6 +1168,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -747,7 +1188,7 @@ "ParallelWithBatchSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_batch_serdes.handler", "Description": "Parallel operation with custom batch-level serialization", "Role": { @@ -756,6 +1197,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -765,7 +1217,7 @@ "HandlerError": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "handler_error.handler", "Description": "Simple function with handler error", "Role": { @@ -774,6 +1226,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -783,7 +1246,7 @@ "NoneResults": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "none_results.handler", "Description": "Test handling of step operations with undefined result after replay.", "Role": { @@ -792,6 +1255,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -801,7 +1275,7 @@ "CallbackSimple": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback_simple.handler", "Description": "Creating a callback ID for external systems to use", "Role": { @@ -810,6 +1284,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -819,7 +1304,7 @@ "CallbackHeartbeat": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback_heartbeat.handler", "Description": "Demonstrates callback failure scenarios where the error propagates and is handled by framework", "Role": { @@ -828,6 +1313,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -837,7 +1333,7 @@ "CallbackMixedOps": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback_mixed_ops.handler", "Description": "Demonstrates createCallback mixed with steps, waits, and other operations", "Role": { @@ -846,6 +1342,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -855,7 +1362,7 @@ "CallbackSerdes": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback_serdes.handler", "Description": "Demonstrates createCallback with custom serialization/deserialization for Date objects", "Role": { @@ -864,6 +1371,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -873,7 +1391,7 @@ "NoReplayExecution": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "no_replay_execution.handler", "Description": "Execution with simples steps and without replay", "Role": { @@ -882,6 +1400,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -891,7 +1420,7 @@ "RunInChildContextStepFailure": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "run_in_child_context_step_failure.handler", "Description": "Demonstrates runInChildContext with a failing step followed by a successful wait", "Role": { @@ -900,6 +1429,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -909,7 +1449,7 @@ "ComprehensiveOperations": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "comprehensive_operations.handler", "Description": "Complex multi-operation example demonstrating all major operations", "Role": { @@ -918,6 +1458,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -927,7 +1478,7 @@ "CallbackConcurrency": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "callback_concurrency.handler", "Description": "Demonstrates multiple concurrent createCallback operations using context.parallel", "Role": { @@ -936,16 +1487,31 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 + }, + "LoggingConfig": { + "ApplicationLogLevel": "DEBUG", + "LogFormat": "JSON" } } }, "MapWithItemNamer": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "map_with_item_namer.handler", "Description": "Map operation with custom item_namer for iteration naming", "Role": { @@ -954,8 +1520,19 @@ "Arn" ] }, - "DurableConfig": { - "RetentionPeriodInDays": 7, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 } } @@ -963,7 +1540,7 @@ "ParallelWithNamedBranches": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "parallel_with_named_branches.handler", "Description": "Parallel operation with named branches using ParallelBranch", "Role": { @@ -972,6 +1549,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -981,7 +1569,7 @@ "ExecutionWithPlugin": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "execution_with_plugin.handler", "Description": "Test plugin", "Role": { @@ -990,6 +1578,17 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 @@ -999,7 +1598,7 @@ "ExecutionWithOtel": { "Type": "AWS::Serverless::Function", "Properties": { - "CodeUri": "build/", + "CodeUri": ".aws-sam/source", "Handler": "execution_with_otel.handler", "Description": "Test Otel plugin", "Role": { @@ -1008,11 +1607,925 @@ "Arn" ] }, + "KmsKeyArn": { + "Fn::If": [ + "HasKmsKeyArn", + { + "Ref": "KmsKeyArn" + }, + { + "Ref": "AWS::NoValue" + } + ] + }, "DurableConfig": { "RetentionPeriodInDays": 7, "ExecutionTimeout": 300 } } } + }, + "Outputs": { + "HelloWorldFunctionName": { + "Value": { + "Ref": "HelloWorld" + } + }, + "HelloWorldQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "HelloWorld" + } + } + ] + } + }, + "StepFunctionName": { + "Value": { + "Ref": "Step" + } + }, + "StepQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "Step" + } + } + ] + } + }, + "StepWithNameFunctionName": { + "Value": { + "Ref": "StepWithName" + } + }, + "StepWithNameQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "StepWithName" + } + } + ] + } + }, + "StepWithRetryFunctionName": { + "Value": { + "Ref": "StepWithRetry" + } + }, + "StepWithRetryQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "StepWithRetry" + } + } + ] + } + }, + "WaitFunctionName": { + "Value": { + "Ref": "Wait" + } + }, + "WaitQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "Wait" + } + } + ] + } + }, + "MultipleWaitFunctionName": { + "Value": { + "Ref": "MultipleWait" + } + }, + "MultipleWaitQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MultipleWait" + } + } + ] + } + }, + "CallbackFunctionName": { + "Value": { + "Ref": "Callback" + } + }, + "CallbackQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "Callback" + } + } + ] + } + }, + "WaitForCallbackFunctionName": { + "Value": { + "Ref": "WaitForCallback" + } + }, + "WaitForCallbackQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallback" + } + } + ] + } + }, + "WaitForCallbackAnonymousFunctionName": { + "Value": { + "Ref": "WaitForCallbackAnonymous" + } + }, + "WaitForCallbackAnonymousQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackAnonymous" + } + } + ] + } + }, + "WaitForCallbackHeartbeatFunctionName": { + "Value": { + "Ref": "WaitForCallbackHeartbeat" + } + }, + "WaitForCallbackHeartbeatQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackHeartbeat" + } + } + ] + } + }, + "WaitForCallbackChildFunctionName": { + "Value": { + "Ref": "WaitForCallbackChild" + } + }, + "WaitForCallbackChildQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackChild" + } + } + ] + } + }, + "WaitForCallbackMixedOpsFunctionName": { + "Value": { + "Ref": "WaitForCallbackMixedOps" + } + }, + "WaitForCallbackMixedOpsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackMixedOps" + } + } + ] + } + }, + "WaitForCallbackMultipleInvocationsFunctionName": { + "Value": { + "Ref": "WaitForCallbackMultipleInvocations" + } + }, + "WaitForCallbackMultipleInvocationsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackMultipleInvocations" + } + } + ] + } + }, + "WaitForCallbackSubmitterFailureCatchableFunctionName": { + "Value": { + "Ref": "WaitForCallbackSubmitterFailureCatchable" + } + }, + "WaitForCallbackSubmitterFailureCatchableQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackSubmitterFailureCatchable" + } + } + ] + } + }, + "WaitForCallbackSubmitterFailureFunctionName": { + "Value": { + "Ref": "WaitForCallbackSubmitterFailure" + } + }, + "WaitForCallbackSubmitterFailureQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackSubmitterFailure" + } + } + ] + } + }, + "WaitForCallbackSerdesFunctionName": { + "Value": { + "Ref": "WaitForCallbackSerdes" + } + }, + "WaitForCallbackSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackSerdes" + } + } + ] + } + }, + "WaitForCallbackNestedFunctionName": { + "Value": { + "Ref": "WaitForCallbackNested" + } + }, + "WaitForCallbackNestedQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCallbackNested" + } + } + ] + } + }, + "RunInChildContextFunctionName": { + "Value": { + "Ref": "RunInChildContext" + } + }, + "RunInChildContextQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "RunInChildContext" + } + } + ] + } + }, + "ParallelFunctionName": { + "Value": { + "Ref": "Parallel" + } + }, + "ParallelQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "Parallel" + } + } + ] + } + }, + "MapOperationsFunctionName": { + "Value": { + "Ref": "MapOperations" + } + }, + "MapOperationsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapOperations" + } + } + ] + } + }, + "MapOperationsFlatFunctionName": { + "Value": { + "Ref": "MapOperationsFlat" + } + }, + "MapOperationsFlatQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapOperationsFlat" + } + } + ] + } + }, + "MapWithLargeScaleFunctionName": { + "Value": { + "Ref": "MapWithLargeScale" + } + }, + "MapWithLargeScaleQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithLargeScale" + } + } + ] + } + }, + "BlockExampleFunctionName": { + "Value": { + "Ref": "BlockExample" + } + }, + "BlockExampleQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "BlockExample" + } + } + ] + } + }, + "LoggerExampleFunctionName": { + "Value": { + "Ref": "LoggerExample" + } + }, + "LoggerExampleQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "LoggerExample" + } + } + ] + } + }, + "StepsWithRetryFunctionName": { + "Value": { + "Ref": "StepsWithRetry" + } + }, + "StepsWithRetryQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "StepsWithRetry" + } + } + ] + } + }, + "WaitForConditionFunctionName": { + "Value": { + "Ref": "WaitForCondition" + } + }, + "WaitForConditionQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "WaitForCondition" + } + } + ] + } + }, + "RunInChildContextLargeDataFunctionName": { + "Value": { + "Ref": "RunInChildContextLargeData" + } + }, + "RunInChildContextLargeDataQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "RunInChildContextLargeData" + } + } + ] + } + }, + "SimpleExecutionFunctionName": { + "Value": { + "Ref": "SimpleExecution" + } + }, + "SimpleExecutionQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "SimpleExecution" + } + } + ] + } + }, + "MapWithMaxConcurrencyFunctionName": { + "Value": { + "Ref": "MapWithMaxConcurrency" + } + }, + "MapWithMaxConcurrencyQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithMaxConcurrency" + } + } + ] + } + }, + "MapWithMinSuccessfulFunctionName": { + "Value": { + "Ref": "MapWithMinSuccessful" + } + }, + "MapWithMinSuccessfulQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithMinSuccessful" + } + } + ] + } + }, + "MapWithFailureToleranceFunctionName": { + "Value": { + "Ref": "MapWithFailureTolerance" + } + }, + "MapWithFailureToleranceQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithFailureTolerance" + } + } + ] + } + }, + "MapCompletionFunctionName": { + "Value": { + "Ref": "MapCompletion" + } + }, + "MapCompletionQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapCompletion" + } + } + ] + } + }, + "ParallelWithMaxConcurrencyFunctionName": { + "Value": { + "Ref": "ParallelWithMaxConcurrency" + } + }, + "ParallelWithMaxConcurrencyQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithMaxConcurrency" + } + } + ] + } + }, + "ParallelWithWaitFunctionName": { + "Value": { + "Ref": "ParallelWithWait" + } + }, + "ParallelWithWaitQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithWait" + } + } + ] + } + }, + "ParallelWithFailureToleranceFunctionName": { + "Value": { + "Ref": "ParallelWithFailureTolerance" + } + }, + "ParallelWithFailureToleranceQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithFailureTolerance" + } + } + ] + } + }, + "MapWithCustomSerdesFunctionName": { + "Value": { + "Ref": "MapWithCustomSerdes" + } + }, + "MapWithCustomSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithCustomSerdes" + } + } + ] + } + }, + "MapWithBatchSerdesFunctionName": { + "Value": { + "Ref": "MapWithBatchSerdes" + } + }, + "MapWithBatchSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithBatchSerdes" + } + } + ] + } + }, + "ParallelWithCustomSerdesFunctionName": { + "Value": { + "Ref": "ParallelWithCustomSerdes" + } + }, + "ParallelWithCustomSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithCustomSerdes" + } + } + ] + } + }, + "ParallelWithBatchSerdesFunctionName": { + "Value": { + "Ref": "ParallelWithBatchSerdes" + } + }, + "ParallelWithBatchSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithBatchSerdes" + } + } + ] + } + }, + "HandlerErrorFunctionName": { + "Value": { + "Ref": "HandlerError" + } + }, + "HandlerErrorQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "HandlerError" + } + } + ] + } + }, + "NoneResultsFunctionName": { + "Value": { + "Ref": "NoneResults" + } + }, + "NoneResultsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "NoneResults" + } + } + ] + } + }, + "CallbackSimpleFunctionName": { + "Value": { + "Ref": "CallbackSimple" + } + }, + "CallbackSimpleQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "CallbackSimple" + } + } + ] + } + }, + "CallbackHeartbeatFunctionName": { + "Value": { + "Ref": "CallbackHeartbeat" + } + }, + "CallbackHeartbeatQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "CallbackHeartbeat" + } + } + ] + } + }, + "CallbackMixedOpsFunctionName": { + "Value": { + "Ref": "CallbackMixedOps" + } + }, + "CallbackMixedOpsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "CallbackMixedOps" + } + } + ] + } + }, + "CallbackSerdesFunctionName": { + "Value": { + "Ref": "CallbackSerdes" + } + }, + "CallbackSerdesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "CallbackSerdes" + } + } + ] + } + }, + "NoReplayExecutionFunctionName": { + "Value": { + "Ref": "NoReplayExecution" + } + }, + "NoReplayExecutionQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "NoReplayExecution" + } + } + ] + } + }, + "RunInChildContextStepFailureFunctionName": { + "Value": { + "Ref": "RunInChildContextStepFailure" + } + }, + "RunInChildContextStepFailureQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "RunInChildContextStepFailure" + } + } + ] + } + }, + "ComprehensiveOperationsFunctionName": { + "Value": { + "Ref": "ComprehensiveOperations" + } + }, + "ComprehensiveOperationsQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ComprehensiveOperations" + } + } + ] + } + }, + "CallbackConcurrencyFunctionName": { + "Value": { + "Ref": "CallbackConcurrency" + } + }, + "CallbackConcurrencyQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "CallbackConcurrency" + } + } + ] + } + }, + "MapWithItemNamerFunctionName": { + "Value": { + "Ref": "MapWithItemNamer" + } + }, + "MapWithItemNamerQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "MapWithItemNamer" + } + } + ] + } + }, + "ParallelWithNamedBranchesFunctionName": { + "Value": { + "Ref": "ParallelWithNamedBranches" + } + }, + "ParallelWithNamedBranchesQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ParallelWithNamedBranches" + } + } + ] + } + }, + "ExecutionWithPluginFunctionName": { + "Value": { + "Ref": "ExecutionWithPlugin" + } + }, + "ExecutionWithPluginQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ExecutionWithPlugin" + } + } + ] + } + }, + "ExecutionWithOtelFunctionName": { + "Value": { + "Ref": "ExecutionWithOtel" + } + }, + "ExecutionWithOtelQualifiedFunctionName": { + "Value": { + "Fn::Sub": [ + "${FunctionName}:$LATEST", + { + "FunctionName": { + "Ref": "ExecutionWithOtel" + } + } + ] + } + } } -} \ No newline at end of file +}