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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions .github/scripts/build_lambda_layer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
#
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path


ARCHITECTURE_PLATFORMS = {
"x86_64": "manylinux2014_x86_64",
"arm64": "manylinux2014_aarch64",
}


@dataclass(frozen=True)
class BuildConfig:
output: Path
target_python: str
architecture: str
sdk_distribution: Path | None = None
otel_distribution: Path | None = None
sdk_requirement: str = "aws-durable-execution-sdk-python"
otel_requirement: str = "aws-durable-execution-sdk-python-otel"
build_dir: Path | None = None


def build_layer(config: BuildConfig) -> Path:
"""Build a Lambda layer zip containing the SDK and OTel plugin."""

_validate_config(config)

with tempfile.TemporaryDirectory() as temp_dir:
work_dir = config.build_dir or Path(temp_dir) / "layer"
if work_dir.exists():
shutil.rmtree(work_dir)
layer_python_dir = work_dir / "python"
layer_python_dir.mkdir(parents=True)

_install_layer_dependencies(config, layer_python_dir)
_write_zip(config.output, work_dir)

return config.output


def _validate_config(config: BuildConfig) -> None:
if config.architecture not in ARCHITECTURE_PLATFORMS:
supported = ", ".join(sorted(ARCHITECTURE_PLATFORMS))
raise ValueError(
f"Unsupported architecture: {config.architecture}. "
f"Supported architectures: {supported}"
)

if not config.target_python.startswith("3."):
raise ValueError("target_python must be a Python 3 minor version, such as 3.12")

for distribution in (config.sdk_distribution, config.otel_distribution):
if distribution is not None and not distribution.is_file():
raise FileNotFoundError(distribution)


def _install_layer_dependencies(config: BuildConfig, target_dir: Path) -> None:
python_version = config.target_python
abi = f"cp{python_version.replace('.', '')}"
platform = ARCHITECTURE_PLATFORMS[config.architecture]
requirements = [
str(config.sdk_distribution or config.sdk_requirement),
str(config.otel_distribution or config.otel_requirement),
]

command = [
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"--target",
str(target_dir),
"--platform",
platform,
"--implementation",
"cp",
"--python-version",
python_version,
"--abi",
abi,
"--only-binary",
":all:",
"--no-compile",
*requirements,
]
subprocess.run(command, check=True)


def _write_zip(output: Path, layer_root: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()

with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(layer_root.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(layer_root))


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="build_lambda_layer.py",
description="Build the AWS Durable Execution SDK Python Lambda layer.",
)
subparsers = parser.add_subparsers(dest="command", required=True)

build_parser = subparsers.add_parser("build", help="build a Lambda layer zip")
build_parser.add_argument("--output", type=Path, required=True)
build_parser.add_argument(
"--target-python",
required=True,
help="Lambda Python minor version, for example 3.12",
)
build_parser.add_argument(
"--architecture",
choices=["x86_64", "arm64"],
required=True,
help="Lambda instruction set architecture.",
)
build_parser.add_argument("--sdk-distribution", type=Path)
build_parser.add_argument("--otel-distribution", type=Path)
build_parser.add_argument(
"--sdk-requirement",
default="aws-durable-execution-sdk-python",
help="Package specifier used when --sdk-distribution is not provided.",
)
build_parser.add_argument(
"--otel-requirement",
default="aws-durable-execution-sdk-python-otel",
help="Package specifier used when --otel-distribution is not provided.",
)
build_parser.add_argument(
"--build-dir",
type=Path,
help="Optional scratch directory. Existing contents are replaced.",
)

args = parser.parse_args(argv)
if args.command == "build":
output = build_layer(
BuildConfig(
output=args.output,
target_python=args.target_python,
architecture=args.architecture,
sdk_distribution=args.sdk_distribution,
otel_distribution=args.otel_distribution,
sdk_requirement=args.sdk_requirement,
otel_requirement=args.otel_requirement,
build_dir=args.build_dir,
)
)
print(output)
return 0

return 1


if __name__ == "__main__":
raise SystemExit(main())
77 changes: 77 additions & 0 deletions .github/scripts/tests/test_build_lambda_layer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from __future__ import annotations

import os
import subprocess
import sys
import zipfile
from pathlib import Path

import pytest


sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from build_lambda_layer import BuildConfig, build_layer


def test_build_layer_installs_dependencies_and_zips_lambda_layout(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
sdk_wheel = tmp_path / "aws_durable_execution_sdk_python-1.0.0-py3-none-any.whl"
otel_wheel = (
tmp_path / "aws_durable_execution_sdk_python_otel-1.0.0-py3-none-any.whl"
)
sdk_wheel.write_text("sdk")
otel_wheel.write_text("otel")
commands: list[list[str]] = []

def fake_run(command: list[str], check: bool) -> subprocess.CompletedProcess[str]:
commands.append(command)
target = Path(command[command.index("--target") + 1])
(target / "aws_durable_execution_sdk_python").mkdir()
(target / "aws_durable_execution_sdk_python" / "__init__.py").write_text("")
(target / "aws_durable_execution_sdk_python_otel").mkdir()
(target / "aws_durable_execution_sdk_python_otel" / "__init__.py").write_text(
""
)
return subprocess.CompletedProcess(command, 0)

monkeypatch.setattr(subprocess, "run", fake_run)

output = build_layer(
BuildConfig(
output=tmp_path / "layer.zip",
target_python="3.12",
architecture="arm64",
sdk_distribution=sdk_wheel,
otel_distribution=otel_wheel,
)
)

assert output == tmp_path / "layer.zip"
assert commands[0][commands[0].index("--platform") + 1] == "manylinux2014_aarch64"
assert commands[0][commands[0].index("--abi") + 1] == "cp312"
assert "--no-compile" in commands[0]
assert str(sdk_wheel) in commands[0]
assert str(otel_wheel) in commands[0]

with zipfile.ZipFile(output) as archive:
assert (
"python/aws_durable_execution_sdk_python/__init__.py" in archive.namelist()
)
assert (
"python/aws_durable_execution_sdk_python_otel/__init__.py"
in archive.namelist()
)


def test_build_layer_rejects_unsupported_architecture(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="Unsupported architecture"):
build_layer(
BuildConfig(
output=tmp_path / "layer.zip",
target_python="3.12",
architecture="sparc",
)
)
Loading
Loading