repro-lambda 0.2.4__tar.gz → 0.4.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.2.4 → repro_lambda-0.4.0}/.github/workflows/build.yml +19 -23
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/.github/workflows/ci.yml +2 -2
- repro_lambda-0.4.0/.github/workflows/promote.yml +75 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/.github/workflows/publish.yml +5 -3
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/CHANGELOG.md +23 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/PKG-INFO +1 -1
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/pyproject.toml +1 -1
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/build.py +4 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/cli.py +89 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/hasher.py +10 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/manifest.py +42 -0
- repro_lambda-0.4.0/src/repro_lambda/promote.py +91 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/s3_uploader.py +19 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/source_stager.py +32 -4
- repro_lambda-0.4.0/tests/test_extra_files.py +222 -0
- repro_lambda-0.4.0/tests/test_promote.py +228 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/uv.lock +1 -1
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/.gitignore +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/LICENSE +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/README.md +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/SETUP.md +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/catalog.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/docker_runner.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/verify.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/src/repro_lambda/zip_packager.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/__init__.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/conftest.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_build_integration.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_build_nodejs.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_catalog.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_cli_lock.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_cli_smoke.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_docker_runner.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_docker_runner_nodejs.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_e2e_nodejs_lambda.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_hasher.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_manifest.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_python_byte_compat_regression.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_s3_uploader.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_source_stager.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_verify.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_zip_excludes.py +0 -0
- {repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/test_zip_packager.py +0 -0
|
@@ -7,24 +7,20 @@ on:
|
|
|
7
7
|
type: string
|
|
8
8
|
default: lambdas.toml
|
|
9
9
|
description: Path to lambdas.toml in the caller repo.
|
|
10
|
-
|
|
11
|
-
type: string
|
|
12
|
-
default: "0.2.4"
|
|
13
|
-
description: Pinned repro-lambda PyPI version.
|
|
14
|
-
aws-dev-role-arn:
|
|
10
|
+
aws_dev_role_arn:
|
|
15
11
|
type: string
|
|
16
12
|
required: true
|
|
17
13
|
description: ARN of the dev OIDC role assumed for artifact upload. Not a secret (the security boundary is the OIDC trust policy + bucket immutability), so callers pass it as a plain input.
|
|
18
|
-
|
|
14
|
+
aws_prod_role_arn:
|
|
19
15
|
type: string
|
|
20
16
|
required: false
|
|
21
17
|
default: ""
|
|
22
18
|
description: ARN of the prod OIDC role (master push only). Empty string disables the prod upload steps.
|
|
23
|
-
|
|
19
|
+
dev_bucket:
|
|
24
20
|
type: string
|
|
25
21
|
required: true
|
|
26
22
|
description: S3 bucket name for dev Lambda artifacts (set as REPRO_LAMBDA_BUCKET on the dev upload). Caller-supplied so the reusable workflow stays consumer-agnostic.
|
|
27
|
-
|
|
23
|
+
prod_bucket:
|
|
28
24
|
type: string
|
|
29
25
|
required: false
|
|
30
26
|
default: ""
|
|
@@ -36,8 +32,8 @@ jobs:
|
|
|
36
32
|
outputs:
|
|
37
33
|
arches: ${{ steps.parse.outputs.arches }}
|
|
38
34
|
steps:
|
|
39
|
-
- uses: actions/checkout@
|
|
40
|
-
- uses: astral-sh/setup-uv@
|
|
35
|
+
- uses: actions/checkout@v7
|
|
36
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
41
37
|
- id: parse
|
|
42
38
|
shell: bash
|
|
43
39
|
run: |
|
|
@@ -65,36 +61,36 @@ jobs:
|
|
|
65
61
|
id-token: write
|
|
66
62
|
contents: write
|
|
67
63
|
steps:
|
|
68
|
-
- uses: actions/checkout@
|
|
69
|
-
- uses: astral-sh/setup-uv@
|
|
64
|
+
- uses: actions/checkout@v7
|
|
65
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
70
66
|
|
|
71
67
|
- name: Configure AWS credentials (dev)
|
|
72
|
-
uses: aws-actions/configure-aws-credentials@
|
|
68
|
+
uses: aws-actions/configure-aws-credentials@v6
|
|
73
69
|
with:
|
|
74
|
-
role-to-assume: ${{ inputs.
|
|
70
|
+
role-to-assume: ${{ inputs.aws_dev_role_arn }}
|
|
75
71
|
aws-region: eu-west-1
|
|
76
72
|
|
|
77
73
|
- name: Build (dev bucket)
|
|
78
74
|
env:
|
|
79
|
-
REPRO_LAMBDA_BUCKET: ${{ inputs.
|
|
80
|
-
run: uvx --from
|
|
75
|
+
REPRO_LAMBDA_BUCKET: ${{ inputs.dev_bucket }}
|
|
76
|
+
run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
|
|
81
77
|
|
|
82
78
|
- name: Verify reproducible (PR only)
|
|
83
79
|
if: github.event_name == 'pull_request'
|
|
84
|
-
run: uvx --from
|
|
80
|
+
run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --verify --dry-run
|
|
85
81
|
|
|
86
82
|
- name: Configure AWS credentials (prod)
|
|
87
|
-
if: github.ref == 'refs/heads/master' && inputs.
|
|
88
|
-
uses: aws-actions/configure-aws-credentials@
|
|
83
|
+
if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
|
|
84
|
+
uses: aws-actions/configure-aws-credentials@v6
|
|
89
85
|
with:
|
|
90
|
-
role-to-assume: ${{ inputs.
|
|
86
|
+
role-to-assume: ${{ inputs.aws_prod_role_arn }}
|
|
91
87
|
aws-region: eu-west-1
|
|
92
88
|
|
|
93
89
|
- name: Build (prod bucket)
|
|
94
|
-
if: github.ref == 'refs/heads/master' && inputs.
|
|
90
|
+
if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
|
|
95
91
|
env:
|
|
96
|
-
REPRO_LAMBDA_BUCKET: ${{ inputs.
|
|
97
|
-
run: uvx --from
|
|
92
|
+
REPRO_LAMBDA_BUCKET: ${{ inputs.prod_bucket }}
|
|
93
|
+
run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
|
|
98
94
|
|
|
99
95
|
- name: Commit catalog drift (master only, dev bot)
|
|
100
96
|
if: github.ref == 'refs/heads/master'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: promote-lambdas (reusable)
|
|
2
|
+
|
|
3
|
+
# Promote already-built dev artifacts to prod by content-addressed S3 copy.
|
|
4
|
+
# No rebuild: the exact lambdas/<name>/<sha>.zip tested in dev is copied to the
|
|
5
|
+
# prod bucket, so a cross-architecture prod runner can never diverge from dev.
|
|
6
|
+
on:
|
|
7
|
+
workflow_call:
|
|
8
|
+
inputs:
|
|
9
|
+
manifest_path:
|
|
10
|
+
type: string
|
|
11
|
+
default: lambdas.toml
|
|
12
|
+
description: Path to lambdas.toml in the caller repo.
|
|
13
|
+
source_sha:
|
|
14
|
+
type: string
|
|
15
|
+
required: true
|
|
16
|
+
description: 40-char hex commit on the caller's master that produced the dev artifacts. Validated and checked out before reading the manifest/catalog.
|
|
17
|
+
promoter_role_arn:
|
|
18
|
+
type: string
|
|
19
|
+
required: true
|
|
20
|
+
description: ARN of the prod OIDC role assumed for the cross-account dev-read + prod-write copy. Not a secret (the boundary is the OIDC trust policy + bucket immutability).
|
|
21
|
+
dev_bucket:
|
|
22
|
+
type: string
|
|
23
|
+
required: true
|
|
24
|
+
description: Source (dev) base S3 bucket; us-east-1 variant auto-derived for Lambda@Edge.
|
|
25
|
+
prod_bucket:
|
|
26
|
+
type: string
|
|
27
|
+
required: true
|
|
28
|
+
description: Destination (prod) base S3 bucket; us-east-1 variant auto-derived for Lambda@Edge.
|
|
29
|
+
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
id-token: write
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
promote:
|
|
36
|
+
runs-on: ubuntu-24.04
|
|
37
|
+
env:
|
|
38
|
+
SOURCE_SHA: ${{ inputs.source_sha }}
|
|
39
|
+
steps:
|
|
40
|
+
# Validate source_sha BEFORE checkout to defend against script injection
|
|
41
|
+
# via the inputs.source_sha expression (passed through env: + bash regex).
|
|
42
|
+
- name: Validate source_sha shape (40-char hex)
|
|
43
|
+
run: |
|
|
44
|
+
if ! [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
|
45
|
+
echo "::error::source_sha must be 40-char lowercase hex; got '$SOURCE_SHA'"
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
- uses: actions/checkout@v7
|
|
50
|
+
with:
|
|
51
|
+
ref: ${{ env.SOURCE_SHA }}
|
|
52
|
+
fetch-depth: 0
|
|
53
|
+
|
|
54
|
+
- name: Validate source_sha is reachable from origin/master
|
|
55
|
+
run: |
|
|
56
|
+
git fetch origin master
|
|
57
|
+
if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/master; then
|
|
58
|
+
echo "::error::source_sha=$SOURCE_SHA not on master lineage"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
63
|
+
|
|
64
|
+
- name: Configure AWS credentials (prod promoter)
|
|
65
|
+
uses: aws-actions/configure-aws-credentials@v6
|
|
66
|
+
with:
|
|
67
|
+
role-to-assume: ${{ inputs.promoter_role_arn }}
|
|
68
|
+
aws-region: eu-west-1
|
|
69
|
+
|
|
70
|
+
- name: Promote dev -> prod (content-addressed copy)
|
|
71
|
+
env:
|
|
72
|
+
REPRO_LAMBDA_DEV_BUCKET: ${{ inputs.dev_bucket }}
|
|
73
|
+
REPRO_LAMBDA_PROD_BUCKET: ${{ inputs.prod_bucket }}
|
|
74
|
+
MANIFEST_PATH: ${{ inputs.manifest_path }}
|
|
75
|
+
run: uvx --from repro-lambda repro-lambda promote --manifest "$MANIFEST_PATH"
|
|
@@ -2,7 +2,9 @@ name: publish
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
tags
|
|
5
|
+
# Full semver tags only (vX.Y.Z) trigger a PyPI release. The sliding major
|
|
6
|
+
# tag (v1), which consumer workflows pin via @v1, must NOT trigger publish.
|
|
7
|
+
tags: ["v*.*.*"]
|
|
6
8
|
|
|
7
9
|
jobs:
|
|
8
10
|
build-and-publish:
|
|
@@ -11,8 +13,8 @@ jobs:
|
|
|
11
13
|
id-token: write
|
|
12
14
|
contents: read
|
|
13
15
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
15
|
-
- uses: astral-sh/setup-uv@
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
16
18
|
- run: uv sync --all-extras
|
|
17
19
|
|
|
18
20
|
- name: Verify tag matches package version
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.0 - 2026-06-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `extra_files` manifest field: bundle prebuilt files or directories into a lambda package alongside its source. Each `[[lambda.extra_files]]` entry has `src` (repo-root-relative, where CI materialized it - e.g. a digest-pinned binary or an extracted release tree), `dest` (package-root-relative), and an optional `executable` flag (sets +x on a file; ignored for directories, which keep source perms). The bytes fold into the content hash via the staged source tree, and the executable bit folds in separately, so flipping it changes the artifact hash even when bytes are identical. This lets a lambda ship vendored CLIs or release trees the consumer's CI downloads and verifies, while the tool itself stays free of any network/tool-download logic. Paths are validated as relative and `..`-free. Specs without `extra_files` hash byte-identically to before.
|
|
7
|
+
|
|
8
|
+
## v0.3.0 - 2026-06-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `promote` command: copy an already-built artifact from the dev bucket to the prod bucket by content hash, with no rebuild. The sha per lambda is read from `builds/catalog.json`, so the promoted object is byte-for-byte the one built and tested in dev. Idempotent (skips keys already present); Lambda@Edge specs resolve to the `-us-east-1` bucket variant on both sides. `S3Uploader.copy()` performs the server-side `CopyObject`.
|
|
12
|
+
- Reusable workflow `promote.yml`: validates `source_sha` (40-char hex + master-lineage), checks it out, assumes a caller-supplied promoter role, and runs `promote`. Inputs: `manifest_path`, `source_sha`, `promoter_role_arn`, `dev_bucket`, `prod_bucket`.
|
|
13
|
+
|
|
14
|
+
### Consumer migration
|
|
15
|
+
- Replace a rebuild-on-prod `promote-to-prod` job with a call to the reusable workflow:
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
promote:
|
|
19
|
+
uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v1
|
|
20
|
+
with:
|
|
21
|
+
source_sha: ${{ inputs.source_sha }}
|
|
22
|
+
promoter_role_arn: arn:aws:iam::<account>:role/<promoter-role>
|
|
23
|
+
dev_bucket: <env>-my-lambda-artifacts
|
|
24
|
+
prod_bucket: <env>-my-lambda-artifacts
|
|
25
|
+
|
|
3
26
|
## v0.2.4 - 2026-06-20
|
|
4
27
|
|
|
5
28
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repro-lambda
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -75,6 +75,7 @@ def compute_sha_for(
|
|
|
75
75
|
builder=builder,
|
|
76
76
|
stage_dir=stage_dir,
|
|
77
77
|
extra_files=extras,
|
|
78
|
+
payload_files=list(spec.extra_files),
|
|
78
79
|
)
|
|
79
80
|
return compute_content_hash(
|
|
80
81
|
staged_source_root=stage_dir / "source",
|
|
@@ -83,6 +84,7 @@ def compute_sha_for(
|
|
|
83
84
|
base_image=primary_base_image,
|
|
84
85
|
builder_version=__version__,
|
|
85
86
|
extra_files=extras,
|
|
87
|
+
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
86
88
|
)
|
|
87
89
|
|
|
88
90
|
|
|
@@ -113,6 +115,7 @@ def build_one(
|
|
|
113
115
|
builder=builder,
|
|
114
116
|
stage_dir=stage_dir,
|
|
115
117
|
extra_files=extras,
|
|
118
|
+
payload_files=list(spec.extra_files),
|
|
116
119
|
)
|
|
117
120
|
|
|
118
121
|
sha = compute_content_hash(
|
|
@@ -122,6 +125,7 @@ def build_one(
|
|
|
122
125
|
base_image=primary_base_image,
|
|
123
126
|
builder_version=__version__,
|
|
124
127
|
extra_files=extras,
|
|
128
|
+
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
125
129
|
)
|
|
126
130
|
bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
|
|
127
131
|
|
|
@@ -169,6 +169,95 @@ def build(
|
|
|
169
169
|
typer.echo(json.dumps(summary, indent=2))
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
@app.command()
|
|
173
|
+
def promote(
|
|
174
|
+
target: Annotated[str, typer.Argument(help="Lambda logical_name or 'all'.")] = "all",
|
|
175
|
+
manifest: Annotated[
|
|
176
|
+
Path,
|
|
177
|
+
typer.Option("--manifest", "-m", help="Path to lambdas.toml."),
|
|
178
|
+
] = Path("lambdas.toml"),
|
|
179
|
+
dev_bucket: Annotated[
|
|
180
|
+
str,
|
|
181
|
+
typer.Option(
|
|
182
|
+
"--dev-bucket",
|
|
183
|
+
envvar="REPRO_LAMBDA_DEV_BUCKET",
|
|
184
|
+
help="Source (dev) base bucket; us-east-1 variant auto-derived.",
|
|
185
|
+
),
|
|
186
|
+
] = "",
|
|
187
|
+
prod_bucket: Annotated[
|
|
188
|
+
str,
|
|
189
|
+
typer.Option(
|
|
190
|
+
"--prod-bucket",
|
|
191
|
+
envvar="REPRO_LAMBDA_PROD_BUCKET",
|
|
192
|
+
help="Destination (prod) base bucket; us-east-1 variant auto-derived.",
|
|
193
|
+
),
|
|
194
|
+
] = "",
|
|
195
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Copy built artifacts dev -> prod by content hash (no rebuild).
|
|
198
|
+
|
|
199
|
+
The sha per lambda is read from builds/catalog.json, so the artifact promoted
|
|
200
|
+
is exactly the one the source commit built and tested in dev.
|
|
201
|
+
"""
|
|
202
|
+
import json
|
|
203
|
+
|
|
204
|
+
from repro_lambda.manifest import load_manifest
|
|
205
|
+
from repro_lambda.promote import (
|
|
206
|
+
MissingSourceArtifactError,
|
|
207
|
+
UnknownShaError,
|
|
208
|
+
promote_one,
|
|
209
|
+
sha_from_catalog,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
repo_root = manifest.parent.resolve()
|
|
213
|
+
parsed = load_manifest(manifest)
|
|
214
|
+
|
|
215
|
+
selected = (
|
|
216
|
+
parsed.lambdas
|
|
217
|
+
if target == "all"
|
|
218
|
+
else [s for s in parsed.lambdas if s.logical_name == target]
|
|
219
|
+
)
|
|
220
|
+
if not selected:
|
|
221
|
+
typer.echo(f"no lambda named {target!r} in {manifest}", err=True)
|
|
222
|
+
raise typer.Exit(2)
|
|
223
|
+
|
|
224
|
+
if not dry_run and (not dev_bucket or not prod_bucket):
|
|
225
|
+
typer.echo(
|
|
226
|
+
"--dev-bucket and --prod-bucket (or REPRO_LAMBDA_DEV_BUCKET / "
|
|
227
|
+
"REPRO_LAMBDA_PROD_BUCKET) are required for non-dry-run",
|
|
228
|
+
err=True,
|
|
229
|
+
)
|
|
230
|
+
raise typer.Exit(2)
|
|
231
|
+
|
|
232
|
+
catalog_path = repo_root / "builds" / "catalog.json"
|
|
233
|
+
summary = []
|
|
234
|
+
for spec in selected:
|
|
235
|
+
try:
|
|
236
|
+
sha = sha_from_catalog(catalog_path, spec.logical_name)
|
|
237
|
+
outcome = promote_one(
|
|
238
|
+
spec=spec,
|
|
239
|
+
sha=sha,
|
|
240
|
+
dev_bucket=dev_bucket,
|
|
241
|
+
prod_bucket=prod_bucket,
|
|
242
|
+
dry_run=dry_run,
|
|
243
|
+
)
|
|
244
|
+
except (MissingSourceArtifactError, UnknownShaError) as e:
|
|
245
|
+
typer.echo(str(e), err=True)
|
|
246
|
+
raise typer.Exit(1) from e
|
|
247
|
+
summary.append(
|
|
248
|
+
{
|
|
249
|
+
"logical_name": spec.logical_name,
|
|
250
|
+
"outcome": outcome.outcome.value,
|
|
251
|
+
"sha256": outcome.sha256,
|
|
252
|
+
"bucket_key": outcome.bucket_key,
|
|
253
|
+
"src_bucket": outcome.src_bucket,
|
|
254
|
+
"dst_bucket": outcome.dst_bucket,
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
typer.echo(json.dumps(summary, indent=2))
|
|
259
|
+
|
|
260
|
+
|
|
172
261
|
@app.command()
|
|
173
262
|
def lock(
|
|
174
263
|
manifest: Annotated[Path, typer.Option("--manifest", "-m")] = Path("lambdas.toml"),
|
|
@@ -24,6 +24,7 @@ def compute_content_hash(
|
|
|
24
24
|
builder_version: str,
|
|
25
25
|
*,
|
|
26
26
|
extra_files: list[tuple[Path, str]] | None = None,
|
|
27
|
+
payload_exec: list[tuple[str, bool]] | None = None,
|
|
27
28
|
) -> str:
|
|
28
29
|
"""
|
|
29
30
|
sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
|
|
@@ -72,4 +73,13 @@ def compute_content_hash(
|
|
|
72
73
|
h.update(_sha256_file(src).encode("ascii"))
|
|
73
74
|
h.update(b"\n")
|
|
74
75
|
|
|
76
|
+
# Payload extra_files (prebuilt binaries/trees) are already hashed by content
|
|
77
|
+
# via the staged source tree above; fold in their executable bit here so that
|
|
78
|
+
# flipping +x changes the artifact hash even when bytes are unchanged. Omitted
|
|
79
|
+
# entirely when empty, preserving byte-identical hashes for specs with none.
|
|
80
|
+
if payload_exec:
|
|
81
|
+
h.update(b"---payload-exec---\n")
|
|
82
|
+
for dest, executable in sorted(payload_exec):
|
|
83
|
+
h.update(f"{dest}={int(executable)}\n".encode())
|
|
84
|
+
|
|
75
85
|
return h.hexdigest()
|
|
@@ -17,6 +17,24 @@ SUPPORTED_ARCHS: tuple[str, ...] = ("arm64", "x86_64")
|
|
|
17
17
|
SUPPORTED_PACKAGE_MANAGERS = {"pip", "npm"}
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ExtraFile:
|
|
22
|
+
"""A prebuilt file or directory staged into the package alongside the source.
|
|
23
|
+
|
|
24
|
+
`src` is relative to the repo root (where the caller's CI materialized it, e.g.
|
|
25
|
+
a downloaded + digest-pinned binary or an extracted release tree). `dest` is
|
|
26
|
+
where it lands in the package (relative to the package root). For a file,
|
|
27
|
+
`executable` sets the +x bit; for a directory, source perms are preserved and
|
|
28
|
+
`executable` is ignored. The bytes fold into the content hash via the staged
|
|
29
|
+
source tree; the executable flag folds in separately, so flipping it changes
|
|
30
|
+
the artifact hash even when bytes are unchanged.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
src: str
|
|
34
|
+
dest: str
|
|
35
|
+
executable: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
20
38
|
@dataclass(frozen=True)
|
|
21
39
|
class LambdaSpec:
|
|
22
40
|
logical_name: str
|
|
@@ -30,6 +48,7 @@ class LambdaSpec:
|
|
|
30
48
|
lambda_at_edge: bool = False
|
|
31
49
|
hash_extra: str = ""
|
|
32
50
|
package_json: str = ""
|
|
51
|
+
extra_files: tuple[ExtraFile, ...] = ()
|
|
33
52
|
|
|
34
53
|
@property
|
|
35
54
|
def resolved_requirements_lock(self) -> str:
|
|
@@ -63,6 +82,26 @@ class Manifest:
|
|
|
63
82
|
builder: BuilderConfig
|
|
64
83
|
|
|
65
84
|
|
|
85
|
+
def _parse_extra_files(path: Path, entry: dict) -> tuple[ExtraFile, ...]:
|
|
86
|
+
"""Parse + validate a lambda's optional [[lambda.extra_files]] entries."""
|
|
87
|
+
parsed: list[ExtraFile] = []
|
|
88
|
+
for ef in entry.get("extra_files", []):
|
|
89
|
+
src = ef.get("src", "")
|
|
90
|
+
dest = ef.get("dest", "")
|
|
91
|
+
if not src or not dest:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"{path}: extra_files entry requires non-empty 'src' and 'dest' (got {ef!r})"
|
|
94
|
+
)
|
|
95
|
+
for field_name, value in (("src", src), ("dest", dest)):
|
|
96
|
+
if value.startswith("/") or ".." in Path(value).parts:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"{path}: extra_files {field_name}={value!r} must be a relative path "
|
|
99
|
+
f"without '..' (src is repo-root-relative, dest is package-root-relative)"
|
|
100
|
+
)
|
|
101
|
+
parsed.append(ExtraFile(src=src, dest=dest, executable=bool(ef.get("executable", False))))
|
|
102
|
+
return tuple(parsed)
|
|
103
|
+
|
|
104
|
+
|
|
66
105
|
def load_manifest(path: Path) -> Manifest:
|
|
67
106
|
"""Parse lambdas.toml and validate semantic invariants."""
|
|
68
107
|
with path.open("rb") as f:
|
|
@@ -119,6 +158,8 @@ def load_manifest(path: Path) -> Manifest:
|
|
|
119
158
|
f"point it at the lambda's package.json relative to repo root"
|
|
120
159
|
)
|
|
121
160
|
|
|
161
|
+
extra_files = _parse_extra_files(path, entry)
|
|
162
|
+
|
|
122
163
|
lambdas.append(
|
|
123
164
|
LambdaSpec(
|
|
124
165
|
logical_name=entry["logical_name"],
|
|
@@ -132,6 +173,7 @@ def load_manifest(path: Path) -> Manifest:
|
|
|
132
173
|
package_manager=pkg,
|
|
133
174
|
lambda_at_edge=bool(entry.get("lambda_at_edge", False)),
|
|
134
175
|
hash_extra=entry.get("hash_extra", ""),
|
|
176
|
+
extra_files=extra_files,
|
|
135
177
|
)
|
|
136
178
|
)
|
|
137
179
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Promote a built lambda artifact from the dev bucket to the prod bucket.
|
|
2
|
+
|
|
3
|
+
Promotion is a content-addressed S3 copy: the exact `lambdas/<name>/<sha>.zip`
|
|
4
|
+
object already built and verified against the dev bucket is copied byte-for-byte
|
|
5
|
+
to the prod bucket. There is no rebuild, so a cross-architecture prod runner can
|
|
6
|
+
never produce a different artifact than the one tested in dev.
|
|
7
|
+
|
|
8
|
+
The sha to promote comes from `builds/catalog.json` (the bounded build history
|
|
9
|
+
committed to the source repo), so a promote always targets the artifact the
|
|
10
|
+
source commit recorded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from repro_lambda.build import _bucket_for
|
|
20
|
+
from repro_lambda.catalog import load_catalog
|
|
21
|
+
from repro_lambda.manifest import LambdaSpec
|
|
22
|
+
from repro_lambda.s3_uploader import S3Uploader, UploadResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PromoteResult(enum.Enum):
|
|
26
|
+
PROMOTED = "promoted"
|
|
27
|
+
ALREADY_PRESENT = "already_present"
|
|
28
|
+
DRY_RUN = "dry_run"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PromoteOutcome:
|
|
33
|
+
outcome: PromoteResult
|
|
34
|
+
sha256: str
|
|
35
|
+
bucket_key: str
|
|
36
|
+
src_bucket: str
|
|
37
|
+
dst_bucket: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MissingSourceArtifactError(RuntimeError):
|
|
41
|
+
"""The dev artifact to promote does not exist (build dev first)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnknownShaError(RuntimeError):
|
|
45
|
+
"""builds/catalog.json has no current sha for the requested lambda."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha_from_catalog(catalog_path: Path, logical_name: str) -> str:
|
|
49
|
+
"""Return the current sha recorded for `logical_name`, or raise UnknownShaError."""
|
|
50
|
+
catalog = load_catalog(catalog_path)
|
|
51
|
+
lc = catalog.lambdas.get(logical_name)
|
|
52
|
+
if lc is None or not lc.current:
|
|
53
|
+
raise UnknownShaError(
|
|
54
|
+
f"no catalog entry for {logical_name!r} in {catalog_path}; "
|
|
55
|
+
f"build the dev artifact before promoting"
|
|
56
|
+
)
|
|
57
|
+
return lc.current
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def promote_one(
|
|
61
|
+
*,
|
|
62
|
+
spec: LambdaSpec,
|
|
63
|
+
sha: str,
|
|
64
|
+
dev_bucket: str,
|
|
65
|
+
prod_bucket: str,
|
|
66
|
+
uploader: S3Uploader | None = None,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
) -> PromoteOutcome:
|
|
69
|
+
"""Copy one lambda's `<sha>.zip` from the dev bucket to the prod bucket.
|
|
70
|
+
|
|
71
|
+
Lambda@Edge specs (region us-east-1) resolve to the `-us-east-1` bucket
|
|
72
|
+
variant on both sides, matching the build-side key scheme exactly.
|
|
73
|
+
"""
|
|
74
|
+
src_bucket = _bucket_for(spec, dev_bucket)
|
|
75
|
+
dst_bucket = _bucket_for(spec, prod_bucket)
|
|
76
|
+
key = f"lambdas/{spec.logical_name}/{sha}.zip"
|
|
77
|
+
|
|
78
|
+
if dry_run:
|
|
79
|
+
return PromoteOutcome(PromoteResult.DRY_RUN, sha, key, src_bucket, dst_bucket)
|
|
80
|
+
|
|
81
|
+
up = uploader or S3Uploader(region=spec.region)
|
|
82
|
+
if not up.exists(bucket=src_bucket, key=key):
|
|
83
|
+
raise MissingSourceArtifactError(
|
|
84
|
+
f"{spec.logical_name}: source artifact missing at s3://{src_bucket}/{key}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
result = up.copy(src_bucket=src_bucket, dst_bucket=dst_bucket, key=key)
|
|
88
|
+
outcome = (
|
|
89
|
+
PromoteResult.PROMOTED if result is UploadResult.UPLOADED else PromoteResult.ALREADY_PRESENT
|
|
90
|
+
)
|
|
91
|
+
return PromoteOutcome(outcome, sha, key, src_bucket, dst_bucket)
|
|
@@ -28,6 +28,25 @@ class S3Uploader:
|
|
|
28
28
|
return False
|
|
29
29
|
raise
|
|
30
30
|
|
|
31
|
+
def copy(self, *, src_bucket: str, dst_bucket: str, key: str) -> UploadResult:
|
|
32
|
+
"""
|
|
33
|
+
Server-side copy of the same key from src_bucket to dst_bucket.
|
|
34
|
+
|
|
35
|
+
Idempotent via a destination existence pre-check; safe because artifact
|
|
36
|
+
buckets are content-addressed and immutable (the same key never changes
|
|
37
|
+
bytes). Returns ALREADY_PRESENT if the destination key already exists,
|
|
38
|
+
UPLOADED otherwise.
|
|
39
|
+
"""
|
|
40
|
+
if self.exists(bucket=dst_bucket, key=key):
|
|
41
|
+
return UploadResult.ALREADY_PRESENT
|
|
42
|
+
self._client.copy_object(
|
|
43
|
+
Bucket=dst_bucket,
|
|
44
|
+
Key=key,
|
|
45
|
+
CopySource={"Bucket": src_bucket, "Key": key},
|
|
46
|
+
ServerSideEncryption="AES256",
|
|
47
|
+
)
|
|
48
|
+
return UploadResult.UPLOADED
|
|
49
|
+
|
|
31
50
|
def upload(self, *, bucket: str, key: str, body_path: Path) -> UploadResult:
|
|
32
51
|
"""
|
|
33
52
|
PutObject with If-None-Match=*.
|
|
@@ -7,7 +7,7 @@ import shutil
|
|
|
7
7
|
import subprocess
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from repro_lambda.manifest import BuilderConfig
|
|
10
|
+
from repro_lambda.manifest import BuilderConfig, ExtraFile
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _git_ls_files(repo_root: Path, source_dir: str) -> list[str]:
|
|
@@ -37,6 +37,29 @@ def _filter_paths(paths: list[str], include: list[str], exclude: list[str]) -> l
|
|
|
37
37
|
return kept
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _stage_payload_files(
|
|
41
|
+
repo_root: Path, target_root: Path, payload_files: list[ExtraFile]
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Stage prebuilt files/dirs (CI-materialized, not git-tracked) into the package.
|
|
44
|
+
|
|
45
|
+
Each lands at target_root/<dest> (the staged source tree, so it ships in the zip
|
|
46
|
+
and folds into the content hash). Files get the +x bit when `executable`; dirs
|
|
47
|
+
are copied recursively with source perms preserved.
|
|
48
|
+
"""
|
|
49
|
+
for ef in payload_files:
|
|
50
|
+
src = repo_root / ef.src
|
|
51
|
+
dest = target_root / ef.dest
|
|
52
|
+
if src.is_dir():
|
|
53
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
54
|
+
elif src.is_file():
|
|
55
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
shutil.copy2(src, dest)
|
|
57
|
+
if ef.executable:
|
|
58
|
+
dest.chmod(dest.stat().st_mode | 0o111)
|
|
59
|
+
else:
|
|
60
|
+
raise FileNotFoundError(f"extra_files src not found: {src} (declared src={ef.src!r})")
|
|
61
|
+
|
|
62
|
+
|
|
40
63
|
def stage_source(
|
|
41
64
|
repo_root: Path,
|
|
42
65
|
source_dir: str,
|
|
@@ -44,13 +67,16 @@ def stage_source(
|
|
|
44
67
|
stage_dir: Path,
|
|
45
68
|
*,
|
|
46
69
|
extra_files: list[tuple[Path, str]] | None = None,
|
|
70
|
+
payload_files: list[ExtraFile] | None = None,
|
|
47
71
|
) -> list[str]:
|
|
48
72
|
"""
|
|
49
73
|
Copy git-tracked files under source_dir into stage_dir/source/, preserving perms.
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
`extra_files` are build inputs (e.g. the requirements lock) copied to
|
|
76
|
+
stage_dir/<rel_name> - consumed by the container, not shipped in the zip.
|
|
77
|
+
|
|
78
|
+
`payload_files` are prebuilt artifacts copied into stage_dir/source/<dest> so
|
|
79
|
+
they ship in the zip and fold into the content hash.
|
|
54
80
|
|
|
55
81
|
Returns the sorted list of relative paths (from repo_root) that were staged.
|
|
56
82
|
"""
|
|
@@ -71,6 +97,8 @@ def stage_source(
|
|
|
71
97
|
if src_mode & 0o111:
|
|
72
98
|
dst.chmod(dst.stat().st_mode | 0o111)
|
|
73
99
|
|
|
100
|
+
_stage_payload_files(repo_root, target_root, payload_files or [])
|
|
101
|
+
|
|
74
102
|
for src_path, rel_name in extra_files or []:
|
|
75
103
|
if not src_path.is_file():
|
|
76
104
|
raise FileNotFoundError(f"extra_files source not found: {src_path}")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""extra_files: prebuilt file/dir bundling into the content-addressed package."""
|
|
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 BuilderConfig, ExtraFile, LambdaSpec, load_manifest
|
|
11
|
+
from repro_lambda.source_stager import stage_source
|
|
12
|
+
|
|
13
|
+
PINNED_IMAGE = "public.ecr.aws/lambda/python:3.13@sha256:" + "0" * 64
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _spec(**kw) -> LambdaSpec:
|
|
17
|
+
base = dict(
|
|
18
|
+
logical_name="app",
|
|
19
|
+
source_dir="handler",
|
|
20
|
+
requirements_lock="handler/requirements.${arch}.lock",
|
|
21
|
+
runtime="python3.13",
|
|
22
|
+
arch="arm64",
|
|
23
|
+
handler="app.lambda_handler",
|
|
24
|
+
)
|
|
25
|
+
base.update(kw)
|
|
26
|
+
return LambdaSpec(**base)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- manifest parse / validate --------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_manifest(tmp_path: Path, extra_files_toml: str) -> Path:
|
|
33
|
+
p = tmp_path / "lambdas.toml"
|
|
34
|
+
p.write_text(
|
|
35
|
+
"[[lambda]]\n"
|
|
36
|
+
'logical_name = "app"\n'
|
|
37
|
+
'source_dir = "handler"\n'
|
|
38
|
+
'requirements_lock = "handler/requirements.${arch}.lock"\n'
|
|
39
|
+
'runtime = "python3.13"\n'
|
|
40
|
+
'arch = "arm64"\n'
|
|
41
|
+
'handler = "app.lambda_handler"\n'
|
|
42
|
+
f"{extra_files_toml}"
|
|
43
|
+
"\n[builder]\n"
|
|
44
|
+
f'base_image_python = "{PINNED_IMAGE}"\n'
|
|
45
|
+
)
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_manifest_parses_extra_files(tmp_path: Path):
|
|
50
|
+
manifest = _write_manifest(
|
|
51
|
+
tmp_path,
|
|
52
|
+
'[[lambda.extra_files]]\nsrc = "builds/bin/terraform"\n'
|
|
53
|
+
'dest = "bin/terraform"\nexecutable = true\n'
|
|
54
|
+
'[[lambda.extra_files]]\nsrc = "builds/pofix"\ndest = "pofix"\n',
|
|
55
|
+
)
|
|
56
|
+
spec = load_manifest(manifest).lambdas[0]
|
|
57
|
+
assert spec.extra_files == (
|
|
58
|
+
ExtraFile(src="builds/bin/terraform", dest="bin/terraform", executable=True),
|
|
59
|
+
ExtraFile(src="builds/pofix", dest="pofix", executable=False),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_manifest_extra_files_defaults_empty(tmp_path: Path):
|
|
64
|
+
manifest = _write_manifest(tmp_path, "")
|
|
65
|
+
assert load_manifest(manifest).lambdas[0].extra_files == ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_manifest_rejects_extra_files_without_dest(tmp_path: Path):
|
|
69
|
+
manifest = _write_manifest(tmp_path, '[[lambda.extra_files]]\nsrc = "builds/bin/terraform"\n')
|
|
70
|
+
with pytest.raises(ValueError, match="non-empty 'src' and 'dest'"):
|
|
71
|
+
load_manifest(manifest)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_manifest_rejects_extra_files_parent_traversal(tmp_path: Path):
|
|
75
|
+
manifest = _write_manifest(
|
|
76
|
+
tmp_path,
|
|
77
|
+
'[[lambda.extra_files]]\nsrc = "builds/bin/x"\ndest = "../escape"\n',
|
|
78
|
+
)
|
|
79
|
+
with pytest.raises(ValueError, match="without '..'"):
|
|
80
|
+
load_manifest(manifest)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_manifest_rejects_extra_files_absolute_src(tmp_path: Path):
|
|
84
|
+
manifest = _write_manifest(
|
|
85
|
+
tmp_path,
|
|
86
|
+
'[[lambda.extra_files]]\nsrc = "/etc/passwd"\ndest = "bin/x"\n',
|
|
87
|
+
)
|
|
88
|
+
with pytest.raises(ValueError, match="relative path"):
|
|
89
|
+
load_manifest(manifest)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- stager ----------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _git_repo_with_source(tmp_path: Path) -> Path:
|
|
96
|
+
repo = tmp_path / "repo"
|
|
97
|
+
(repo / "handler").mkdir(parents=True)
|
|
98
|
+
(repo / "handler" / "app.py").write_text("def lambda_handler(e, c): return 200\n")
|
|
99
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
100
|
+
subprocess.run(["git", "config", "user.email", "t@t"], cwd=repo, check=True)
|
|
101
|
+
subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True)
|
|
102
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
103
|
+
subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
|
|
104
|
+
return repo
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_stage_payload_file_gets_exec_bit(tmp_path: Path):
|
|
108
|
+
repo = _git_repo_with_source(tmp_path)
|
|
109
|
+
(repo / "builds" / "bin").mkdir(parents=True)
|
|
110
|
+
binary = repo / "builds" / "bin" / "terraform"
|
|
111
|
+
binary.write_bytes(b"\x7fELF fake binary")
|
|
112
|
+
|
|
113
|
+
stage = tmp_path / "stage"
|
|
114
|
+
stage_source(
|
|
115
|
+
repo_root=repo,
|
|
116
|
+
source_dir="handler",
|
|
117
|
+
builder=BuilderConfig(base_image_python=PINNED_IMAGE),
|
|
118
|
+
stage_dir=stage,
|
|
119
|
+
payload_files=[
|
|
120
|
+
ExtraFile(src="builds/bin/terraform", dest="bin/terraform", executable=True)
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
staged = stage / "source" / "bin" / "terraform"
|
|
124
|
+
assert staged.read_bytes() == b"\x7fELF fake binary"
|
|
125
|
+
assert staged.stat().st_mode & 0o111, "executable bit not set"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_stage_payload_dir_recursive(tmp_path: Path):
|
|
129
|
+
repo = _git_repo_with_source(tmp_path)
|
|
130
|
+
tree = repo / "builds" / "pofix"
|
|
131
|
+
(tree / "data").mkdir(parents=True)
|
|
132
|
+
(tree / ".tool-versions").write_text("terraform 1.9.0\n")
|
|
133
|
+
(tree / "data" / "x.json").write_text("{}\n")
|
|
134
|
+
|
|
135
|
+
stage = tmp_path / "stage"
|
|
136
|
+
stage_source(
|
|
137
|
+
repo_root=repo,
|
|
138
|
+
source_dir="handler",
|
|
139
|
+
builder=BuilderConfig(base_image_python=PINNED_IMAGE),
|
|
140
|
+
stage_dir=stage,
|
|
141
|
+
payload_files=[ExtraFile(src="builds/pofix", dest="pofix")],
|
|
142
|
+
)
|
|
143
|
+
assert (stage / "source" / "pofix" / ".tool-versions").read_text() == "terraform 1.9.0\n"
|
|
144
|
+
assert (stage / "source" / "pofix" / "data" / "x.json").exists()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_stage_payload_missing_src_raises(tmp_path: Path):
|
|
148
|
+
repo = _git_repo_with_source(tmp_path)
|
|
149
|
+
stage = tmp_path / "stage"
|
|
150
|
+
with pytest.raises(FileNotFoundError, match="extra_files src not found"):
|
|
151
|
+
stage_source(
|
|
152
|
+
repo_root=repo,
|
|
153
|
+
source_dir="handler",
|
|
154
|
+
builder=BuilderConfig(base_image_python=PINNED_IMAGE),
|
|
155
|
+
stage_dir=stage,
|
|
156
|
+
payload_files=[ExtraFile(src="builds/missing", dest="bin/x")],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- hasher ----------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _staged_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
164
|
+
root = tmp_path / "source"
|
|
165
|
+
(root / "bin").mkdir(parents=True)
|
|
166
|
+
(root / "app.py").write_text("x = 1\n")
|
|
167
|
+
(root / "bin" / "terraform").write_bytes(b"BIN")
|
|
168
|
+
lock = tmp_path / "requirements.lock"
|
|
169
|
+
lock.write_text("")
|
|
170
|
+
return root, lock
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _hash(root: Path, lock: Path, *, payload_exec=None) -> str:
|
|
174
|
+
return compute_content_hash(
|
|
175
|
+
staged_source_root=root,
|
|
176
|
+
requirements_lock=lock,
|
|
177
|
+
spec=_spec(),
|
|
178
|
+
base_image=PINNED_IMAGE,
|
|
179
|
+
builder_version="0.3.0",
|
|
180
|
+
payload_exec=payload_exec,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_hash_none_equals_empty_payload_exec(tmp_path: Path):
|
|
185
|
+
root, lock = _staged_tree(tmp_path)
|
|
186
|
+
assert _hash(root, lock, payload_exec=None) == _hash(root, lock, payload_exec=[])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_hash_changes_when_executable_flag_flips(tmp_path: Path):
|
|
190
|
+
root, lock = _staged_tree(tmp_path)
|
|
191
|
+
off = _hash(root, lock, payload_exec=[("bin/terraform", False)])
|
|
192
|
+
on = _hash(root, lock, payload_exec=[("bin/terraform", True)])
|
|
193
|
+
assert off != on
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_hash_omits_payload_section_when_empty(tmp_path: Path):
|
|
197
|
+
# Byte-identical to a spec with no extra_files at all (regression guard for
|
|
198
|
+
# the 3 existing lambdas, whose hashes must not move).
|
|
199
|
+
root, lock = _staged_tree(tmp_path)
|
|
200
|
+
assert _hash(root, lock) == _hash(root, lock, payload_exec=[])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- build.compute_sha_for integration -------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_compute_sha_for_reflects_payload_binary(tmp_path: Path):
|
|
207
|
+
repo = _git_repo_with_source(tmp_path)
|
|
208
|
+
(repo / "handler" / "requirements.arm64.lock").write_text("")
|
|
209
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
210
|
+
subprocess.run(["git", "commit", "-qm", "lock"], cwd=repo, check=True)
|
|
211
|
+
(repo / "builds" / "bin").mkdir(parents=True)
|
|
212
|
+
binary = repo / "builds" / "bin" / "terraform"
|
|
213
|
+
|
|
214
|
+
builder = BuilderConfig(base_image_python=PINNED_IMAGE)
|
|
215
|
+
spec = _spec(extra_files=(ExtraFile(src="builds/bin/terraform", dest="bin/terraform"),))
|
|
216
|
+
|
|
217
|
+
binary.write_bytes(b"VERSION-A")
|
|
218
|
+
sha_a = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
219
|
+
binary.write_bytes(b"VERSION-B")
|
|
220
|
+
sha_b = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
221
|
+
|
|
222
|
+
assert sha_a != sha_b, "content hash must track the bundled binary's bytes"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Promote (dev -> prod copy by content hash) unit + CLI coverage."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
import pytest
|
|
7
|
+
from moto import mock_aws
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from repro_lambda.cli import app
|
|
11
|
+
from repro_lambda.manifest import LambdaSpec
|
|
12
|
+
from repro_lambda.promote import (
|
|
13
|
+
MissingSourceArtifactError,
|
|
14
|
+
PromoteResult,
|
|
15
|
+
UnknownShaError,
|
|
16
|
+
promote_one,
|
|
17
|
+
sha_from_catalog,
|
|
18
|
+
)
|
|
19
|
+
from repro_lambda.s3_uploader import S3Uploader, UploadResult
|
|
20
|
+
|
|
21
|
+
runner = CliRunner()
|
|
22
|
+
|
|
23
|
+
DEV = "dev-test-lambda-artifacts"
|
|
24
|
+
PROD = "prod-test-lambda-artifacts"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_bucket(client, name: str, region: str) -> None:
|
|
28
|
+
if region == "us-east-1":
|
|
29
|
+
client.create_bucket(Bucket=name)
|
|
30
|
+
else:
|
|
31
|
+
client.create_bucket(Bucket=name, CreateBucketConfiguration={"LocationConstraint": region})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _spec(name: str, *, region: str = "eu-west-1", at_edge: bool = False) -> LambdaSpec:
|
|
35
|
+
return LambdaSpec(
|
|
36
|
+
logical_name=name,
|
|
37
|
+
source_dir="handler",
|
|
38
|
+
requirements_lock="handler/requirements.${arch}.lock",
|
|
39
|
+
runtime="python3.13",
|
|
40
|
+
arch="x86_64" if at_edge else "arm64",
|
|
41
|
+
handler="app.lambda_handler",
|
|
42
|
+
region=region,
|
|
43
|
+
lambda_at_edge=at_edge,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- S3Uploader.copy -------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_copy_uploads_then_reports_already_present():
|
|
51
|
+
with mock_aws():
|
|
52
|
+
c = boto3.client("s3", region_name="eu-west-1")
|
|
53
|
+
_make_bucket(c, DEV, "eu-west-1")
|
|
54
|
+
_make_bucket(c, PROD, "eu-west-1")
|
|
55
|
+
c.put_object(Bucket=DEV, Key="lambdas/app/abc.zip", Body=b"PK\x05\x06" + b"\x00" * 18)
|
|
56
|
+
|
|
57
|
+
up = S3Uploader(region="eu-west-1")
|
|
58
|
+
first = up.copy(src_bucket=DEV, dst_bucket=PROD, key="lambdas/app/abc.zip")
|
|
59
|
+
second = up.copy(src_bucket=DEV, dst_bucket=PROD, key="lambdas/app/abc.zip")
|
|
60
|
+
|
|
61
|
+
assert first == UploadResult.UPLOADED
|
|
62
|
+
assert second == UploadResult.ALREADY_PRESENT
|
|
63
|
+
assert up.exists(bucket=PROD, key="lambdas/app/abc.zip")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- promote_one -----------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_promote_one_copies_regional_lambda():
|
|
70
|
+
with mock_aws():
|
|
71
|
+
c = boto3.client("s3", region_name="eu-west-1")
|
|
72
|
+
_make_bucket(c, DEV, "eu-west-1")
|
|
73
|
+
_make_bucket(c, PROD, "eu-west-1")
|
|
74
|
+
c.put_object(Bucket=DEV, Key="lambdas/app/sha1.zip", Body=b"z")
|
|
75
|
+
|
|
76
|
+
out = promote_one(spec=_spec("app"), sha="sha1", dev_bucket=DEV, prod_bucket=PROD)
|
|
77
|
+
|
|
78
|
+
assert out.outcome == PromoteResult.PROMOTED
|
|
79
|
+
assert out.src_bucket == DEV
|
|
80
|
+
assert out.dst_bucket == PROD
|
|
81
|
+
assert out.bucket_key == "lambdas/app/sha1.zip"
|
|
82
|
+
assert c.head_object(Bucket=PROD, Key="lambdas/app/sha1.zip")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_promote_one_edge_lambda_uses_us_east_1_bucket_variant():
|
|
86
|
+
with mock_aws():
|
|
87
|
+
eu = boto3.client("s3", region_name="eu-west-1")
|
|
88
|
+
us = boto3.client("s3", region_name="us-east-1")
|
|
89
|
+
_make_bucket(eu, DEV, "eu-west-1")
|
|
90
|
+
_make_bucket(eu, PROD, "eu-west-1")
|
|
91
|
+
_make_bucket(us, f"{DEV}-us-east-1", "us-east-1")
|
|
92
|
+
_make_bucket(us, f"{PROD}-us-east-1", "us-east-1")
|
|
93
|
+
us.put_object(Bucket=f"{DEV}-us-east-1", Key="lambdas/edge/e1.zip", Body=b"z")
|
|
94
|
+
|
|
95
|
+
out = promote_one(
|
|
96
|
+
spec=_spec("edge", region="us-east-1", at_edge=True),
|
|
97
|
+
sha="e1",
|
|
98
|
+
dev_bucket=DEV,
|
|
99
|
+
prod_bucket=PROD,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert out.src_bucket == f"{DEV}-us-east-1"
|
|
103
|
+
assert out.dst_bucket == f"{PROD}-us-east-1"
|
|
104
|
+
assert us.head_object(Bucket=f"{PROD}-us-east-1", Key="lambdas/edge/e1.zip")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_promote_one_missing_source_raises():
|
|
108
|
+
with mock_aws():
|
|
109
|
+
c = boto3.client("s3", region_name="eu-west-1")
|
|
110
|
+
_make_bucket(c, DEV, "eu-west-1")
|
|
111
|
+
_make_bucket(c, PROD, "eu-west-1")
|
|
112
|
+
|
|
113
|
+
with pytest.raises(MissingSourceArtifactError):
|
|
114
|
+
promote_one(spec=_spec("app"), sha="missing", dev_bucket=DEV, prod_bucket=PROD)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_promote_one_dry_run_touches_no_s3():
|
|
118
|
+
out = promote_one(spec=_spec("app"), sha="sha1", dev_bucket=DEV, prod_bucket=PROD, dry_run=True)
|
|
119
|
+
assert out.outcome == PromoteResult.DRY_RUN
|
|
120
|
+
assert out.bucket_key == "lambdas/app/sha1.zip"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- sha_from_catalog ------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_sha_from_catalog_reads_current(tmp_path: Path):
|
|
127
|
+
catalog = tmp_path / "catalog.json"
|
|
128
|
+
catalog.write_text(
|
|
129
|
+
'{"schema_version": 1, "lambdas": {"app": {"current": "deadbeef", "history": []}}}\n'
|
|
130
|
+
)
|
|
131
|
+
assert sha_from_catalog(catalog, "app") == "deadbeef"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_sha_from_catalog_unknown_lambda_raises(tmp_path: Path):
|
|
135
|
+
catalog = tmp_path / "catalog.json"
|
|
136
|
+
catalog.write_text('{"schema_version": 1, "lambdas": {}}\n')
|
|
137
|
+
with pytest.raises(UnknownShaError):
|
|
138
|
+
sha_from_catalog(catalog, "app")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- CLI -------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _consumer(tmp_path: Path) -> Path:
|
|
145
|
+
repo = tmp_path / "consumer"
|
|
146
|
+
(repo / "handler").mkdir(parents=True)
|
|
147
|
+
(repo / "lambdas.toml").write_text(
|
|
148
|
+
"[[lambda]]\n"
|
|
149
|
+
'logical_name = "app"\n'
|
|
150
|
+
'source_dir = "handler"\n'
|
|
151
|
+
'requirements_lock = "handler/requirements.${arch}.lock"\n'
|
|
152
|
+
'runtime = "python3.13"\n'
|
|
153
|
+
'arch = "arm64"\n'
|
|
154
|
+
'handler = "app.lambda_handler"\n'
|
|
155
|
+
'region = "eu-west-1"\n'
|
|
156
|
+
"\n"
|
|
157
|
+
"[builder]\n"
|
|
158
|
+
'base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:' + "0" * 64 + '"\n'
|
|
159
|
+
)
|
|
160
|
+
(repo / "builds").mkdir()
|
|
161
|
+
(repo / "builds" / "catalog.json").write_text(
|
|
162
|
+
'{"schema_version": 1, "lambdas": {"app": {"current": "cafef00d", "history": []}}}\n'
|
|
163
|
+
)
|
|
164
|
+
return repo
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_cli_promote_copies_from_catalog(tmp_path: Path):
|
|
168
|
+
repo = _consumer(tmp_path)
|
|
169
|
+
with mock_aws():
|
|
170
|
+
c = boto3.client("s3", region_name="eu-west-1")
|
|
171
|
+
_make_bucket(c, DEV, "eu-west-1")
|
|
172
|
+
_make_bucket(c, PROD, "eu-west-1")
|
|
173
|
+
c.put_object(Bucket=DEV, Key="lambdas/app/cafef00d.zip", Body=b"z")
|
|
174
|
+
|
|
175
|
+
result = runner.invoke(
|
|
176
|
+
app,
|
|
177
|
+
[
|
|
178
|
+
"promote",
|
|
179
|
+
"app",
|
|
180
|
+
"--manifest",
|
|
181
|
+
str(repo / "lambdas.toml"),
|
|
182
|
+
"--dev-bucket",
|
|
183
|
+
DEV,
|
|
184
|
+
"--prod-bucket",
|
|
185
|
+
PROD,
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
assert result.exit_code == 0, result.stdout
|
|
189
|
+
assert "promoted" in result.stdout
|
|
190
|
+
assert c.head_object(Bucket=PROD, Key="lambdas/app/cafef00d.zip")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_cli_promote_dry_run_touches_no_s3(tmp_path: Path):
|
|
194
|
+
repo = _consumer(tmp_path)
|
|
195
|
+
result = runner.invoke(
|
|
196
|
+
app,
|
|
197
|
+
[
|
|
198
|
+
"promote",
|
|
199
|
+
"app",
|
|
200
|
+
"--manifest",
|
|
201
|
+
str(repo / "lambdas.toml"),
|
|
202
|
+
"--dev-bucket",
|
|
203
|
+
DEV,
|
|
204
|
+
"--prod-bucket",
|
|
205
|
+
PROD,
|
|
206
|
+
"--dry-run",
|
|
207
|
+
],
|
|
208
|
+
)
|
|
209
|
+
assert result.exit_code == 0, result.stdout
|
|
210
|
+
assert "dry_run" in result.stdout
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_cli_promote_unknown_target_exits_2(tmp_path: Path):
|
|
214
|
+
repo = _consumer(tmp_path)
|
|
215
|
+
result = runner.invoke(
|
|
216
|
+
app,
|
|
217
|
+
[
|
|
218
|
+
"promote",
|
|
219
|
+
"nope",
|
|
220
|
+
"--manifest",
|
|
221
|
+
str(repo / "lambdas.toml"),
|
|
222
|
+
"--dev-bucket",
|
|
223
|
+
DEV,
|
|
224
|
+
"--prod-bucket",
|
|
225
|
+
PROD,
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
assert result.exit_code == 2
|
|
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.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.2.4 → repro_lambda-0.4.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.2.4 → repro_lambda-0.4.0}/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
|