Skip to content

Commit 479832b

Browse files
authored
ci: publish otel plugin lambda layer (#619)
* ci: publish otel plugin lambda layer * ci: make otel plugin layers public * ci: continue layer publishing across regions * ci: make layer publishing idempotent * ci: allow manual layer publishing * ci: reuse built layer artifacts * fix: reject layer output inside build directory * ci: harden layer release workflow * ci: verify lambda layer artifact hashes * ci: pin sdk version for otel-only layers * ci: move layer sdk pin to release metadata
1 parent 808e9a8 commit 479832b

8 files changed

Lines changed: 861 additions & 5 deletions

.github/lambda-layer-publish.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[layer]
2+
sdk-version = "1.8.0"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
from __future__ import annotations
6+
7+
import argparse
8+
import shutil
9+
import subprocess
10+
import sys
11+
import tempfile
12+
import zipfile
13+
from dataclasses import dataclass
14+
from pathlib import Path
15+
16+
17+
ARCHITECTURE_PLATFORMS = {
18+
"x86_64": "manylinux2014_x86_64",
19+
"arm64": "manylinux2014_aarch64",
20+
}
21+
SUPPORTED_PYTHON_VERSIONS = ("3.11", "3.12", "3.13", "3.14")
22+
23+
24+
@dataclass(frozen=True)
25+
class BuildConfig:
26+
output: Path
27+
target_python: str
28+
architecture: str
29+
sdk_distribution: Path
30+
otel_distribution: Path
31+
build_dir: Path | None = None
32+
33+
34+
def build_layer(config: BuildConfig) -> Path:
35+
"""Build a Lambda layer containing the SDK and OpenTelemetry plugin."""
36+
37+
_validate_config(config)
38+
39+
with tempfile.TemporaryDirectory() as temp_dir:
40+
work_dir = config.build_dir or Path(temp_dir) / "layer"
41+
_validate_output_location(config.output, work_dir)
42+
if work_dir.exists():
43+
shutil.rmtree(work_dir)
44+
layer_python_dir = work_dir / "python"
45+
layer_python_dir.mkdir(parents=True)
46+
47+
_install_layer_dependencies(config, layer_python_dir)
48+
_write_zip(config.output, work_dir)
49+
50+
return config.output
51+
52+
53+
def _validate_config(config: BuildConfig) -> None:
54+
if config.architecture not in ARCHITECTURE_PLATFORMS:
55+
supported = ", ".join(sorted(ARCHITECTURE_PLATFORMS))
56+
raise ValueError(
57+
f"Unsupported architecture: {config.architecture}. "
58+
f"Supported architectures: {supported}"
59+
)
60+
61+
if config.target_python not in SUPPORTED_PYTHON_VERSIONS:
62+
supported = ", ".join(SUPPORTED_PYTHON_VERSIONS)
63+
raise ValueError(
64+
f"Unsupported Python version: {config.target_python}. "
65+
f"Supported versions: {supported}"
66+
)
67+
68+
for distribution in (config.sdk_distribution, config.otel_distribution):
69+
if not distribution.is_file():
70+
raise FileNotFoundError(distribution)
71+
72+
73+
def _validate_output_location(output: Path, build_dir: Path) -> None:
74+
if output.resolve().is_relative_to(build_dir.resolve()):
75+
raise ValueError("Layer output must be outside the build directory")
76+
77+
78+
def _install_layer_dependencies(config: BuildConfig, target_dir: Path) -> None:
79+
python_version = config.target_python
80+
abi = f"cp{python_version.replace('.', '')}"
81+
platform = ARCHITECTURE_PLATFORMS[config.architecture]
82+
83+
command = [
84+
sys.executable,
85+
"-m",
86+
"pip",
87+
"install",
88+
"--upgrade",
89+
"--target",
90+
str(target_dir),
91+
"--platform",
92+
platform,
93+
"--implementation",
94+
"cp",
95+
"--python-version",
96+
python_version,
97+
"--abi",
98+
abi,
99+
"--only-binary",
100+
":all:",
101+
"--no-compile",
102+
str(config.sdk_distribution),
103+
str(config.otel_distribution),
104+
]
105+
subprocess.run(command, check=True)
106+
107+
108+
def _write_zip(output: Path, layer_root: Path) -> None:
109+
output.parent.mkdir(parents=True, exist_ok=True)
110+
if output.exists():
111+
output.unlink()
112+
113+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
114+
for path in sorted(layer_root.rglob("*")):
115+
if path.is_file():
116+
archive.write(path, path.relative_to(layer_root))
117+
118+
119+
def main(argv: list[str] | None = None) -> int:
120+
parser = argparse.ArgumentParser(
121+
prog="build_lambda_layer.py",
122+
description="Build the AWS Durable Execution SDK OTel plugin Lambda layer.",
123+
)
124+
parser.add_argument("--output", type=Path, required=True)
125+
parser.add_argument(
126+
"--target-python",
127+
choices=SUPPORTED_PYTHON_VERSIONS,
128+
required=True,
129+
help="Lambda Python minor version.",
130+
)
131+
parser.add_argument(
132+
"--architecture",
133+
choices=sorted(ARCHITECTURE_PLATFORMS),
134+
required=True,
135+
help="Lambda instruction set architecture.",
136+
)
137+
parser.add_argument("--sdk-distribution", type=Path, required=True)
138+
parser.add_argument("--otel-distribution", type=Path, required=True)
139+
parser.add_argument(
140+
"--build-dir",
141+
type=Path,
142+
help="Optional scratch directory. Existing contents are replaced.",
143+
)
144+
145+
args = parser.parse_args(argv)
146+
output = build_layer(
147+
BuildConfig(
148+
output=args.output,
149+
target_python=args.target_python,
150+
architecture=args.architecture,
151+
sdk_distribution=args.sdk_distribution,
152+
otel_distribution=args.otel_distribution,
153+
build_dir=args.build_dir,
154+
)
155+
)
156+
print(output)
157+
return 0
158+
159+
160+
if __name__ == "__main__":
161+
raise SystemExit(main())
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
from __future__ import annotations
6+
7+
import argparse
8+
import tomllib
9+
from pathlib import Path
10+
11+
12+
def _read_pinned_sdk_version(metadata_path: Path) -> str:
13+
with metadata_path.open("rb") as file:
14+
metadata = tomllib.load(file)
15+
16+
try:
17+
pinned_version = metadata["layer"]["sdk-version"]
18+
except KeyError as error:
19+
raise ValueError(f"{metadata_path}: missing layer.sdk-version") from error
20+
21+
if not isinstance(pinned_version, str) or not pinned_version:
22+
raise ValueError(f"{metadata_path}: layer.sdk-version must be a string")
23+
return pinned_version
24+
25+
26+
def resolve_layer_sdk_version(
27+
event_name: str,
28+
release_tag: str,
29+
source_sdk_version: str,
30+
metadata_path: Path,
31+
) -> str:
32+
"""Resolve the SDK version bundled in the OTel plugin Lambda layer."""
33+
34+
pinned_version = _read_pinned_sdk_version(metadata_path)
35+
sdk_release = any(part.startswith("sdk-v") for part in release_tag.split(","))
36+
37+
if event_name == "release" and not sdk_release:
38+
return pinned_version
39+
40+
if event_name == "release" and pinned_version != source_sdk_version:
41+
raise ValueError(
42+
f"New SDK releases must update layer.sdk-version to {source_sdk_version}"
43+
)
44+
45+
return source_sdk_version
46+
47+
48+
def main(argv: list[str] | None = None) -> int:
49+
parser = argparse.ArgumentParser(
50+
description="Resolve the SDK version for the OTel plugin Lambda layer."
51+
)
52+
parser.add_argument("--event-name", required=True)
53+
parser.add_argument("--release-tag", default="")
54+
parser.add_argument("--source-sdk-version", required=True)
55+
parser.add_argument("--metadata", type=Path, required=True)
56+
args = parser.parse_args(argv)
57+
58+
print(
59+
resolve_layer_sdk_version(
60+
event_name=args.event_name,
61+
release_tag=args.release_tag,
62+
source_sdk_version=args.source_sdk_version,
63+
metadata_path=args.metadata,
64+
)
65+
)
66+
return 0
67+
68+
69+
if __name__ == "__main__":
70+
raise SystemExit(main())
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
import sys
6+
import zipfile
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
12+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
13+
14+
from build_lambda_layer import BuildConfig, build_layer
15+
16+
17+
def test_build_layer_installs_dependencies_and_zips_lambda_layout(
18+
monkeypatch: pytest.MonkeyPatch,
19+
tmp_path: Path,
20+
) -> None:
21+
sdk_wheel = tmp_path / "aws_durable_execution_sdk_python-1.0.0-py3-none-any.whl"
22+
otel_wheel = (
23+
tmp_path / "aws_durable_execution_sdk_python_otel-1.0.0-py3-none-any.whl"
24+
)
25+
sdk_wheel.write_text("sdk")
26+
otel_wheel.write_text("otel")
27+
commands: list[list[str]] = []
28+
29+
def fake_run(command: list[str], check: bool) -> subprocess.CompletedProcess[str]:
30+
commands.append(command)
31+
target = Path(command[command.index("--target") + 1])
32+
(target / "aws_durable_execution_sdk_python").mkdir()
33+
(target / "aws_durable_execution_sdk_python" / "__init__.py").write_text("")
34+
(target / "aws_durable_execution_sdk_python_otel").mkdir()
35+
(target / "aws_durable_execution_sdk_python_otel" / "__init__.py").write_text(
36+
""
37+
)
38+
return subprocess.CompletedProcess(command, 0)
39+
40+
monkeypatch.setattr(subprocess, "run", fake_run)
41+
42+
output = build_layer(
43+
BuildConfig(
44+
output=tmp_path / "layer.zip",
45+
target_python="3.12",
46+
architecture="arm64",
47+
sdk_distribution=sdk_wheel,
48+
otel_distribution=otel_wheel,
49+
)
50+
)
51+
52+
assert output == tmp_path / "layer.zip"
53+
assert commands[0][commands[0].index("--platform") + 1] == "manylinux2014_aarch64"
54+
assert commands[0][commands[0].index("--abi") + 1] == "cp312"
55+
assert "--no-compile" in commands[0]
56+
assert str(sdk_wheel) in commands[0]
57+
assert str(otel_wheel) in commands[0]
58+
59+
with zipfile.ZipFile(output) as archive:
60+
assert (
61+
"python/aws_durable_execution_sdk_python/__init__.py" in archive.namelist()
62+
)
63+
assert (
64+
"python/aws_durable_execution_sdk_python_otel/__init__.py"
65+
in archive.namelist()
66+
)
67+
68+
69+
@pytest.mark.parametrize(
70+
("target_python", "architecture", "error"),
71+
[
72+
("3.10", "x86_64", "Unsupported Python version"),
73+
("3.12", "sparc", "Unsupported architecture"),
74+
],
75+
)
76+
def test_build_layer_rejects_unsupported_targets(
77+
target_python: str,
78+
architecture: str,
79+
error: str,
80+
tmp_path: Path,
81+
) -> None:
82+
sdk_wheel = tmp_path / "sdk.whl"
83+
otel_wheel = tmp_path / "otel.whl"
84+
sdk_wheel.write_text("sdk")
85+
otel_wheel.write_text("otel")
86+
87+
with pytest.raises(ValueError, match=error):
88+
build_layer(
89+
BuildConfig(
90+
output=tmp_path / "layer.zip",
91+
target_python=target_python,
92+
architecture=architecture,
93+
sdk_distribution=sdk_wheel,
94+
otel_distribution=otel_wheel,
95+
)
96+
)
97+
98+
99+
def test_build_layer_requires_built_distributions(tmp_path: Path) -> None:
100+
with pytest.raises(FileNotFoundError):
101+
build_layer(
102+
BuildConfig(
103+
output=tmp_path / "layer.zip",
104+
target_python="3.13",
105+
architecture="x86_64",
106+
sdk_distribution=tmp_path / "missing-sdk.whl",
107+
otel_distribution=tmp_path / "missing-otel.whl",
108+
)
109+
)
110+
111+
112+
def test_build_layer_rejects_output_inside_build_directory(tmp_path: Path) -> None:
113+
sdk_wheel = tmp_path / "sdk.whl"
114+
otel_wheel = tmp_path / "otel.whl"
115+
sdk_wheel.write_text("sdk")
116+
otel_wheel.write_text("otel")
117+
build_dir = tmp_path / "layer"
118+
119+
with pytest.raises(ValueError, match="outside the build directory"):
120+
build_layer(
121+
BuildConfig(
122+
output=build_dir / "layer.zip",
123+
target_python="3.13",
124+
architecture="x86_64",
125+
sdk_distribution=sdk_wheel,
126+
otel_distribution=otel_wheel,
127+
build_dir=build_dir,
128+
)
129+
)

0 commit comments

Comments
 (0)