repro-lambda 0.1.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/build.yml +4 -7
- repro_lambda-0.2.1/CHANGELOG.md +72 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/PKG-INFO +11 -5
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/README.md +10 -4
- repro_lambda-0.2.1/SETUP.md +482 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/pyproject.toml +5 -1
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/build.py +62 -17
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/cli.py +6 -0
- repro_lambda-0.2.1/src/repro_lambda/docker_runner.py +290 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/hasher.py +20 -1
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/manifest.py +36 -8
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/source_stager.py +13 -0
- repro_lambda-0.2.1/src/repro_lambda/verify.py +85 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/zip_packager.py +8 -0
- repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/index.js +4 -0
- repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +18 -0
- repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/package.json +9 -0
- repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/lambdas.toml +18 -0
- repro_lambda-0.2.1/tests/test_build_nodejs.py +99 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_lock.py +29 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_smoke.py +1 -1
- repro_lambda-0.2.1/tests/test_docker_runner_nodejs.py +89 -0
- repro_lambda-0.2.1/tests/test_e2e_nodejs_lambda.py +75 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_hasher.py +108 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_manifest.py +64 -0
- repro_lambda-0.2.1/tests/test_python_byte_compat_regression.py +109 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_source_stager.py +28 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_verify.py +61 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_zip_packager.py +18 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/uv.lock +1 -1
- repro_lambda-0.1.0/CHANGELOG.md +0 -26
- repro_lambda-0.1.0/src/repro_lambda/docker_runner.py +0 -122
- repro_lambda-0.1.0/src/repro_lambda/verify.py +0 -61
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/ci.yml +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/publish.yml +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.gitignore +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/LICENSE +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/catalog.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/s3_uploader.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/__init__.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/conftest.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_build_integration.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_catalog.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_docker_runner.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_s3_uploader.py +0 -0
|
@@ -9,7 +9,7 @@ on:
|
|
|
9
9
|
description: Path to lambdas.toml in the caller repo.
|
|
10
10
|
repro_lambda_version:
|
|
11
11
|
type: string
|
|
12
|
-
default: "0.1
|
|
12
|
+
default: "0.2.1"
|
|
13
13
|
description: Pinned repro-lambda PyPI version.
|
|
14
14
|
secrets:
|
|
15
15
|
aws-dev-role-arn:
|
|
@@ -55,9 +55,6 @@ jobs:
|
|
|
55
55
|
- uses: actions/checkout@v4
|
|
56
56
|
- uses: astral-sh/setup-uv@v3
|
|
57
57
|
|
|
58
|
-
- name: Install repro-lambda
|
|
59
|
-
run: uv pip install --system "repro-lambda==${{ inputs.repro_lambda_version }}"
|
|
60
|
-
|
|
61
58
|
- name: Configure AWS credentials (dev)
|
|
62
59
|
uses: aws-actions/configure-aws-credentials@v4
|
|
63
60
|
with:
|
|
@@ -67,11 +64,11 @@ jobs:
|
|
|
67
64
|
- name: Build (dev bucket)
|
|
68
65
|
env:
|
|
69
66
|
REPRO_LAMBDA_BUCKET: dev-ctf-lambda-artifacts
|
|
70
|
-
run: repro-lambda build --manifest "${{ inputs.manifest_path }}"
|
|
67
|
+
run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}"
|
|
71
68
|
|
|
72
69
|
- name: Verify reproducible (PR only)
|
|
73
70
|
if: github.event_name == 'pull_request'
|
|
74
|
-
run: repro-lambda build --manifest "${{ inputs.manifest_path }}" --verify --dry-run
|
|
71
|
+
run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}" --verify --dry-run
|
|
75
72
|
|
|
76
73
|
- name: Configure AWS credentials (prod)
|
|
77
74
|
if: github.ref == 'refs/heads/master' && secrets.aws-prod-role-arn != ''
|
|
@@ -84,7 +81,7 @@ jobs:
|
|
|
84
81
|
if: github.ref == 'refs/heads/master' && secrets.aws-prod-role-arn != ''
|
|
85
82
|
env:
|
|
86
83
|
REPRO_LAMBDA_BUCKET: prod-ctf-lambda-artifacts
|
|
87
|
-
run: repro-lambda build --manifest "${{ inputs.manifest_path }}"
|
|
84
|
+
run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}"
|
|
88
85
|
|
|
89
86
|
- name: Commit catalog drift (master only, dev bot)
|
|
90
87
|
if: github.ref == 'refs/heads/master'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.2.1 - 2026-05-27
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- CI workflow: `uvx --from "repro-lambda==<v>" repro-lambda <args>` replaces `uv pip install --system "repro-lambda==<v>"`. uv 0.11+ deprecates the `uv pip` legacy interface for install/uninstall/sync.
|
|
7
|
+
|
|
8
|
+
### Docs
|
|
9
|
+
- README install instruction switches to `uv tool install repro-lambda` (plus `uvx repro-lambda` ephemeral alternative).
|
|
10
|
+
- SETUP.md examples bumped to `@v0.2.1` / `repro_lambda_version: "0.2.1"`.
|
|
11
|
+
|
|
12
|
+
### Consumer migration
|
|
13
|
+
- Consumer repos must bump their workflow ref to `uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1` to receive the uvx-based install. The v0.2.0 workflow ref still works but invokes the deprecated install command.
|
|
14
|
+
|
|
15
|
+
## v0.2.0 - 2026-05-27
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Node.js Lambda packaging (`nodejs20.x`, `nodejs22.x`) via `npm ci --omit=dev --ignore-scripts --cpu=${arch} --os=linux` in the digest-pinned Node base image.
|
|
19
|
+
- Two-container Node build: install in the Node base image, pack the resulting `pkg/` directory inside the digest-pinned Python base image (the Python image's zlib is the only deflate implementation invoked, so macOS arm64 hosts and Linux x86_64 CI produce byte-identical output).
|
|
20
|
+
- `BuilderConfig.base_image_nodejs` (required when any lambda uses `package_manager = "npm"`).
|
|
21
|
+
- `LambdaSpec.package_json` (required for npm specs) + `package_json_resolved` property.
|
|
22
|
+
- `ARCH_TO_NPM_CPU` mapping (`arm64` -> `arm64`, `x86_64` -> `x64`).
|
|
23
|
+
- `build_nodejs_lambda` + `install_nodejs_dependencies` + `pack_in_python_sidecar` in `docker_runner`.
|
|
24
|
+
- `stage_source(... extra_files=[(host_path, dest_relname), ...])` for staging artifacts outside `source_dir` (e.g. `package.json`, `package-lock.json`).
|
|
25
|
+
- `compute_content_hash(... extra_files=...)` keyed by destination relname so npm `package.json` edits bump the cache key.
|
|
26
|
+
- SETUP.md sections for Node.js + Lambda@Edge usage + caveats.
|
|
27
|
+
- Docker-gated end-to-end Node.js reproducibility test (`test_e2e_nodejs_lambda.py`) using a `tslib@2.7.0` fixture.
|
|
28
|
+
- Python byte-compat regression test (`test_python_byte_compat_regression.py`) pinning v0.1 zip output against a digest-pinned base image.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `build.py` and `verify.py` route per `spec.package_manager` (pip | npm). Both pre-stage source + extras BEFORE the cache-key hash so cache-hit and cache-miss branches see the same hash inputs.
|
|
32
|
+
- `manifest.py` accepts `nodejs20.x` and `nodejs22.x` runtimes and `package_manager = "npm"`.
|
|
33
|
+
- `lock` subcommand skips npm specs (npm uses package-lock.json directly; regenerate with `npm install` upstream).
|
|
34
|
+
- `zip_packager.pack_directory` skips symlinks with a stderr warning (zip cannot preserve link semantics).
|
|
35
|
+
- Docker `--user $(id -u):$(id -g)` on POSIX (skipped on Windows; `sys.platform == "win32"` gate).
|
|
36
|
+
- `_PYTHON_INSTALL_SCRIPT` now declares the full pip platform quartet (`--platform`, `--abi`, `--python-version`, `--implementation`) and strips `REQUESTED` files alongside `RECORD` / `INSTALLER` / `direct_url.json`. Module-level invariance assert keeps `ARCH_TO_DOCKER_PLATFORM` / `ARCH_TO_PIP_PLATFORM` / `ARCH_TO_NPM_CPU` keys in lockstep.
|
|
37
|
+
|
|
38
|
+
### Compatibility
|
|
39
|
+
- v0.1 zip byte-output preserved (regression-tested by `test_python_byte_compat_regression.py` once the operator records the v0.1.0 reference sha against the pinned base image digest).
|
|
40
|
+
- Content-hash key shifts ONCE at the v0.1 -> v0.2 cut-line because the lockfile is now hashed via the unified `extra_files` channel. v0.1-built zips remain pullable by their old keys (content-addressed S3 storage), so the shift only affects the next rebuild.
|
|
41
|
+
|
|
42
|
+
### Known caveats
|
|
43
|
+
- npm workspaces NOT supported (single `package.json` per Lambda only).
|
|
44
|
+
- Native deps must ship a `linux-${arch}` binary via `optionalDependencies` in `package-lock.json`; npm cannot cross-compile native modules.
|
|
45
|
+
- Symlinks inside `source_dir` are skipped (zip cannot preserve link semantics). Replace with file contents if your build relies on them.
|
|
46
|
+
- Per-arch lockfiles remain Python-only.
|
|
47
|
+
- Windows host: the `--user` flag is skipped; Docker Desktop's default user mapping applies.
|
|
48
|
+
|
|
49
|
+
## v0.1.0 - 2026-05-27
|
|
50
|
+
|
|
51
|
+
Initial public release.
|
|
52
|
+
|
|
53
|
+
### Features
|
|
54
|
+
|
|
55
|
+
- Python 3.11/3.12/3.13 Lambda packaging with `pip install --require-hashes`
|
|
56
|
+
- Byte-reproducible zips via deterministic `zipfile.ZipFile` writes
|
|
57
|
+
(sorted entries, fixed mtime 1980-01-01, 0o755 dirs / 0o644 files / 0o755 executables)
|
|
58
|
+
- Content-hash sha256 cache key (source tree + lockfile + spec + base image digest + builder version)
|
|
59
|
+
- Idempotent S3 upload via `If-None-Match=*`, designed for bucket-policy-enforced immutability
|
|
60
|
+
- `--verify` two-pass byte-reproducibility check
|
|
61
|
+
- `--dry-run` for hash + catalog inspection
|
|
62
|
+
- `--allow-dirty` for local iteration
|
|
63
|
+
- `builds/catalog.json` with bounded 10-entry history per lambda
|
|
64
|
+
- Per-arch lockfile generation via `uv pip compile`
|
|
65
|
+
- Reusable GitHub Actions workflow at `.github/workflows/build.yml`
|
|
66
|
+
- Native arm64 + x86_64 build matrix (no QEMU)
|
|
67
|
+
|
|
68
|
+
### Not yet supported (planned for v0.2)
|
|
69
|
+
|
|
70
|
+
- Node.js / npm packaging
|
|
71
|
+
- Lambda@Edge-specific constraints (us-east-1 routing, no env vars)
|
|
72
|
+
- Rust runtime
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repro-lambda
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf.
|
|
5
5
|
Project-URL: Homepage, https://github.com/antonbabenko/repro-lambda
|
|
6
6
|
Project-URL: Repository, https://github.com/antonbabenko/repro-lambda
|
|
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
# repro-lambda
|
|
34
34
|
|
|
35
35
|
Build reproducible AWS Lambda packages outside Terraform, optimized for
|
|
36
|
-
[terraform-aws-lambda
|
|
36
|
+
[terraform-aws-lambda](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) by [serverless.tf](https://serverless.tf/).
|
|
37
37
|
|
|
38
38
|
Produces byte-identical zip files across local dev (macOS) and CI (Linux),
|
|
39
39
|
uploads to S3 by content-hash key, and lets Terraform read `s3_existing_package`
|
|
@@ -54,10 +54,10 @@ See `docs/` for full design.
|
|
|
54
54
|
|
|
55
55
|
## Release
|
|
56
56
|
|
|
57
|
-
Releases are tag-driven. To cut v0.
|
|
57
|
+
Releases are tag-driven. To cut v0.2.1:
|
|
58
58
|
|
|
59
|
-
git tag v0.
|
|
60
|
-
git push origin v0.
|
|
59
|
+
git tag v0.2.1
|
|
60
|
+
git push origin v0.2.1
|
|
61
61
|
|
|
62
62
|
The `publish.yml` workflow uses PyPI Trusted Publishing (OIDC) — no PyPI token
|
|
63
63
|
needed in repo secrets. Configure once via PyPI's "Publishing" panel:
|
|
@@ -66,3 +66,9 @@ needed in repo secrets. Configure once via PyPI's "Publishing" panel:
|
|
|
66
66
|
- Repository: `repro-lambda`
|
|
67
67
|
- Workflow: `publish.yml`
|
|
68
68
|
- Environment: (leave blank)
|
|
69
|
+
|
|
70
|
+
## Setup
|
|
71
|
+
|
|
72
|
+
See [SETUP.md](./SETUP.md) for a copy-paste-able guide to provisioning the
|
|
73
|
+
S3 buckets, IAM OIDC role, and CI workflow needed to use `repro-lambda` in
|
|
74
|
+
your project.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# repro-lambda
|
|
2
2
|
|
|
3
3
|
Build reproducible AWS Lambda packages outside Terraform, optimized for
|
|
4
|
-
[terraform-aws-lambda
|
|
4
|
+
[terraform-aws-lambda](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) by [serverless.tf](https://serverless.tf/).
|
|
5
5
|
|
|
6
6
|
Produces byte-identical zip files across local dev (macOS) and CI (Linux),
|
|
7
7
|
uploads to S3 by content-hash key, and lets Terraform read `s3_existing_package`
|
|
@@ -22,10 +22,10 @@ See `docs/` for full design.
|
|
|
22
22
|
|
|
23
23
|
## Release
|
|
24
24
|
|
|
25
|
-
Releases are tag-driven. To cut v0.
|
|
25
|
+
Releases are tag-driven. To cut v0.2.1:
|
|
26
26
|
|
|
27
|
-
git tag v0.
|
|
28
|
-
git push origin v0.
|
|
27
|
+
git tag v0.2.1
|
|
28
|
+
git push origin v0.2.1
|
|
29
29
|
|
|
30
30
|
The `publish.yml` workflow uses PyPI Trusted Publishing (OIDC) — no PyPI token
|
|
31
31
|
needed in repo secrets. Configure once via PyPI's "Publishing" panel:
|
|
@@ -34,3 +34,9 @@ needed in repo secrets. Configure once via PyPI's "Publishing" panel:
|
|
|
34
34
|
- Repository: `repro-lambda`
|
|
35
35
|
- Workflow: `publish.yml`
|
|
36
36
|
- Environment: (leave blank)
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
See [SETUP.md](./SETUP.md) for a copy-paste-able guide to provisioning the
|
|
41
|
+
S3 buckets, IAM OIDC role, and CI workflow needed to use `repro-lambda` in
|
|
42
|
+
your project.
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# Setting up `repro-lambda` for your project
|
|
2
|
+
|
|
3
|
+
`repro-lambda` builds and uploads Lambda artifacts to S3 outside of Terraform.
|
|
4
|
+
For Terraform to read those artifacts via `s3_existing_package`, you need to
|
|
5
|
+
provision the supporting AWS infrastructure once per AWS account and region.
|
|
6
|
+
|
|
7
|
+
This guide is a copy-paste-able reference. Adapt the names and account IDs to
|
|
8
|
+
your environment.
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
For each environment (e.g. `dev`, `prod`) you typically need:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
${env}-my-lambda-artifacts S3 bucket, region = eu-west-1 (or your primary region)
|
|
16
|
+
${env}-my-lambda-artifacts-us-east-1 S3 bucket, region = us-east-1 (for Lambda@Edge — optional)
|
|
17
|
+
|
|
18
|
+
gha-lambda-builder-${env} IAM role, assumed via GitHub OIDC by source-repo CI to upload
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both buckets enforce **key-level immutability** via bucket policy:
|
|
22
|
+
|
|
23
|
+
- `s3:PutObject` denied unless `If-None-Match=*` is set (writes are first-write-wins)
|
|
24
|
+
- `s3:DeleteObject` and `s3:DeleteObjectVersion` denied
|
|
25
|
+
- The Lambda service is allowed `s3:GetObject` so functions can load their zip
|
|
26
|
+
|
|
27
|
+
Once an artifact with a content-hash key (`lambdas/<name>/<sha256>.zip`) is
|
|
28
|
+
uploaded, the key is permanently bound to those bytes. `repro-lambda` treats
|
|
29
|
+
HTTP 412 PreconditionFailed on a duplicate upload as success.
|
|
30
|
+
|
|
31
|
+
## Terraform — per-account bootstrap
|
|
32
|
+
|
|
33
|
+
The Terraform below assumes you already have a configured AWS provider in the
|
|
34
|
+
target account. Drop it into your bootstrap directory (or anywhere you provision
|
|
35
|
+
account-wide infrastructure that has no dependencies on other Terraform state).
|
|
36
|
+
|
|
37
|
+
```hcl
|
|
38
|
+
# us-east-1 is needed only for Lambda@Edge artifacts. Skip if you don't use L@E.
|
|
39
|
+
provider "aws" {
|
|
40
|
+
alias = "us_east_1"
|
|
41
|
+
region = "us-east-1"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
locals {
|
|
45
|
+
env = "dev" # change per environment
|
|
46
|
+
lambda_artifacts_bucket_primary = "${local.env}-my-lambda-artifacts"
|
|
47
|
+
lambda_artifacts_bucket_us_east_1 = "${local.env}-my-lambda-artifacts-us-east-1"
|
|
48
|
+
|
|
49
|
+
# GitHub OIDC subjects allowed to assume the builder role.
|
|
50
|
+
# Pattern: repo:<owner>/<repo>:* — narrow to specific refs in production.
|
|
51
|
+
lambda_builder_oidc_subjects = [
|
|
52
|
+
"repo:my-org/my-source-repo:*",
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Immutability policy applied to every artifact bucket.
|
|
57
|
+
data "aws_iam_policy_document" "lambda_artifacts_immutability" {
|
|
58
|
+
for_each = toset([
|
|
59
|
+
local.lambda_artifacts_bucket_primary,
|
|
60
|
+
local.lambda_artifacts_bucket_us_east_1,
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
statement {
|
|
64
|
+
sid = "DenyOverwrites"
|
|
65
|
+
effect = "Deny"
|
|
66
|
+
|
|
67
|
+
principals {
|
|
68
|
+
type = "*"
|
|
69
|
+
identifiers = ["*"]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
actions = ["s3:PutObject"]
|
|
73
|
+
resources = ["arn:aws:s3:::${each.value}/*"]
|
|
74
|
+
|
|
75
|
+
condition {
|
|
76
|
+
test = "StringNotEquals"
|
|
77
|
+
variable = "s3:If-None-Match"
|
|
78
|
+
values = ["*"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
statement {
|
|
83
|
+
sid = "DenyDelete"
|
|
84
|
+
effect = "Deny"
|
|
85
|
+
|
|
86
|
+
principals {
|
|
87
|
+
type = "*"
|
|
88
|
+
identifiers = ["*"]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
actions = [
|
|
92
|
+
"s3:DeleteObject",
|
|
93
|
+
"s3:DeleteObjectVersion",
|
|
94
|
+
]
|
|
95
|
+
resources = ["arn:aws:s3:::${each.value}/*"]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
statement {
|
|
99
|
+
sid = "AllowLambdaServiceRead"
|
|
100
|
+
effect = "Allow"
|
|
101
|
+
|
|
102
|
+
principals {
|
|
103
|
+
type = "Service"
|
|
104
|
+
identifiers = ["lambda.amazonaws.com"]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
actions = ["s3:GetObject"]
|
|
108
|
+
resources = ["arn:aws:s3:::${each.value}/*"]
|
|
109
|
+
|
|
110
|
+
condition {
|
|
111
|
+
test = "StringEquals"
|
|
112
|
+
variable = "aws:SourceAccount"
|
|
113
|
+
values = [data.aws_caller_identity.current.account_id]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
data "aws_caller_identity" "current" {}
|
|
119
|
+
|
|
120
|
+
# Primary-region bucket
|
|
121
|
+
module "lambda_artifacts_bucket_primary" {
|
|
122
|
+
source = "terraform-aws-modules/s3-bucket/aws"
|
|
123
|
+
version = "~> 4.0"
|
|
124
|
+
|
|
125
|
+
bucket = local.lambda_artifacts_bucket_primary
|
|
126
|
+
|
|
127
|
+
versioning = {
|
|
128
|
+
enabled = false # sha-as-key IS the versioning
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
server_side_encryption_configuration = {
|
|
132
|
+
rule = {
|
|
133
|
+
apply_server_side_encryption_by_default = {
|
|
134
|
+
sse_algorithm = "AES256"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
attach_policy = true
|
|
140
|
+
policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_primary].json
|
|
141
|
+
|
|
142
|
+
block_public_acls = true
|
|
143
|
+
block_public_policy = true
|
|
144
|
+
ignore_public_acls = true
|
|
145
|
+
restrict_public_buckets = true
|
|
146
|
+
|
|
147
|
+
force_destroy = false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# us-east-1 bucket — only needed for Lambda@Edge. Remove if not using L@E.
|
|
151
|
+
module "lambda_artifacts_bucket_us_east_1" {
|
|
152
|
+
source = "terraform-aws-modules/s3-bucket/aws"
|
|
153
|
+
version = "~> 4.0"
|
|
154
|
+
|
|
155
|
+
providers = {
|
|
156
|
+
aws = aws.us_east_1
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
bucket = local.lambda_artifacts_bucket_us_east_1
|
|
160
|
+
|
|
161
|
+
versioning = {
|
|
162
|
+
enabled = false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
server_side_encryption_configuration = {
|
|
166
|
+
rule = {
|
|
167
|
+
apply_server_side_encryption_by_default = {
|
|
168
|
+
sse_algorithm = "AES256"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
attach_policy = true
|
|
174
|
+
policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_us_east_1].json
|
|
175
|
+
|
|
176
|
+
block_public_acls = true
|
|
177
|
+
block_public_policy = true
|
|
178
|
+
ignore_public_acls = true
|
|
179
|
+
restrict_public_buckets = true
|
|
180
|
+
|
|
181
|
+
force_destroy = false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# OIDC role assumed by GitHub Actions in source repos to upload artifacts.
|
|
185
|
+
module "gha_lambda_builder_role" {
|
|
186
|
+
source = "terraform-aws-modules/iam/aws//modules/iam-role"
|
|
187
|
+
version = "~> 6.0"
|
|
188
|
+
|
|
189
|
+
name = "gha-lambda-builder-${local.env}"
|
|
190
|
+
|
|
191
|
+
enable_github_oidc = true
|
|
192
|
+
oidc_wildcard_subjects = local.lambda_builder_oidc_subjects
|
|
193
|
+
|
|
194
|
+
create_inline_policy = true
|
|
195
|
+
inline_policy_permissions = {
|
|
196
|
+
WriteLambdaArtifacts = {
|
|
197
|
+
actions = [
|
|
198
|
+
"s3:PutObject",
|
|
199
|
+
"s3:GetObject",
|
|
200
|
+
"s3:HeadObject",
|
|
201
|
+
"s3:ListBucket",
|
|
202
|
+
]
|
|
203
|
+
resources = [
|
|
204
|
+
module.lambda_artifacts_bucket_primary.s3_bucket_arn,
|
|
205
|
+
"${module.lambda_artifacts_bucket_primary.s3_bucket_arn}/*",
|
|
206
|
+
module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn,
|
|
207
|
+
"${module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn}/*",
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
output "lambda_artifacts_bucket_primary_arn" {
|
|
214
|
+
value = module.lambda_artifacts_bucket_primary.s3_bucket_arn
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
output "lambda_artifacts_bucket_us_east_1_arn" {
|
|
218
|
+
value = module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
output "gha_lambda_builder_role_arn" {
|
|
222
|
+
value = module.gha_lambda_builder_role.arn
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
> Note: if you only use a single region (no Lambda@Edge), delete the
|
|
227
|
+
> `lambda_artifacts_bucket_us_east_1` module and the `us_east_1` provider alias.
|
|
228
|
+
> The OIDC role policy only needs the primary bucket then.
|
|
229
|
+
|
|
230
|
+
Apply the Terraform once per environment. The bucket names and role ARN are
|
|
231
|
+
referenced by your source repos' CI workflows below.
|
|
232
|
+
|
|
233
|
+
## GitHub OIDC provider
|
|
234
|
+
|
|
235
|
+
If your account doesn't already have the GitHub Actions OIDC provider, the
|
|
236
|
+
`terraform-aws-modules/iam` collection includes a module for it:
|
|
237
|
+
|
|
238
|
+
```hcl
|
|
239
|
+
module "iam_github_oidc_provider" {
|
|
240
|
+
source = "terraform-aws-modules/iam/aws//modules/iam-oidc-provider"
|
|
241
|
+
version = "~> 6.0"
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The provider is shared per AWS account — declare it once, not per environment.
|
|
246
|
+
|
|
247
|
+
## Source-repo CI workflow
|
|
248
|
+
|
|
249
|
+
In each repo that builds a Lambda, add a thin caller workflow that delegates to
|
|
250
|
+
the reusable workflow in `antonbabenko/repro-lambda`:
|
|
251
|
+
|
|
252
|
+
```yaml
|
|
253
|
+
# .github/workflows/build-lambdas.yml
|
|
254
|
+
name: build-lambdas
|
|
255
|
+
|
|
256
|
+
on:
|
|
257
|
+
pull_request:
|
|
258
|
+
push:
|
|
259
|
+
branches: [master]
|
|
260
|
+
|
|
261
|
+
jobs:
|
|
262
|
+
build:
|
|
263
|
+
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1
|
|
264
|
+
with:
|
|
265
|
+
manifest_path: lambdas.toml
|
|
266
|
+
repro_lambda_version: "0.2.1"
|
|
267
|
+
secrets:
|
|
268
|
+
aws-dev-role-arn: ${{ secrets.AWS_DEV_ROLE_ARN }}
|
|
269
|
+
aws-prod-role-arn: ${{ secrets.AWS_PROD_ROLE_ARN }}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Store the role ARNs (from the Terraform outputs above) as repo or organization
|
|
273
|
+
secrets:
|
|
274
|
+
|
|
275
|
+
- `AWS_DEV_ROLE_ARN` — `gha-lambda-builder-dev` role from the dev account
|
|
276
|
+
- `AWS_PROD_ROLE_ARN` — `gha-lambda-builder-prod` role from the prod account
|
|
277
|
+
|
|
278
|
+
## Per-Lambda manifest
|
|
279
|
+
|
|
280
|
+
Each consumer repo defines a `lambdas.toml` at its root:
|
|
281
|
+
|
|
282
|
+
```toml
|
|
283
|
+
[[lambda]]
|
|
284
|
+
logical_name = "app"
|
|
285
|
+
source_dir = "src/app"
|
|
286
|
+
requirements_lock = "src/app/requirements.${arch}.lock"
|
|
287
|
+
runtime = "python3.13"
|
|
288
|
+
arch = "arm64"
|
|
289
|
+
handler = "app.lambda_handler"
|
|
290
|
+
region = "eu-west-1"
|
|
291
|
+
package_manager = "pip"
|
|
292
|
+
lambda_at_edge = false
|
|
293
|
+
hash_extra = ""
|
|
294
|
+
|
|
295
|
+
[builder]
|
|
296
|
+
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
|
|
297
|
+
include_patterns = ["**/*.py", "**/*.json"]
|
|
298
|
+
exclude_patterns = [".venv/**", "__pycache__/**", "*.pyc", ".git/**", ".env*"]
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Pin the `base_image_python` to a specific digest with `docker pull <image> &&
|
|
302
|
+
docker inspect --format='{{index .RepoDigests 0}}' <image>` — never use a
|
|
303
|
+
floating tag in production.
|
|
304
|
+
|
|
305
|
+
## Terraform consumer — `s3_existing_package`
|
|
306
|
+
|
|
307
|
+
In the Terraform that creates your Lambda function, point at the artifact in
|
|
308
|
+
S3 instead of building inline:
|
|
309
|
+
|
|
310
|
+
```hcl
|
|
311
|
+
locals {
|
|
312
|
+
lambda_manifest = jsondecode(file("${path.module}/builds/catalog.json"))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module "lambda_app" {
|
|
316
|
+
source = "terraform-aws-modules/lambda/aws"
|
|
317
|
+
version = "~> 8.0"
|
|
318
|
+
|
|
319
|
+
function_name = "my-app"
|
|
320
|
+
runtime = "python3.13"
|
|
321
|
+
architectures = ["arm64"]
|
|
322
|
+
handler = "app.lambda_handler"
|
|
323
|
+
publish = true
|
|
324
|
+
|
|
325
|
+
s3_existing_package = {
|
|
326
|
+
bucket = "${var.env}-my-lambda-artifacts"
|
|
327
|
+
key = "lambdas/app/${local.lambda_manifest.lambdas.app.current}.zip"
|
|
328
|
+
object_version = null
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
For Lambda@Edge, point at the `-us-east-1` bucket and set `lambda_at_edge = true`
|
|
334
|
+
in the Terraform module.
|
|
335
|
+
|
|
336
|
+
## Smoke test
|
|
337
|
+
|
|
338
|
+
After applying the bootstrap Terraform and configuring CI secrets:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
# In your consumer repo
|
|
342
|
+
gh workflow run build-lambdas.yml
|
|
343
|
+
# Wait for completion, then check that the artifact landed
|
|
344
|
+
aws s3 ls s3://dev-my-lambda-artifacts/lambdas/app/
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The first PR after migration should show a Terraform plan whose only diff is the
|
|
348
|
+
`s3_key` change. If you see `last_modified`, `qualified_arn`, `version`,
|
|
349
|
+
`local_file.archive_plan`, or `null_resource.archive` mentioned, the migration
|
|
350
|
+
is incomplete — review your Lambda module call and remove the legacy build
|
|
351
|
+
attributes (`source_path`, `build_in_docker`, `trigger_on_package_timestamp`,
|
|
352
|
+
`ignore_source_code_hash`, `hash_extra`, `local_existing_package`,
|
|
353
|
+
`store_on_s3`).
|
|
354
|
+
|
|
355
|
+
## Troubleshooting
|
|
356
|
+
|
|
357
|
+
- **`403 AccessDenied` on upload**: the OIDC role doesn't have `s3:PutObject` on
|
|
358
|
+
the bucket. Check the inline policy resources include both the bucket ARN and
|
|
359
|
+
`${bucket_arn}/*`.
|
|
360
|
+
- **`PreconditionFailed` on every upload**: the artifact already exists with
|
|
361
|
+
that sha. This is the expected cache-hit behavior; `repro-lambda` treats it
|
|
362
|
+
as success.
|
|
363
|
+
- **AWS CLI multipart upload fails with 403**: bucket policy denies multipart
|
|
364
|
+
parts. Pin the multipart threshold above your Lambda size cap:
|
|
365
|
+
`aws configure set s3.multipart_threshold 300MB`.
|
|
366
|
+
- **Plan still shows noisy diff after migration**: verify the consumer Terraform
|
|
367
|
+
removed every legacy attribute and that the catalog sha read by `jsondecode`
|
|
368
|
+
matches the sha in the S3 key. The plan diff should be exactly `~ s3_key =
|
|
369
|
+
"<old>.zip" -> "<new>.zip"` and nothing else.
|
|
370
|
+
|
|
371
|
+
## Node.js (npm) Lambdas
|
|
372
|
+
|
|
373
|
+
`repro-lambda` v0.2 adds Node.js Lambda packaging. The build runs `npm ci` in
|
|
374
|
+
the digest-pinned Node base image, then packs the resulting `pkg/` directory
|
|
375
|
+
inside the digest-pinned Python base image (Python is the only language with
|
|
376
|
+
deterministic-zip tooling pre-installed in the AWS Lambda runtime images, so
|
|
377
|
+
its zlib is the only deflate implementation invoked - macOS arm64 hosts and
|
|
378
|
+
Linux x86_64 CI produce byte-identical output).
|
|
379
|
+
|
|
380
|
+
### Manifest fields for npm specs
|
|
381
|
+
|
|
382
|
+
```toml
|
|
383
|
+
[[lambda]]
|
|
384
|
+
logical_name = "api"
|
|
385
|
+
source_dir = "src/api"
|
|
386
|
+
requirements_lock = "src/api/package-lock.json" # npm lockfile
|
|
387
|
+
package_json = "src/api/package.json" # REQUIRED for npm specs
|
|
388
|
+
runtime = "nodejs22.x" # or "nodejs20.x"
|
|
389
|
+
arch = "x86_64" # or "arm64"
|
|
390
|
+
handler = "index.handler"
|
|
391
|
+
region = "eu-west-1"
|
|
392
|
+
package_manager = "npm"
|
|
393
|
+
lambda_at_edge = false
|
|
394
|
+
hash_extra = ""
|
|
395
|
+
|
|
396
|
+
[builder]
|
|
397
|
+
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
|
|
398
|
+
base_image_nodejs = "public.ecr.aws/lambda/nodejs:22@sha256:<pinned-digest>"
|
|
399
|
+
include_patterns = ["**/*.js", "**/*.json"]
|
|
400
|
+
exclude_patterns = [".git/**", "node_modules/**", "*.md", "LICENSE*", "CHANGELOG*"]
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Pin both base images by digest:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
docker pull public.ecr.aws/lambda/nodejs:22
|
|
407
|
+
docker inspect --format='{{index .RepoDigests 0}}' public.ecr.aws/lambda/nodejs:22
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Lockfile regeneration
|
|
411
|
+
|
|
412
|
+
`repro-lambda lock` only regenerates per-arch Python lockfiles via `uv pip
|
|
413
|
+
compile`. For npm specs it prints `skip <name>: npm uses package-lock.json
|
|
414
|
+
directly`. Regenerate the npm lockfile upstream with:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
cd src/api
|
|
418
|
+
npm install --package-lock-only
|
|
419
|
+
git add package-lock.json
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The lockfile and `package.json` both contribute to the artifact content hash;
|
|
423
|
+
editing either bumps the S3 key.
|
|
424
|
+
|
|
425
|
+
## Lambda@Edge example
|
|
426
|
+
|
|
427
|
+
Lambda@Edge functions must be deployed to `us-east-1`, regardless of where the
|
|
428
|
+
CloudFront distribution serves traffic. Set `region = "us-east-1"` and
|
|
429
|
+
`lambda_at_edge = true` in the manifest; `repro-lambda` will upload to the
|
|
430
|
+
`*-us-east-1` artifact bucket automatically.
|
|
431
|
+
|
|
432
|
+
```toml
|
|
433
|
+
[[lambda]]
|
|
434
|
+
logical_name = "edge"
|
|
435
|
+
source_dir = "src/edge"
|
|
436
|
+
requirements_lock = "src/edge/package-lock.json"
|
|
437
|
+
package_json = "src/edge/package.json"
|
|
438
|
+
runtime = "nodejs22.x"
|
|
439
|
+
arch = "x86_64" # L@E currently requires x86_64
|
|
440
|
+
handler = "index.handler"
|
|
441
|
+
region = "us-east-1"
|
|
442
|
+
package_manager = "npm"
|
|
443
|
+
lambda_at_edge = true
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Consumer Terraform points at the us-east-1 bucket:
|
|
447
|
+
|
|
448
|
+
```hcl
|
|
449
|
+
module "lambda_edge" {
|
|
450
|
+
source = "terraform-aws-modules/lambda/aws"
|
|
451
|
+
version = "~> 8.0"
|
|
452
|
+
providers = { aws = aws.us_east_1 }
|
|
453
|
+
|
|
454
|
+
function_name = "my-edge"
|
|
455
|
+
runtime = "nodejs22.x"
|
|
456
|
+
architectures = ["x86_64"]
|
|
457
|
+
handler = "index.handler"
|
|
458
|
+
publish = true
|
|
459
|
+
lambda_at_edge = true
|
|
460
|
+
|
|
461
|
+
s3_existing_package = {
|
|
462
|
+
bucket = "${var.env}-my-lambda-artifacts-us-east-1"
|
|
463
|
+
key = "lambdas/edge/${local.lambda_manifest.lambdas.edge.current}.zip"
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## Caveats
|
|
469
|
+
|
|
470
|
+
- **No npm workspaces.** v0.2 supports a single `package.json` per Lambda. If
|
|
471
|
+
your repo uses workspaces, copy the published package into a single-package
|
|
472
|
+
layout per Lambda before invoking `repro-lambda`.
|
|
473
|
+
- **Native dependencies need `optionalDependencies` arms.** `npm ci --cpu=${arch}
|
|
474
|
+
--os=linux` cannot cross-compile native modules. A dep with native code must
|
|
475
|
+
ship a `linux-${arch}` binary via its `package-lock.json`
|
|
476
|
+
`optionalDependencies` arm (the lockfile-v3 standard mechanism). If it
|
|
477
|
+
doesn't, the build runs on the host arch and may produce a non-portable
|
|
478
|
+
artifact.
|
|
479
|
+
- **Symlinks in source are skipped.** `pack_directory` skips symlinks and
|
|
480
|
+
prints a stderr warning; the resulting zip cannot preserve link semantics.
|
|
481
|
+
If your build relies on symlinks (e.g. monorepo references), replace them
|
|
482
|
+
with the file contents.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "repro-lambda"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -56,6 +56,10 @@ target-version = "py311"
|
|
|
56
56
|
[tool.ruff.lint]
|
|
57
57
|
select = ["E", "F", "I", "B", "UP", "SIM"]
|
|
58
58
|
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
# Shell-script string literals inside docker_runner cannot be line-wrapped.
|
|
61
|
+
"src/repro_lambda/docker_runner.py" = ["E501"]
|
|
62
|
+
|
|
59
63
|
[tool.pytest.ini_options]
|
|
60
64
|
testpaths = ["tests"]
|
|
61
65
|
addopts = "-ra --strict-markers"
|