repro-lambda 0.4.0__tar.gz → 0.4.2__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.4.2/.github/workflows/move-major-tag.yml +28 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/CHANGELOG.md +11 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/PKG-INFO +1 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/SETUP.md +38 -10
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/pyproject.toml +1 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/build.py +7 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/docker_runner.py +8 -2
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/hasher.py +21 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/manifest.py +48 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/verify.py +2 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_docker_runner.py +2 -2
- repro_lambda-0.4.2/tests/test_per_lambda_builder.py +231 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/uv.lock +1 -1
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/build.yml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/ci.yml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/promote.yml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.github/workflows/publish.yml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.gitignore +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/LICENSE +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/README.md +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/catalog.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/cli.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/promote.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/s3_uploader.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/source_stager.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/src/repro_lambda/zip_packager.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/__init__.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/conftest.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_build_integration.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_build_nodejs.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_catalog.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_lock.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_cli_smoke.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_docker_runner_nodejs.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_e2e_nodejs_lambda.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_extra_files.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_hasher.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_manifest.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_promote.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_python_byte_compat_regression.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_s3_uploader.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_source_stager.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_verify.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_zip_excludes.py +0 -0
- {repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/test_zip_packager.py +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: move-major-tag
|
|
2
|
+
|
|
3
|
+
# On every semver release tag (vX.Y.Z), force-move the sliding MAJOR tag vX to it,
|
|
4
|
+
# so consumers can pin `@v0` (or `@v1` once 1.x ships) and always get the latest
|
|
5
|
+
# backward-compatible reusable workflows. While the project is 0.x the major is `v0`.
|
|
6
|
+
#
|
|
7
|
+
# Safe by construction: publish.yml triggers on `v*.*.*` only, so moving a non-semver
|
|
8
|
+
# tag (v0) never loop-triggers a publish; and a tag pushed by GITHUB_TOKEN does not
|
|
9
|
+
# retrigger any workflow.
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
tags: ["v*.*.*"]
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
move:
|
|
19
|
+
runs-on: ubuntu-24.04
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- name: Force-move sliding major tag
|
|
23
|
+
env:
|
|
24
|
+
TAG: ${{ github.ref_name }}
|
|
25
|
+
run: |
|
|
26
|
+
MAJOR="${TAG%%.*}" # v0.4.1 -> v0
|
|
27
|
+
git tag -f "$MAJOR" "$TAG"
|
|
28
|
+
git push -f origin "refs/tags/$MAJOR"
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.2 - 2026-06-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Per-lambda builder overrides: any `[[lambda]]` may now set `base_image_python`, `include_patterns`, or `exclude_patterns` to override the `[builder]` defaults for itself. An override fully REPLACES the matching default (lists are not merged); an unset field inherits `[builder]`. A per-lambda `base_image_python` must still be digest-pinned (validated at manifest load). This lets one lambda build on its own base image or filter its source more tightly than the others - e.g. a lambda that bundles a large prebuilt tree can narrow `include_patterns` to just its runtime modules so unrelated file changes no longer re-key its artifact. The resolved per-lambda builder (base-image digest + include/exclude lists + builder version) folds into the content hash, so changing an override re-keys only that lambda. Manifests with no per-lambda overrides resolve to the `[builder]` defaults unchanged. The builder version bump re-keys all content hashes, as expected.
|
|
7
|
+
|
|
8
|
+
## v0.4.1 - 2026-06-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Lower the pip `--platform` floor from `manylinux_2_28` to `manylinux_2_17` (manylinux2014) for both arches. pip's explicit `--platform` does not expand a higher manylinux tag down to lower-baseline wheels, so with `--only-binary=:all:` a `2_28` floor failed to find compiled wheels that ship only `manylinux_2_17` for a given Python/arch (e.g. `pydantic-core`). `manylinux_2_17` is the broadest baseline the AWS Lambda base images (Amazon Linux 2023, glibc 2.34) still run, and it matches `2_17` wheels plus any lower baseline. Pure-Python lambdas are unaffected (their `py3-none-any` wheels never depended on the platform). The builder version bump re-keys all content hashes, as expected.
|
|
12
|
+
|
|
3
13
|
## v0.4.0 - 2026-06-21
|
|
4
14
|
|
|
5
15
|
### Added
|
|
@@ -16,7 +26,7 @@
|
|
|
16
26
|
|
|
17
27
|
jobs:
|
|
18
28
|
promote:
|
|
19
|
-
uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@
|
|
29
|
+
uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v0
|
|
20
30
|
with:
|
|
21
31
|
source_sha: ${{ inputs.source_sha }}
|
|
22
32
|
promoter_role_arn: arn:aws:iam::<account>:role/<promoter-role>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repro-lambda
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
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
|
|
@@ -260,20 +260,21 @@ on:
|
|
|
260
260
|
|
|
261
261
|
jobs:
|
|
262
262
|
build:
|
|
263
|
-
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
|
|
263
|
+
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
|
|
264
264
|
with:
|
|
265
265
|
manifest_path: lambdas.toml
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
aws
|
|
266
|
+
aws_dev_role_arn: arn:aws:iam::<dev-account-id>:role/gha-lambda-builder-dev
|
|
267
|
+
dev_bucket: <env>-lambda-artifacts
|
|
268
|
+
# Optional - master-push upload to a prod bucket:
|
|
269
|
+
# aws_prod_role_arn: arn:aws:iam::<prod-account-id>:role/gha-lambda-builder-prod
|
|
270
|
+
# prod_bucket: <env>-lambda-artifacts
|
|
270
271
|
```
|
|
271
272
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
-
|
|
276
|
-
|
|
273
|
+
Pin the reusable workflow with the sliding major tag `@v0` - it auto-moves to the
|
|
274
|
+
latest backward-compatible 0.x release on every tag (switch to `@v1` once repro-lambda
|
|
275
|
+
ships 1.0). The role ARNs are not secrets: the boundary is the OIDC trust policy plus
|
|
276
|
+
the key-level bucket immutability, so they are plain inputs (only the account IDs,
|
|
277
|
+
which are public), not stored secrets.
|
|
277
278
|
|
|
278
279
|
## Per-Lambda manifest
|
|
279
280
|
|
|
@@ -302,6 +303,33 @@ Pin the `base_image_python` to a specific digest with `docker pull <image> &&
|
|
|
302
303
|
docker inspect --format='{{index .RepoDigests 0}}' <image>` — never use a
|
|
303
304
|
floating tag in production.
|
|
304
305
|
|
|
306
|
+
### Per-lambda builder overrides
|
|
307
|
+
|
|
308
|
+
`[builder]` sets the defaults for every lambda. Any `[[lambda]]` may override
|
|
309
|
+
`base_image_python`, `include_patterns`, or `exclude_patterns` for itself. An
|
|
310
|
+
override REPLACES the default for that field (lists are not merged); an unset
|
|
311
|
+
field inherits `[builder]`. Use this when one lambda needs a different base image
|
|
312
|
+
or a tighter file filter (so it re-hashes only on changes that affect it):
|
|
313
|
+
|
|
314
|
+
```toml
|
|
315
|
+
[[lambda]]
|
|
316
|
+
logical_name = "worker"
|
|
317
|
+
source_dir = "src/worker"
|
|
318
|
+
requirements_lock = "src/worker/requirements.${arch}.lock"
|
|
319
|
+
runtime = "python3.13"
|
|
320
|
+
arch = "arm64"
|
|
321
|
+
handler = "worker.handler"
|
|
322
|
+
# Override: only this lambda's runtime modules trigger a rebuild, and it builds
|
|
323
|
+
# on its own pinned base image. base_image_python must still be digest-pinned.
|
|
324
|
+
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<other-digest>"
|
|
325
|
+
include_patterns = ["worker/**/*.py", "worker/**/*.json"]
|
|
326
|
+
exclude_patterns = ["**/tests/**"]
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The resolved per-lambda builder (base-image digest + include/exclude lists +
|
|
330
|
+
builder version) folds into the content hash, so changing an override re-keys
|
|
331
|
+
that lambda's artifact while leaving the others untouched.
|
|
332
|
+
|
|
305
333
|
## Terraform consumer — `s3_existing_package`
|
|
306
334
|
|
|
307
335
|
In the Terraform that creates your Lambda function, point at the artifact in
|
|
@@ -12,7 +12,7 @@ from repro_lambda import __version__
|
|
|
12
12
|
from repro_lambda.catalog import Catalog, CatalogEntry
|
|
13
13
|
from repro_lambda.docker_runner import build_nodejs_lambda, build_python_lambda
|
|
14
14
|
from repro_lambda.hasher import compute_content_hash
|
|
15
|
-
from repro_lambda.manifest import BuilderConfig, LambdaSpec
|
|
15
|
+
from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
|
|
16
16
|
from repro_lambda.s3_uploader import S3Uploader, UploadResult
|
|
17
17
|
from repro_lambda.source_stager import stage_source
|
|
18
18
|
|
|
@@ -61,6 +61,7 @@ def compute_sha_for(
|
|
|
61
61
|
builder: BuilderConfig,
|
|
62
62
|
) -> str:
|
|
63
63
|
"""Stage source and compute the content hash; tempdir disposed on exit."""
|
|
64
|
+
builder = resolve_builder(builder, spec)
|
|
64
65
|
with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
|
|
65
66
|
stage_dir = Path(td)
|
|
66
67
|
lock_path = repo_root / spec.resolved_requirements_lock
|
|
@@ -85,6 +86,8 @@ def compute_sha_for(
|
|
|
85
86
|
builder_version=__version__,
|
|
86
87
|
extra_files=extras,
|
|
87
88
|
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
89
|
+
include_patterns=builder.include_patterns,
|
|
90
|
+
exclude_patterns=builder.exclude_patterns,
|
|
88
91
|
)
|
|
89
92
|
|
|
90
93
|
|
|
@@ -99,6 +102,7 @@ def build_one(
|
|
|
99
102
|
dry_run: bool = False,
|
|
100
103
|
) -> BuildOutcome:
|
|
101
104
|
"""Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
|
|
105
|
+
builder = resolve_builder(builder, spec)
|
|
102
106
|
target_bucket = _bucket_for(spec, bucket)
|
|
103
107
|
|
|
104
108
|
with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
|
|
@@ -126,6 +130,8 @@ def build_one(
|
|
|
126
130
|
builder_version=__version__,
|
|
127
131
|
extra_files=extras,
|
|
128
132
|
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
133
|
+
include_patterns=builder.include_patterns,
|
|
134
|
+
exclude_patterns=builder.exclude_patterns,
|
|
129
135
|
)
|
|
130
136
|
bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
|
|
131
137
|
|
|
@@ -12,9 +12,15 @@ ARCH_TO_DOCKER_PLATFORM: dict[str, str] = {
|
|
|
12
12
|
"x86_64": "linux/amd64",
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
# manylinux_2_17 (== manylinux2014) is the broadest baseline the AWS Lambda base
|
|
16
|
+
# images (Amazon Linux 2023, glibc 2.34) still run, and pip's explicit --platform does
|
|
17
|
+
# NOT expand a higher tag (e.g. 2_28) down to lower-baseline wheels. Many compiled
|
|
18
|
+
# wheels (e.g. pydantic-core) ship only manylinux_2_17 for a given Python/arch, so a
|
|
19
|
+
# 2_28 floor misses them with --only-binary=:all:. 2_17 matches 2_17 wheels and, via
|
|
20
|
+
# pip's tag expansion, any lower baseline too.
|
|
15
21
|
ARCH_TO_PIP_PLATFORM: dict[str, str] = {
|
|
16
|
-
"arm64": "
|
|
17
|
-
"x86_64": "
|
|
22
|
+
"arm64": "manylinux_2_17_aarch64",
|
|
23
|
+
"x86_64": "manylinux_2_17_x86_64",
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
ARCH_TO_NPM_CPU: dict[str, str] = {
|
|
@@ -25,11 +25,14 @@ def compute_content_hash(
|
|
|
25
25
|
*,
|
|
26
26
|
extra_files: list[tuple[Path, str]] | None = None,
|
|
27
27
|
payload_exec: list[tuple[str, bool]] | None = None,
|
|
28
|
+
include_patterns: list[str] | None = None,
|
|
29
|
+
exclude_patterns: list[str] | None = None,
|
|
28
30
|
) -> str:
|
|
29
31
|
"""
|
|
30
32
|
sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
|
|
31
33
|
+ sha256(requirements_lock) + spec scalars + base_image + builder_version
|
|
32
|
-
+ optional
|
|
34
|
+
+ optional resolved include/exclude filter lists + optional extra_files keyed by
|
|
35
|
+
destination relname (e.g. "package.json").
|
|
33
36
|
|
|
34
37
|
Inputs are concatenated with newline separators in a fixed order, then hashed.
|
|
35
38
|
|
|
@@ -37,6 +40,12 @@ def compute_content_hash(
|
|
|
37
40
|
(dest_relname, file_bytes) only - not the host path - so the hash is
|
|
38
41
|
host-path-independent. Callers with no extras produce byte-identical hashes
|
|
39
42
|
to v0.1 (the extras section is omitted entirely when extra_files is falsy).
|
|
43
|
+
|
|
44
|
+
include_patterns / exclude_patterns are the RESOLVED per-lambda filter lists.
|
|
45
|
+
They are folded sorted (membership is order-independent, so a pure reorder does
|
|
46
|
+
not re-key) and only when not None, so an explicit empty list (replace-with-empty)
|
|
47
|
+
hashes differently from an unset/None filter. Callers passing None omit the
|
|
48
|
+
section entirely, preserving hashes for code paths that do not resolve a builder.
|
|
40
49
|
"""
|
|
41
50
|
h = hashlib.sha256()
|
|
42
51
|
|
|
@@ -62,6 +71,17 @@ def compute_content_hash(
|
|
|
62
71
|
h.update(f"base_image={base_image}\n".encode())
|
|
63
72
|
h.update(f"builder_version={builder_version}\n".encode())
|
|
64
73
|
|
|
74
|
+
if include_patterns is not None:
|
|
75
|
+
h.update(b"---include---\n")
|
|
76
|
+
for pat in sorted(include_patterns):
|
|
77
|
+
h.update(pat.encode("utf-8"))
|
|
78
|
+
h.update(b"\n")
|
|
79
|
+
if exclude_patterns is not None:
|
|
80
|
+
h.update(b"---exclude---\n")
|
|
81
|
+
for pat in sorted(exclude_patterns):
|
|
82
|
+
h.update(pat.encode("utf-8"))
|
|
83
|
+
h.update(b"\n")
|
|
84
|
+
|
|
65
85
|
if extra_files:
|
|
66
86
|
h.update(b"---extras---\n")
|
|
67
87
|
# Sort by relname so staging order does not perturb the hash.
|
|
@@ -49,6 +49,11 @@ class LambdaSpec:
|
|
|
49
49
|
hash_extra: str = ""
|
|
50
50
|
package_json: str = ""
|
|
51
51
|
extra_files: tuple[ExtraFile, ...] = ()
|
|
52
|
+
# Per-lambda builder overrides (REPLACE-once-set; None = inherit the [builder]
|
|
53
|
+
# default). base_image_python must stay digest-pinned. Resolved via resolve_builder().
|
|
54
|
+
base_image_python: str | None = None
|
|
55
|
+
include_patterns: list[str] | None = None
|
|
56
|
+
exclude_patterns: list[str] | None = None
|
|
52
57
|
|
|
53
58
|
@property
|
|
54
59
|
def resolved_requirements_lock(self) -> str:
|
|
@@ -82,6 +87,26 @@ class Manifest:
|
|
|
82
87
|
builder: BuilderConfig
|
|
83
88
|
|
|
84
89
|
|
|
90
|
+
def resolve_builder(default: BuilderConfig, spec: LambdaSpec) -> BuilderConfig:
|
|
91
|
+
"""Resolve the effective builder for one lambda (REPLACE-once-set overrides).
|
|
92
|
+
|
|
93
|
+
Each per-lambda override fully REPLACES the matching [builder] default when set;
|
|
94
|
+
an unset override (None) inherits the default. An explicitly empty list replaces
|
|
95
|
+
with empty (stages/filters nothing) - the caller's choice, distinct from unset.
|
|
96
|
+
base_image_nodejs has no per-lambda override yet (add when a node lambda needs it).
|
|
97
|
+
"""
|
|
98
|
+
return BuilderConfig(
|
|
99
|
+
base_image_python=spec.base_image_python or default.base_image_python,
|
|
100
|
+
base_image_nodejs=default.base_image_nodejs,
|
|
101
|
+
include_patterns=(
|
|
102
|
+
default.include_patterns if spec.include_patterns is None else spec.include_patterns
|
|
103
|
+
),
|
|
104
|
+
exclude_patterns=(
|
|
105
|
+
default.exclude_patterns if spec.exclude_patterns is None else spec.exclude_patterns
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
85
110
|
def _parse_extra_files(path: Path, entry: dict) -> tuple[ExtraFile, ...]:
|
|
86
111
|
"""Parse + validate a lambda's optional [[lambda.extra_files]] entries."""
|
|
87
112
|
parsed: list[ExtraFile] = []
|
|
@@ -160,6 +185,26 @@ def load_manifest(path: Path) -> Manifest:
|
|
|
160
185
|
|
|
161
186
|
extra_files = _parse_extra_files(path, entry)
|
|
162
187
|
|
|
188
|
+
override_base_image = entry.get("base_image_python")
|
|
189
|
+
if override_base_image is not None and "@sha256:" not in override_base_image:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"{path}: lambda {entry.get('logical_name')!r} base_image_python override must be "
|
|
192
|
+
f"pinned by digest (got {override_base_image!r}; need image@sha256:<digest>)"
|
|
193
|
+
)
|
|
194
|
+
override_include = entry.get("include_patterns")
|
|
195
|
+
override_exclude = entry.get("exclude_patterns")
|
|
196
|
+
for fname, fval in (
|
|
197
|
+
("include_patterns", override_include),
|
|
198
|
+
("exclude_patterns", override_exclude),
|
|
199
|
+
):
|
|
200
|
+
if fval is not None and (
|
|
201
|
+
not isinstance(fval, list) or not all(isinstance(x, str) for x in fval)
|
|
202
|
+
):
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"{path}: lambda {entry.get('logical_name')!r} {fname} override "
|
|
205
|
+
f"must be a list of strings (got {fval!r})"
|
|
206
|
+
)
|
|
207
|
+
|
|
163
208
|
lambdas.append(
|
|
164
209
|
LambdaSpec(
|
|
165
210
|
logical_name=entry["logical_name"],
|
|
@@ -174,6 +219,9 @@ def load_manifest(path: Path) -> Manifest:
|
|
|
174
219
|
lambda_at_edge=bool(entry.get("lambda_at_edge", False)),
|
|
175
220
|
hash_extra=entry.get("hash_extra", ""),
|
|
176
221
|
extra_files=extra_files,
|
|
222
|
+
base_image_python=override_base_image,
|
|
223
|
+
include_patterns=list(override_include) if override_include is not None else None,
|
|
224
|
+
exclude_patterns=list(override_exclude) if override_exclude is not None else None,
|
|
177
225
|
)
|
|
178
226
|
)
|
|
179
227
|
|
|
@@ -7,7 +7,7 @@ import tempfile
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from repro_lambda.docker_runner import build_nodejs_lambda, build_python_lambda
|
|
10
|
-
from repro_lambda.manifest import BuilderConfig, LambdaSpec
|
|
10
|
+
from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
|
|
11
11
|
from repro_lambda.source_stager import stage_source
|
|
12
12
|
|
|
13
13
|
|
|
@@ -45,6 +45,7 @@ def verify_reproducible(
|
|
|
45
45
|
|
|
46
46
|
Returns (sha_build_1, sha_build_2) on match. Raises ReproducibilityError on mismatch.
|
|
47
47
|
"""
|
|
48
|
+
builder = resolve_builder(builder, spec)
|
|
48
49
|
lock_path = repo_root / spec.resolved_requirements_lock
|
|
49
50
|
extras = _extras_for(spec, lock_path, repo_root)
|
|
50
51
|
|
|
@@ -20,8 +20,8 @@ def test_arch_mapping_tables_are_complete():
|
|
|
20
20
|
def test_arch_mapping_values():
|
|
21
21
|
assert ARCH_TO_DOCKER_PLATFORM["arm64"] == "linux/arm64"
|
|
22
22
|
assert ARCH_TO_DOCKER_PLATFORM["x86_64"] == "linux/amd64"
|
|
23
|
-
assert ARCH_TO_PIP_PLATFORM["arm64"] == "
|
|
24
|
-
assert ARCH_TO_PIP_PLATFORM["x86_64"] == "
|
|
23
|
+
assert ARCH_TO_PIP_PLATFORM["arm64"] == "manylinux_2_17_aarch64"
|
|
24
|
+
assert ARCH_TO_PIP_PLATFORM["x86_64"] == "manylinux_2_17_x86_64"
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def test_build_python_lambda_raises_on_unknown_arch(tmp_path: Path):
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Per-lambda builder overrides (base image / include / exclude), REPLACE-once-set."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from repro_lambda.build import compute_sha_for
|
|
9
|
+
from repro_lambda.hasher import compute_content_hash
|
|
10
|
+
from repro_lambda.manifest import (
|
|
11
|
+
BuilderConfig,
|
|
12
|
+
LambdaSpec,
|
|
13
|
+
load_manifest,
|
|
14
|
+
resolve_builder,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PINNED_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "0" * 64
|
|
18
|
+
OTHER_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "1" * 64
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _spec(**kw) -> LambdaSpec:
|
|
22
|
+
base = dict(
|
|
23
|
+
logical_name="app",
|
|
24
|
+
source_dir="handler",
|
|
25
|
+
requirements_lock="handler/requirements.${arch}.lock",
|
|
26
|
+
runtime="python3.13",
|
|
27
|
+
arch="arm64",
|
|
28
|
+
handler="app.lambda_handler",
|
|
29
|
+
)
|
|
30
|
+
base.update(kw)
|
|
31
|
+
return LambdaSpec(**base)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default_builder() -> BuilderConfig:
|
|
35
|
+
return BuilderConfig(
|
|
36
|
+
base_image_python=PINNED_IMAGE,
|
|
37
|
+
include_patterns=["**/*.py", "**/*.json"],
|
|
38
|
+
exclude_patterns=["tests/**"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- manifest parse / validate --------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_manifest(tmp_path: Path, lambda_overrides_toml: str) -> Path:
|
|
46
|
+
p = tmp_path / "lambdas.toml"
|
|
47
|
+
p.write_text(
|
|
48
|
+
"[[lambda]]\n"
|
|
49
|
+
'logical_name = "app"\n'
|
|
50
|
+
'source_dir = "handler"\n'
|
|
51
|
+
'requirements_lock = "handler/requirements.${arch}.lock"\n'
|
|
52
|
+
'runtime = "python3.13"\n'
|
|
53
|
+
'arch = "arm64"\n'
|
|
54
|
+
'handler = "app.lambda_handler"\n'
|
|
55
|
+
f"{lambda_overrides_toml}"
|
|
56
|
+
"\n[builder]\n"
|
|
57
|
+
f'base_image_python = "{PINNED_IMAGE}"\n'
|
|
58
|
+
)
|
|
59
|
+
return p
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_manifest_parses_per_lambda_builder_overrides(tmp_path: Path):
|
|
63
|
+
manifest = _write_manifest(
|
|
64
|
+
tmp_path,
|
|
65
|
+
f'base_image_python = "{OTHER_IMAGE}"\n'
|
|
66
|
+
'include_patterns = ["**/*.py"]\n'
|
|
67
|
+
'exclude_patterns = ["tests/**", "docs/**"]\n',
|
|
68
|
+
)
|
|
69
|
+
spec = load_manifest(manifest).lambdas[0]
|
|
70
|
+
assert spec.base_image_python == OTHER_IMAGE
|
|
71
|
+
assert spec.include_patterns == ["**/*.py"]
|
|
72
|
+
assert spec.exclude_patterns == ["tests/**", "docs/**"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_manifest_overrides_default_to_none(tmp_path: Path):
|
|
76
|
+
spec = load_manifest(_write_manifest(tmp_path, "")).lambdas[0]
|
|
77
|
+
assert spec.base_image_python is None
|
|
78
|
+
assert spec.include_patterns is None
|
|
79
|
+
assert spec.exclude_patterns is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_manifest_explicit_empty_include_is_empty_list_not_none(tmp_path: Path):
|
|
83
|
+
spec = load_manifest(_write_manifest(tmp_path, "include_patterns = []\n")).lambdas[0]
|
|
84
|
+
assert spec.include_patterns == [] # explicit empty != unset
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_manifest_rejects_unpinned_override_base_image(tmp_path: Path):
|
|
88
|
+
manifest = _write_manifest(
|
|
89
|
+
tmp_path, 'base_image_python = "public.ecr.aws/lambda/python:3.13"\n'
|
|
90
|
+
)
|
|
91
|
+
with pytest.raises(ValueError, match="must be pinned by digest"):
|
|
92
|
+
load_manifest(manifest)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_manifest_rejects_non_list_include(tmp_path: Path):
|
|
96
|
+
manifest = _write_manifest(tmp_path, 'include_patterns = "**/*.py"\n')
|
|
97
|
+
with pytest.raises(ValueError, match="must be a list of strings"):
|
|
98
|
+
load_manifest(manifest)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_manifest_rejects_non_string_in_exclude(tmp_path: Path):
|
|
102
|
+
manifest = _write_manifest(tmp_path, "exclude_patterns = [1, 2]\n")
|
|
103
|
+
with pytest.raises(ValueError, match="must be a list of strings"):
|
|
104
|
+
load_manifest(manifest)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- resolve_builder (REPLACE-once-set) ------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_resolve_inherits_default_when_unset():
|
|
111
|
+
default = _default_builder()
|
|
112
|
+
resolved = resolve_builder(default, _spec())
|
|
113
|
+
assert resolved.base_image_python == PINNED_IMAGE
|
|
114
|
+
assert resolved.include_patterns == ["**/*.py", "**/*.json"]
|
|
115
|
+
assert resolved.exclude_patterns == ["tests/**"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_resolve_replaces_each_set_field():
|
|
119
|
+
default = _default_builder()
|
|
120
|
+
spec = _spec(
|
|
121
|
+
base_image_python=OTHER_IMAGE,
|
|
122
|
+
include_patterns=["src/**/*.py"],
|
|
123
|
+
exclude_patterns=["docs/**"],
|
|
124
|
+
)
|
|
125
|
+
resolved = resolve_builder(default, spec)
|
|
126
|
+
assert resolved.base_image_python == OTHER_IMAGE
|
|
127
|
+
assert resolved.include_patterns == ["src/**/*.py"]
|
|
128
|
+
assert resolved.exclude_patterns == ["docs/**"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_resolve_empty_list_replaces_with_empty():
|
|
132
|
+
default = _default_builder()
|
|
133
|
+
resolved = resolve_builder(default, _spec(exclude_patterns=[]))
|
|
134
|
+
assert resolved.exclude_patterns == [] # replace, not inherit
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_resolve_preserves_default_nodejs_image():
|
|
138
|
+
default = BuilderConfig(base_image_python=PINNED_IMAGE, base_image_nodejs="node@sha256:abc")
|
|
139
|
+
resolved = resolve_builder(default, _spec(base_image_python=OTHER_IMAGE))
|
|
140
|
+
assert resolved.base_image_nodejs == "node@sha256:abc"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- hash folds resolved include/exclude -----------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _staged_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
147
|
+
root = tmp_path / "source"
|
|
148
|
+
root.mkdir(parents=True)
|
|
149
|
+
(root / "app.py").write_text("x = 1\n")
|
|
150
|
+
lock = tmp_path / "requirements.lock"
|
|
151
|
+
lock.write_text("")
|
|
152
|
+
return root, lock
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _hash(root: Path, lock: Path, **kw) -> str:
|
|
156
|
+
return compute_content_hash(
|
|
157
|
+
staged_source_root=root,
|
|
158
|
+
requirements_lock=lock,
|
|
159
|
+
spec=_spec(),
|
|
160
|
+
base_image=PINNED_IMAGE,
|
|
161
|
+
builder_version="0.4.2",
|
|
162
|
+
**kw,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_hash_none_filters_is_stable(tmp_path: Path):
|
|
167
|
+
root, lock = _staged_tree(tmp_path)
|
|
168
|
+
assert _hash(root, lock) == _hash(root, lock, include_patterns=None, exclude_patterns=None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_hash_rekeys_when_exclude_differs(tmp_path: Path):
|
|
172
|
+
root, lock = _staged_tree(tmp_path)
|
|
173
|
+
a = _hash(root, lock, include_patterns=["**/*.py"], exclude_patterns=["tests/**"])
|
|
174
|
+
b = _hash(root, lock, include_patterns=["**/*.py"], exclude_patterns=["tests/**", "docs/**"])
|
|
175
|
+
assert a != b
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_hash_none_differs_from_explicit_empty(tmp_path: Path):
|
|
179
|
+
root, lock = _staged_tree(tmp_path)
|
|
180
|
+
unset = _hash(root, lock, include_patterns=None)
|
|
181
|
+
empty = _hash(root, lock, include_patterns=[])
|
|
182
|
+
assert unset != empty # unset omits the section; [] emits an empty section marker
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_hash_pattern_reorder_does_not_rekey(tmp_path: Path):
|
|
186
|
+
root, lock = _staged_tree(tmp_path)
|
|
187
|
+
a = _hash(root, lock, exclude_patterns=["a/**", "b/**"])
|
|
188
|
+
b = _hash(root, lock, exclude_patterns=["b/**", "a/**"])
|
|
189
|
+
assert a == b # membership is order-independent -> sorted fold
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- compute_sha_for integration (git-backed) ------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _git_repo(tmp_path: Path) -> Path:
|
|
196
|
+
repo = tmp_path / "repo"
|
|
197
|
+
(repo / "handler" / "tests").mkdir(parents=True)
|
|
198
|
+
(repo / "handler" / "app.py").write_text("def lambda_handler(e, c): return 200\n")
|
|
199
|
+
(repo / "handler" / "tests" / "test_app.py").write_text("def test_x(): pass\n")
|
|
200
|
+
(repo / "handler" / "requirements.arm64.lock").write_text("")
|
|
201
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
202
|
+
subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
|
|
203
|
+
subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
|
|
204
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
205
|
+
subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
|
|
206
|
+
return repo
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_compute_sha_for_rekeys_on_per_lambda_exclude(tmp_path: Path):
|
|
210
|
+
repo = _git_repo(tmp_path)
|
|
211
|
+
default = BuilderConfig(
|
|
212
|
+
base_image_python=PINNED_IMAGE,
|
|
213
|
+
include_patterns=["**/*.py"],
|
|
214
|
+
exclude_patterns=[],
|
|
215
|
+
)
|
|
216
|
+
# Lambda A keeps tests; lambda B excludes them -> different staged tree -> different sha.
|
|
217
|
+
spec_keep = _spec()
|
|
218
|
+
spec_drop = _spec(exclude_patterns=["**/tests/**"])
|
|
219
|
+
sha_keep = compute_sha_for(repo_root=repo, spec=spec_keep, builder=default)
|
|
220
|
+
sha_drop = compute_sha_for(repo_root=repo, spec=spec_drop, builder=default)
|
|
221
|
+
assert sha_keep != sha_drop
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_compute_sha_for_rekeys_on_per_lambda_base_image(tmp_path: Path):
|
|
225
|
+
repo = _git_repo(tmp_path)
|
|
226
|
+
default = BuilderConfig(base_image_python=PINNED_IMAGE, include_patterns=["**/*.py"])
|
|
227
|
+
sha_default = compute_sha_for(repo_root=repo, spec=_spec(), builder=default)
|
|
228
|
+
sha_override = compute_sha_for(
|
|
229
|
+
repo_root=repo, spec=_spec(base_image_python=OTHER_IMAGE), builder=default
|
|
230
|
+
)
|
|
231
|
+
assert sha_default != sha_override
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/index.js
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_nodejs_lambda/handler/package.json
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.4.0 → repro_lambda-0.4.2}/tests/fixtures/sample_python_lambda/handler/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|