repro-lambda 0.4.1__tar.gz → 0.5.0__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.1 → repro_lambda-0.5.0}/.github/workflows/build.yml +20 -0
- repro_lambda-0.5.0/.github/workflows/move-major-tag.yml +28 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/CHANGELOG.md +19 -1
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/PKG-INFO +2 -1
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/SETUP.md +114 -10
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/pyproject.toml +2 -2
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/build.py +23 -1
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/cli.py +31 -1
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/hasher.py +47 -2
- repro_lambda-0.5.0/src/repro_lambda/manifest.py +478 -0
- repro_lambda-0.5.0/src/repro_lambda/source_locker.py +149 -0
- repro_lambda-0.5.0/src/repro_lambda/sources.py +465 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/verify.py +2 -1
- repro_lambda-0.5.0/tests/test_per_lambda_builder.py +231 -0
- repro_lambda-0.5.0/tests/test_source_locker.py +137 -0
- repro_lambda-0.5.0/tests/test_sources.py +442 -0
- repro_lambda-0.5.0/tests/test_sources_hash.py +109 -0
- repro_lambda-0.5.0/tests/test_sources_schema.py +246 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/uv.lock +12 -1
- repro_lambda-0.4.1/src/repro_lambda/manifest.py +0 -180
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/ci.yml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/promote.yml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/publish.yml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.gitignore +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/LICENSE +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/README.md +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/catalog.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/docker_runner.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/promote.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/s3_uploader.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/source_stager.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/zip_packager.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/__init__.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/conftest.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_build_integration.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_build_nodejs.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_catalog.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_lock.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_smoke.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_docker_runner.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_docker_runner_nodejs.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_e2e_nodejs_lambda.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_extra_files.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_hasher.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_manifest.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_promote.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_python_byte_compat_regression.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_s3_uploader.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_source_stager.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_verify.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_zip_excludes.py +0 -0
- {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_zip_packager.py +0 -0
|
@@ -25,6 +25,14 @@ on:
|
|
|
25
25
|
required: false
|
|
26
26
|
default: ""
|
|
27
27
|
description: S3 bucket name for prod Lambda artifacts (master push only).
|
|
28
|
+
secrets:
|
|
29
|
+
sources_token:
|
|
30
|
+
required: false
|
|
31
|
+
description: >-
|
|
32
|
+
Token with read access to the private release repos referenced by any
|
|
33
|
+
github_release [[lambda.source]]. Exported as REPRO_LAMBDA_SOURCES_TOKEN for the
|
|
34
|
+
build. Omit when every source is a public https URL. Generic by design - the
|
|
35
|
+
reusable workflow never names a consumer repo.
|
|
28
36
|
|
|
29
37
|
jobs:
|
|
30
38
|
detect-arches:
|
|
@@ -64,6 +72,16 @@ jobs:
|
|
|
64
72
|
- uses: actions/checkout@v7
|
|
65
73
|
- uses: astral-sh/setup-uv@v8.2.0
|
|
66
74
|
|
|
75
|
+
# Content-addressed source cache, scoped per-arch (a source's sha256 is its key, so a
|
|
76
|
+
# re-pin changes the manifest hash -> fresh fetch; restore-keys reuses the prior cache).
|
|
77
|
+
- name: Cache fetched sources
|
|
78
|
+
uses: actions/cache@v4
|
|
79
|
+
with:
|
|
80
|
+
path: builds/.sources-cache
|
|
81
|
+
key: repro-sources-${{ matrix.arch }}-${{ hashFiles(inputs.manifest_path) }}
|
|
82
|
+
restore-keys: |
|
|
83
|
+
repro-sources-${{ matrix.arch }}-
|
|
84
|
+
|
|
67
85
|
- name: Configure AWS credentials (dev)
|
|
68
86
|
uses: aws-actions/configure-aws-credentials@v6
|
|
69
87
|
with:
|
|
@@ -73,6 +91,7 @@ jobs:
|
|
|
73
91
|
- name: Build (dev bucket)
|
|
74
92
|
env:
|
|
75
93
|
REPRO_LAMBDA_BUCKET: ${{ inputs.dev_bucket }}
|
|
94
|
+
REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
|
|
76
95
|
run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
|
|
77
96
|
|
|
78
97
|
- name: Verify reproducible (PR only)
|
|
@@ -90,6 +109,7 @@ jobs:
|
|
|
90
109
|
if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
|
|
91
110
|
env:
|
|
92
111
|
REPRO_LAMBDA_BUCKET: ${{ inputs.prod_bucket }}
|
|
112
|
+
REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
|
|
93
113
|
run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
|
|
94
114
|
|
|
95
115
|
- name: Commit catalog drift (master only, dev bot)
|
|
@@ -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,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.5.0 - 2026-06-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Declarative sources DSL: `[[lambda.source]]` fetches a pinned external artifact into the package before the container build, replacing consumer-side download scripts. Two source types - `github_release` (`repo`/`tag`/`asset`, resolved via the GitHub API) and `https` (a direct `url`). Each source is PINNED: `sha256` is verified before extraction; `extract` is `zip`/`tar.gz`/`none`; an optional `member` extracts a single file or a (versioned) directory subtree to `dest` (package-root-relative); `executable` sets +x. Source names are required and unique per lambda; dest collisions (including tree overlaps and overlaps with the staged source) are refused.
|
|
7
|
+
- `version_from` (single-level): a source can derive its `version` at lock time from an asdf-style `key value` line in a referenced source's file (read relative to that source's member-stripped tree). `{version}` is substituted into `url`/`tag`/`asset`/`member`. `version_from` is a lock input and never participates in the content hash.
|
|
8
|
+
- `lock --sources` re-resolves `version_from`, re-downloads each source, recomputes its sha256, and rewrites `lambdas.toml` in place with tomlkit (comments preserved, atomic). It is idempotent: a run that changes nothing leaves the file byte-for-byte unchanged (no spurious PR). `lock` keeps regenerating requirements locks too; use `--no-requirements` / `--no-sources` to scope it.
|
|
9
|
+
- The reusable `build.yml` gains a generic `sources_token` secret (exported as `REPRO_LAMBDA_SOURCES_TOKEN`, used only for `github_release` API calls) and an arch-scoped `actions/cache` for the content-addressed source cache.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
- SSRF-hardened fetcher: HTTPS-only with a manual redirect loop; each hop resolves the hostname, refuses any non-global-unicast / reserved / private / loopback / link-local / IPv4-mapped address, and connects to that pinned IP while verifying the TLS cert against the original hostname (no second DNS lookup a rebind could poison). `Authorization` is stripped on any cross-host redirect (e.g. the GitHub API -> asset host), so a private token never leaks. Download size is capped and log URLs are redacted (userinfo + query stripped).
|
|
13
|
+
- Hardened extraction: sha256 is verified before any archive is opened; entries with absolute / `..` / symlink / hardlink / device / fifo paths are rejected; total bytes, entry count, and per-entry bytes are bounded (decompression-bomb limits, with streaming tar reads); files are written to a temp tree then promoted with normalized mtime + perms. The sha256-keyed cache is re-verified on every use (anti cache-poisoning).
|
|
14
|
+
- Content hashing folds the RESOLVED source metadata (not the bytes), so the artifact key is computable offline and a `member`/`extract`/`dest`/`sha256`/version change re-keys, while a re-fetch alone does not.
|
|
15
|
+
|
|
16
|
+
## v0.4.2 - 2026-06-21
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
3
21
|
## v0.4.1 - 2026-06-21
|
|
4
22
|
|
|
5
23
|
### Fixed
|
|
@@ -21,7 +39,7 @@
|
|
|
21
39
|
|
|
22
40
|
jobs:
|
|
23
41
|
promote:
|
|
24
|
-
uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@
|
|
42
|
+
uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v0
|
|
25
43
|
with:
|
|
26
44
|
source_sha: ${{ inputs.source_sha }}
|
|
27
45
|
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.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -20,6 +20,7 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
21
|
Requires-Dist: boto3>=1.34
|
|
22
22
|
Requires-Dist: repro-zipfile>=0.3.1
|
|
23
|
+
Requires-Dist: tomlkit>=0.13
|
|
23
24
|
Requires-Dist: typer>=0.12
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: moto[s3]>=5; extra == 'dev'
|
|
@@ -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,109 @@ 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
|
+
|
|
333
|
+
## Declarative sources — `[[lambda.source]]`
|
|
334
|
+
|
|
335
|
+
A lambda can bundle pinned external artifacts (a release tarball, a vendored CLI
|
|
336
|
+
binary) fetched at build time, instead of a hand-rolled download script. Each
|
|
337
|
+
`[[lambda.source]]` is fully pinned and fetched + verified + extracted into the
|
|
338
|
+
package before the container build:
|
|
339
|
+
|
|
340
|
+
```toml
|
|
341
|
+
[[lambda]]
|
|
342
|
+
logical_name = "app"
|
|
343
|
+
source_dir = "src/app"
|
|
344
|
+
requirements_lock = "src/app/requirements.${arch}.lock"
|
|
345
|
+
runtime = "python3.13"
|
|
346
|
+
arch = "arm64"
|
|
347
|
+
handler = "app.lambda_handler"
|
|
348
|
+
|
|
349
|
+
# A private GitHub release tarball -> staged at package path "vendor".
|
|
350
|
+
[[lambda.source]]
|
|
351
|
+
name = "vendor"
|
|
352
|
+
type = "github_release"
|
|
353
|
+
repo = "owner/vendor-tool"
|
|
354
|
+
tag = "vendor-v{version}"
|
|
355
|
+
asset = "vendor-{version}.tar.gz"
|
|
356
|
+
sha256 = "<64-hex; written by `repro-lambda lock`>"
|
|
357
|
+
extract = "tar.gz"
|
|
358
|
+
member = "vendor-{version}" # map the versioned top dir to dest
|
|
359
|
+
dest = "vendor"
|
|
360
|
+
version = "1.4.0" # bump this one line; lock re-pins everything
|
|
361
|
+
|
|
362
|
+
# A public binary whose version is derived from the vendor release's .tool-versions.
|
|
363
|
+
[[lambda.source]]
|
|
364
|
+
name = "terraform"
|
|
365
|
+
type = "https"
|
|
366
|
+
url = "https://releases.hashicorp.com/terraform/{version}/terraform_{version}_linux_arm64.zip"
|
|
367
|
+
sha256 = "<64-hex; written by lock>"
|
|
368
|
+
extract = "zip"
|
|
369
|
+
member = "terraform"
|
|
370
|
+
dest = "bin/terraform"
|
|
371
|
+
executable = true
|
|
372
|
+
version = "1.9.0"
|
|
373
|
+
[lambda.source.version_from]
|
|
374
|
+
source = "vendor" # read from the vendor source's extracted tree
|
|
375
|
+
file = ".tool-versions" # relative to its member-stripped root
|
|
376
|
+
key = "terraform"
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- **Pinning.** `sha256` is verified before the archive is opened. `extract` is
|
|
380
|
+
`zip` / `tar.gz` / `none`. `member` extracts one file or a directory subtree to
|
|
381
|
+
`dest`; omit it to extract the whole archive under `dest`. Source names are
|
|
382
|
+
unique per lambda and dests may not overlap each other or the staged source.
|
|
383
|
+
- **`version_from`** (single-level) derives a source's `version` from an asdf-style
|
|
384
|
+
`key value` line in another source's file, so bumping the root `version`
|
|
385
|
+
cascades to dependents. It is a lock input - it never affects the artifact hash.
|
|
386
|
+
- **`repro-lambda lock`** re-resolves `version_from`, re-downloads, recomputes each
|
|
387
|
+
`sha256`, and rewrites this file (comment-preserving, atomic, idempotent). Run it
|
|
388
|
+
after bumping a `version`. Pass `REPRO_LAMBDA_SOURCES_TOKEN` for private
|
|
389
|
+
`github_release` sources.
|
|
390
|
+
- **Security.** Fetches are HTTPS-only with an SSRF guard (no private/loopback/
|
|
391
|
+
link-local/metadata IPs), strip `Authorization` on cross-host redirects, verify
|
|
392
|
+
sha256 before extraction, and reject path-traversal / link / device entries with
|
|
393
|
+
decompression-bomb bounds. See `src/repro_lambda/sources.py`.
|
|
394
|
+
|
|
395
|
+
In CI, pass the token to the reusable `build.yml` as the `sources_token` secret:
|
|
396
|
+
|
|
397
|
+
```yaml
|
|
398
|
+
jobs:
|
|
399
|
+
build:
|
|
400
|
+
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
|
|
401
|
+
with:
|
|
402
|
+
manifest_path: lambdas.toml
|
|
403
|
+
aws_dev_role_arn: arn:aws:iam::<account>:role/<dev-builder-role>
|
|
404
|
+
dev_bucket: <env>-my-lambda-artifacts
|
|
405
|
+
secrets:
|
|
406
|
+
sources_token: ${{ secrets.MY_RELEASE_TOKEN }}
|
|
407
|
+
```
|
|
408
|
+
|
|
305
409
|
## Terraform consumer — `s3_existing_package`
|
|
306
410
|
|
|
307
411
|
In the Terraform that creates your Lambda function, point at the artifact in
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "repro-lambda"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
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"
|
|
@@ -21,7 +21,7 @@ dependencies = [
|
|
|
21
21
|
"typer>=0.12",
|
|
22
22
|
"repro-zipfile>=0.3.1",
|
|
23
23
|
"boto3>=1.34",
|
|
24
|
-
|
|
24
|
+
"tomlkit>=0.13",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.optional-dependencies]
|
|
@@ -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,9 @@ 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,
|
|
91
|
+
sources=spec.sources,
|
|
88
92
|
)
|
|
89
93
|
|
|
90
94
|
|
|
@@ -97,8 +101,11 @@ def build_one(
|
|
|
97
101
|
catalog: Catalog,
|
|
98
102
|
source_commit: str,
|
|
99
103
|
dry_run: bool = False,
|
|
104
|
+
sources_token: str | None = None,
|
|
105
|
+
sources_cache: Path | None = None,
|
|
100
106
|
) -> BuildOutcome:
|
|
101
107
|
"""Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
|
|
108
|
+
builder = resolve_builder(builder, spec)
|
|
102
109
|
target_bucket = _bucket_for(spec, bucket)
|
|
103
110
|
|
|
104
111
|
with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
|
|
@@ -126,6 +133,9 @@ def build_one(
|
|
|
126
133
|
builder_version=__version__,
|
|
127
134
|
extra_files=extras,
|
|
128
135
|
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
136
|
+
include_patterns=builder.include_patterns,
|
|
137
|
+
exclude_patterns=builder.exclude_patterns,
|
|
138
|
+
sources=spec.sources,
|
|
129
139
|
)
|
|
130
140
|
bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
|
|
131
141
|
|
|
@@ -137,6 +147,18 @@ def build_one(
|
|
|
137
147
|
_record(catalog, spec, sha, source_commit, builder)
|
|
138
148
|
return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key)
|
|
139
149
|
|
|
150
|
+
# Cache miss only: fetch + verify + extract declarative sources into the staged
|
|
151
|
+
# tree (post-filter; collides loudly with already-staged source/payload files).
|
|
152
|
+
if spec.sources:
|
|
153
|
+
from repro_lambda.sources import fetch_sources
|
|
154
|
+
|
|
155
|
+
fetch_sources(
|
|
156
|
+
sources=spec.sources,
|
|
157
|
+
dest_root=stage_dir / "source",
|
|
158
|
+
cache_dir=sources_cache or (repo_root / "builds" / ".sources-cache"),
|
|
159
|
+
github_token=sources_token,
|
|
160
|
+
)
|
|
161
|
+
|
|
140
162
|
out_zip = stage_dir / "lambda.zip"
|
|
141
163
|
if spec.package_manager == "pip":
|
|
142
164
|
build_python_lambda(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Annotated
|
|
@@ -128,6 +129,8 @@ def build(
|
|
|
128
129
|
except subprocess.CalledProcessError:
|
|
129
130
|
source_commit = "unknown"
|
|
130
131
|
|
|
132
|
+
sources_token = os.environ.get("REPRO_LAMBDA_SOURCES_TOKEN") or None
|
|
133
|
+
|
|
131
134
|
summary = []
|
|
132
135
|
for spec in selected:
|
|
133
136
|
outcome = build_one(
|
|
@@ -138,6 +141,7 @@ def build(
|
|
|
138
141
|
catalog=catalog,
|
|
139
142
|
source_commit=source_commit,
|
|
140
143
|
dry_run=dry_run,
|
|
144
|
+
sources_token=sources_token,
|
|
141
145
|
)
|
|
142
146
|
summary.append(
|
|
143
147
|
{
|
|
@@ -261,13 +265,39 @@ def promote(
|
|
|
261
265
|
@app.command()
|
|
262
266
|
def lock(
|
|
263
267
|
manifest: Annotated[Path, typer.Option("--manifest", "-m")] = Path("lambdas.toml"),
|
|
268
|
+
requirements: Annotated[
|
|
269
|
+
bool,
|
|
270
|
+
typer.Option(
|
|
271
|
+
"--requirements/--no-requirements",
|
|
272
|
+
help="Regenerate per-arch requirements.${arch}.lock via uv pip compile.",
|
|
273
|
+
),
|
|
274
|
+
] = True,
|
|
275
|
+
sources: Annotated[
|
|
276
|
+
bool,
|
|
277
|
+
typer.Option(
|
|
278
|
+
"--sources/--no-sources",
|
|
279
|
+
help="Re-resolve version_from + re-pin [[lambda.source]] sha256 in the manifest.",
|
|
280
|
+
),
|
|
281
|
+
] = True,
|
|
264
282
|
) -> None:
|
|
265
|
-
"""
|
|
283
|
+
"""Lock pinned inputs: per-arch requirements locks and/or declarative source pins."""
|
|
266
284
|
from repro_lambda.manifest import load_manifest
|
|
267
285
|
|
|
268
286
|
parsed = load_manifest(manifest)
|
|
269
287
|
repo_root = manifest.parent.resolve()
|
|
270
288
|
|
|
289
|
+
if requirements:
|
|
290
|
+
_lock_requirements(parsed, repo_root)
|
|
291
|
+
|
|
292
|
+
if sources:
|
|
293
|
+
from repro_lambda.source_locker import lock_sources
|
|
294
|
+
|
|
295
|
+
token = os.environ.get("REPRO_LAMBDA_SOURCES_TOKEN") or None
|
|
296
|
+
changed = lock_sources(manifest, token)
|
|
297
|
+
typer.echo(f"lock sources: {'updated ' + str(manifest) if changed else 'no changes'}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _lock_requirements(parsed, repo_root: Path) -> None:
|
|
271
301
|
for spec in parsed.lambdas:
|
|
272
302
|
if spec.package_manager == "npm":
|
|
273
303
|
typer.echo(
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
from repro_lambda.manifest import LambdaSpec
|
|
8
|
+
from repro_lambda.manifest import LambdaSpec, Source
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _sha256_file(path: Path) -> str:
|
|
@@ -25,11 +25,15 @@ 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,
|
|
30
|
+
sources: tuple[Source, ...] | None = None,
|
|
28
31
|
) -> str:
|
|
29
32
|
"""
|
|
30
33
|
sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
|
|
31
34
|
+ sha256(requirements_lock) + spec scalars + base_image + builder_version
|
|
32
|
-
+ optional
|
|
35
|
+
+ optional resolved include/exclude filter lists + optional extra_files keyed by
|
|
36
|
+
destination relname (e.g. "package.json").
|
|
33
37
|
|
|
34
38
|
Inputs are concatenated with newline separators in a fixed order, then hashed.
|
|
35
39
|
|
|
@@ -37,6 +41,18 @@ def compute_content_hash(
|
|
|
37
41
|
(dest_relname, file_bytes) only - not the host path - so the hash is
|
|
38
42
|
host-path-independent. Callers with no extras produce byte-identical hashes
|
|
39
43
|
to v0.1 (the extras section is omitted entirely when extra_files is falsy).
|
|
44
|
+
|
|
45
|
+
include_patterns / exclude_patterns are the RESOLVED per-lambda filter lists.
|
|
46
|
+
They are folded sorted (membership is order-independent, so a pure reorder does
|
|
47
|
+
not re-key) and only when not None, so an explicit empty list (replace-with-empty)
|
|
48
|
+
hashes differently from an unset/None filter. Callers passing None omit the
|
|
49
|
+
section entirely, preserving hashes for code paths that do not resolve a builder.
|
|
50
|
+
|
|
51
|
+
sources are the declarative [[lambda.source]] entries. Their RESOLVED metadata
|
|
52
|
+
(the {version}-substituted url/tag/asset/member plus type/repo/extract/dest/
|
|
53
|
+
executable/sha256), sorted by name, folds in - NOT the downloaded bytes, so the
|
|
54
|
+
hash stays download-free. version_from is a lock input and never folded; a
|
|
55
|
+
member/extract/dest/sha256 change re-keys. Omitted entirely when there are none.
|
|
40
56
|
"""
|
|
41
57
|
h = hashlib.sha256()
|
|
42
58
|
|
|
@@ -62,6 +78,17 @@ def compute_content_hash(
|
|
|
62
78
|
h.update(f"base_image={base_image}\n".encode())
|
|
63
79
|
h.update(f"builder_version={builder_version}\n".encode())
|
|
64
80
|
|
|
81
|
+
if include_patterns is not None:
|
|
82
|
+
h.update(b"---include---\n")
|
|
83
|
+
for pat in sorted(include_patterns):
|
|
84
|
+
h.update(pat.encode("utf-8"))
|
|
85
|
+
h.update(b"\n")
|
|
86
|
+
if exclude_patterns is not None:
|
|
87
|
+
h.update(b"---exclude---\n")
|
|
88
|
+
for pat in sorted(exclude_patterns):
|
|
89
|
+
h.update(pat.encode("utf-8"))
|
|
90
|
+
h.update(b"\n")
|
|
91
|
+
|
|
65
92
|
if extra_files:
|
|
66
93
|
h.update(b"---extras---\n")
|
|
67
94
|
# Sort by relname so staging order does not perturb the hash.
|
|
@@ -82,4 +109,22 @@ def compute_content_hash(
|
|
|
82
109
|
for dest, executable in sorted(payload_exec):
|
|
83
110
|
h.update(f"{dest}={int(executable)}\n".encode())
|
|
84
111
|
|
|
112
|
+
# Declarative sources: fold resolved metadata only (no bytes), sorted by name.
|
|
113
|
+
# version_from is intentionally excluded (it is a lock input, not artifact identity).
|
|
114
|
+
if sources:
|
|
115
|
+
h.update(b"---sources---\n")
|
|
116
|
+
for s in sorted(sources, key=lambda s: s.name):
|
|
117
|
+
h.update(f"name={s.name}\n".encode())
|
|
118
|
+
h.update(f"type={s.type}\n".encode())
|
|
119
|
+
h.update(f"repo={s.repo}\n".encode())
|
|
120
|
+
h.update(f"url={s.resolved_url}\n".encode())
|
|
121
|
+
h.update(f"tag={s.resolved_tag}\n".encode())
|
|
122
|
+
h.update(f"asset={s.resolved_asset}\n".encode())
|
|
123
|
+
h.update(f"member={s.resolved_member or ''}\n".encode())
|
|
124
|
+
h.update(f"extract={s.extract}\n".encode())
|
|
125
|
+
h.update(f"dest={s.dest}\n".encode())
|
|
126
|
+
h.update(f"executable={int(s.executable)}\n".encode())
|
|
127
|
+
h.update(f"sha256={s.sha256}\n".encode())
|
|
128
|
+
h.update(b"--\n")
|
|
129
|
+
|
|
85
130
|
return h.hexdigest()
|