Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 41 additions & 103 deletions .claude/rules/build-test-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ same collections file and only one writer can hold the mutex

## SAM deploy (the real deploy path)

Stack name `nzimageapi`, region `ap-southeast-2`. Needs Docker (the Swift Lambda's Makefile
target and the converter's container image both build via Docker) — Docker commands need
the sandbox disabled here, since the Docker socket isn't sandbox-allowlisted.
Stack name `nzimageapi`, region `ap-southeast-2`. Needs Docker for the Swift Lambda's Makefile
target. Docker commands need the sandbox disabled here, since the Docker socket isn't
sandbox-allowlisted. The converter and guard are .zips built by their own Makefiles
(`converter/Makefile`, `guard/Makefile`) with no Docker. The converter's `pip install` needs
network access to PyPI.

```bash
sam build
Expand All @@ -75,117 +77,53 @@ Function URL wiring) — prefer `sam build && sam deploy` for anything touching

## Deploy artefact retention (cost control)

Every `sam deploy` leaves permanent artefacts behind. Left unmanaged these are the account's
only real recurring cost, and they grow with every deploy while the running stack itself
(Lambda, API Gateway, CloudWatch, SNS, SQS) sits at $0.00.
Every `sam deploy` uploads the packaged code (the Swift Lambda zip ~44 MB, the converter zip
~22 MB, the guard) and templates to S3, and versioning is on. Left unmanaged, deploy artefacts
were the account's only real recurring cost while the running stack sat at $0.00: by 2026-08-24
there were 29 converter images in ECR (~5.5 GB) and 1.45 GB in S3.

Two things accumulate:
The converter was a container image until October 2026. ECR's free storage is a 12-month
offer this account no longer has, so even three retained images cost about $0.06 a month. It is
now a .zip, whose code storage Lambda includes for free. Removing `ConverterRepository` from
`infra/bootstrap.yaml` does not delete the repository (it was `DeletionPolicy: Retain`), so after
the first .zip deploy replaced the image function, the bootstrap stack was redeployed and
`nzimageapi/jp2converter` was deleted by hand with
`aws ecr delete-repository --region ap-southeast-2 --repository-name nzimageapi/jp2converter --force`.

- **The converter's ECR repo.** Each deploy pushes a fresh ~195 MB image under a unique tag
(`jp2converterfunction-<hash>-latest`), so nothing is ever overwritten. Only the one tag
referenced by the deployed `Jp2ConverterFunction` is live.
- **The SAM artefact bucket.** Each deploy uploads the Swift Lambda zip (~44 MB) plus
packaged templates, and versioning is on.
### `infra/bootstrap.yaml` owns the artefact bucket

By 2026-08-24 that had reached 29 ECR images (~5.5 GB of manifests) and 75 S3 objects
(1.45 GB) from roughly 20 deploys since June 2026 - about $0.17/month and climbing, all of
it waste.

### `infra/bootstrap.yaml` owns both

Both resources, and both retention policies, are declared in **`infra/bootstrap.yaml`** and
deployed as the separate `nzimageapi-bootstrap` stack:
The bucket and its retention policy are declared in **`infra/bootstrap.yaml`** and deployed as
the separate `nzimageapi-bootstrap` stack:

```bash
aws cloudformation deploy --region ap-southeast-2 --stack-name nzimageapi-bootstrap --template-file infra/bootstrap.yaml
```

**Why a separate stack and not `template.yaml`:** `sam deploy` pushes the container image to
ECR and uploads the packaged template to S3 *before* CloudFormation runs. A repo or bucket
declared in the stack that consumes them would not exist yet at push time. They have to be
created by something that runs first, hence a tiny bootstrap stack deployed on its own.

Retention is tunable via stack parameters rather than by editing policy JSON:
**Why a separate stack and not `template.yaml`:** `sam deploy` uploads the packaged code and
template to S3 *before* CloudFormation runs, so a bucket declared in the stack that consumes it
would not exist yet at upload time.

| Parameter | Default | Effect |
| --- | --- | --- |
| `ImageRetentionCount` | `3` | ECR keeps this many images. Newest is live, the rest are rollback targets. |
| `ArtifactRetentionDays` | `60` | S3 artefacts expire after this many days. Non-current versions go at 7 days, stalled multipart uploads at 7. |
`ArtifactRetentionDays` (default `60`) sets when artefacts expire. Non-current versions go at 7
days, stalled multipart uploads at 7. A stack update failing more than 60 days after the deploy
that produced an artefact cannot roll back to it, which is well outside any realistic rollback
window here. The bucket carries `DeletionPolicy: Retain` / `UpdateReplacePolicy: Retain`.

The 60-day S3 window is deliberately generous. It does mean a stack update failing more than
60 days after the deploy that produced an artefact cannot roll back to it, which is well
outside any realistic rollback window here.
### The wiring

Both resources carry `DeletionPolicy: Retain` / `UpdateReplacePolicy: Retain`, so deleting or
replacing the bootstrap stack will not take the artefacts (or a bucket full of objects,
which would fail the delete anyway) with it.
The gitignored `samconfig.toml` points the main stack at the bucket with
`s3_bucket = "nzimageapi-sam-artifacts-686865771242-ap-southeast-2"` (which **replaces**
`resolve_s3 = true`. The two are mutually exclusive, and `resolve_s3` is what made SAM
auto-create its own unmanaged bucket). It no longer needs an `image_repositories` entry.

### The wiring in `samconfig.toml`
`.github/workflows/ci-cd.yml` cannot read `samconfig.toml`, so its deploy job reads the bucket
from the bootstrap stack's `ArtifactBucketName` output and passes it as `--s3-bucket`, failing
with a pointer to the bootstrap-deploy command if the stack or output is missing. It reads the
stack rather than a GitHub variable on purpose: a stale `CONVERTER_ECR_REPO` variable once kept CI
deploying into superseded, unmanaged stores. Never reintroduce `--resolve-s3` there.

The gitignored `samconfig.toml` points the main stack at the bootstrap stack's outputs:

```toml
s3_bucket = "nzimageapi-sam-artifacts-686865771242-ap-southeast-2"
image_repositories = ["Jp2ConverterFunction=686865771242.dkr.ecr.ap-southeast-2.amazonaws.com/nzimageapi/jp2converter"]
```

`s3_bucket` **replaces** `resolve_s3 = true` - the two are mutually exclusive, and
`resolve_s3` is what made SAM auto-create its own unmanaged bucket. Because `samconfig.toml`
is gitignored, this wiring is the one part that is not in version control; re-read the two
outputs after any bootstrap-stack change:

```bash
aws cloudformation describe-stacks --region ap-southeast-2 --stack-name nzimageapi-bootstrap --query 'Stacks[0].Outputs'
```
### Prefer .zip over container images

### The wiring in CI

`.github/workflows/ci-cd.yml` cannot read the gitignored `samconfig.toml`, so its deploy job
resolves the same two values itself, in a `Resolve bootstrap artefact stores` step that queries
the bootstrap stack's outputs and feeds them to `sam deploy` as `--s3-bucket` and
`--image-repositories`. The step fails the job with a pointer to the bootstrap-deploy command if
the stack is missing or either output is absent, so a misconfigured deploy never reaches the
push.

They are read from the stack rather than from GitHub repo/environment variables on purpose. The
job previously used `--resolve-s3` plus a `CONVERTER_ECR_REPO` environment variable, and that
variable still held the *pre-migration* companion-stack repo, so every CI deploy kept pushing a
~195 MB image into the unmanaged repo and a zip into a SAM-auto-created bucket while local deploys
correctly used the bootstrap stores. Reading the stack removes the copy that can go stale. If you
add another artefact store, add its output to `infra/bootstrap.yaml` and read it in the same step.

`CONVERTER_ECR_REPO` is no longer referenced by anything and can be deleted from the repository's
`production` environment.

### Superseded resources (retired 2026-08-25)

The pre-migration artefact stores are **gone**. Both were deleted on 2026-08-25, once a CI
deploy against the bootstrap stores was confirmed:

- ECR `nzimageapi40221342/jp2converterfunctione92cbfdcrepo` and its owning SAM-generated
`nzimageapi-40221342-CompanionStack` (3 remaining images deleted first, since the stack's
`AWS::ECR::Repository` sets neither `EmptyOnDelete` nor a retain policy and would otherwise
fail to delete).
- S3 `aws-sam-cli-managed-default-samclisourcebucket-tfqwkahyri86` and its owning
`aws-sam-cli-managed-default` stack (158 object versions and delete markers purged first;
`aws s3 rm --recursive` alone is not enough on a versioned bucket, it only writes delete
markers).

The account now holds exactly one ECR repository and one S3 bucket, both from
`infra/bootstrap.yaml`.

The practical consequence: CloudFormation cannot roll the `nzimageapi` stack back to any
version deployed before 2026-08-25, because those templates and images no longer exist. Every
artefact from that date on lives in the bootstrap stores under their retention policies.

They lasted as long as they did because CI kept writing to both of them until the deploy job was
pointed at the bootstrap stack (see "The wiring in CI" above), which is why their final contents
were newer than the migration date suggested.

### When adding a new container-image Lambda

A new `PackageType: Image` function does **not** automatically get a managed repo any more,
because `image_repositories` is now explicit. Add an `AWS::ECR::Repository` for it in
`infra/bootstrap.yaml` (copy `ConverterRepository`, including its `LifecyclePolicy`), deploy
the bootstrap stack, then add the new `<FunctionLogicalId>=<repoUri>` entry to
`image_repositories`. Skipping the lifecycle policy is how this problem started.
Keep Lambdas as .zips (250 MB unzipped limit) so there is nothing in ECR to pay for. If a function
ever has to be a container image, it needs an `AWS::ECR::Repository` with a lifecycle policy in
`infra/bootstrap.yaml` (see git history before October 2026 for the old `ConverterRepository`),
plus an explicit `image_repositories` entry in `samconfig.toml` and `--image-repositories` in CI.
48 changes: 48 additions & 0 deletions .claude/rules/converter.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,54 @@ CORS open) that downloads a master image (JP2 via OpenJPEG, TIFF via libtiff) an
displayable JPEG capped at `MAX_DIM` (default 4000px). It exists because the Swift Lambda is a
pure URL builder and never decodes image bytes itself.

It is a python3.13 arm64 **.zip** built by `converter/Makefile`, which installs Pillow's
`manylinux_2_28_aarch64` wheel (it bundles OpenJPEG and libtiff) on any build host. CI's deploy job
imports the built package on arm64 Python 3.13 and fails if either codec is missing.

## Timeouts

NDHA streams some masters to the Lambda at under 1 MB/s. The function timeout is 180 s and
`download_image` gives up after `DOWNLOAD_DEADLINE` (90 s) for the whole download, on top of the
per-socket `DOWNLOAD_TIMEOUT`, so a slow source ends as a logged `502 download failed` instead of
a silent function timeout. FL73848659 (45.8 MB, 7356×5010) is the known slow master.

## Kill switch (`guard/`)

The Function URL is public, so `ConverterGuardFunction` (`guard/app.py`) keeps the converter
inside Lambda's always-free allowance. It switches the converter off by setting its reserved
concurrency to 0 (every call is throttled, nothing is billed), and back on by deleting the
reservation:

- **Spike:** `nzimageapi-converter-invocations-high` (over `ConverterSpikeThreshold`, default 100,
invocations in 5 minutes) emails `nzimageapi-cost-alarms` and, through the same topic, switches
the converter off.
- **Hourly:** month-to-date usage from CloudWatch is checked against `GuardMaxGbSeconds` (250,000
of the 400,000 free, about two hours of worst-case burn below it), `GuardMaxRequests` (800,000 of 1,000,000) and
`GuardMaxConverterInvocations` (20,000, which keeps data transfer out under the free 100 GB at
up to ~4 MB a response). Over any limit, it switches off. Under all of them, and with the spike
alarm not fired for `GuardSpikeCooldownHours` (6), it switches back on. That is how it recovers
after a spike or at the start of a month.

The cooldown exists because throttled calls are not invocations: the spike alarm clears within
minutes of the switch-off, so its state alone can't tell that a flood has stopped. The guard reads
the alarm's history instead. A flood that outlasts the cooldown costs about one alarm period of
compute per cycle, and the month-to-date limits bound the total.

Every switch is emailed to the cost-alarm topic. While the converter is off, converter URLs fail
and the slideshow skips those images.

Known limits, accepted for this account's size:
- Usage is read for `ap-southeast-2` only, but Lambda's free tier is account-wide. Every function
in the account is in this region today. If Lambdas are added elsewhere, lower the limits.
- The guard shares the account's 10-slot concurrency pool with the converter (nothing can be
reserved). In a flood that holds all 10, the alarm's async invocation is retried and gets in
as soon as a converter call finishes, which takes seconds to two minutes.
- The hourly run re-enables a converter that was switched off by hand. To keep it off, also
disable the guard's schedule:
`aws events disable-rule --region ap-southeast-2 --name <the guard's Hourly rule>`.
To switch it on by hand: `aws lambda delete-function-concurrency --region ap-southeast-2
--function-name <converter>`.

## HMAC signer/verifier contract

- Swift side: `URLProcessor.signedConverterURL(for:)` (`Sources/NZImageApiLambda/Helpers/URLProcessor.swift`)
Expand Down
39 changes: 23 additions & 16 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,21 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14" # matches converter Lambda runtime
python-version: "3.13" # matches the converter and guard Lambda runtime
- name: Install dev deps
run: pip install -r converter/requirements-dev.txt
- name: pytest
run: pip install -r converter/requirements-dev.txt -r guard/requirements-dev.txt
- name: pytest (converter)
working-directory: converter
run: pytest
- name: pytest (guard)
working-directory: guard
run: pytest

deploy:
name: Build & deploy (SAM)
needs: [swift-test, python-test] # deploy blocked until BOTH pass
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04-arm # native arm64 Docker: fast Swift cross-compile + converter image
runs-on: ubuntu-24.04-arm # native arm64 Docker for the Swift cross-compile, arm64 Python for the converter codec check
environment: production # gives an optional approval gate; scopes OIDC sub (see setup)
permissions:
id-token: write # required for OIDC
Expand All @@ -56,13 +59,21 @@ jobs:
- uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
with:
use-installer: true # arch-independent SAM CLI install
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13" # same as the Lambda runtime, so the codec check imports the built wheel
- name: sam build
run: sam build # Swift via Makefile+Docker; converter via Docker image
# The artefact stores live in the separate `nzimageapi-bootstrap` stack (infra/bootstrap.yaml),
# which owns their retention lifecycle policies. Read them from that stack's outputs rather
# than from a hand-maintained repo variable: a stale variable silently sends deploys back to
# an unmanaged store, which is exactly what happened before this step existed.
- name: Resolve bootstrap artefact stores
run: sam build # Swift via Makefile+Docker, converter and guard via their Makefiles
# The converter .zip carries Pillow's manylinux aarch64 wheel. Prove the build has the two
# codecs it exists for, on the same architecture and Python as the Lambda, before deploying.
- name: Check converter codecs
run: |
PYTHONPATH=.aws-sam/build/Jp2ConverterFunction python -c "from PIL import features; assert features.check('jpg_2000'), 'OpenJPEG/JP2 NOT enabled in Pillow'; assert features.check('libtiff'), 'libtiff NOT enabled in Pillow'; print('JP2 and TIFF decoding available')"
# The artefact bucket lives in the separate `nzimageapi-bootstrap` stack (infra/bootstrap.yaml),
# which owns its retention lifecycle policy. Read it from that stack's outputs rather than
# from a hand-maintained repo variable: a stale variable silently sends deploys back to an
# unmanaged store, which is exactly what happened before this step existed.
- name: Resolve bootstrap artefact bucket
id: bootstrap
run: |
set -euo pipefail
Expand All @@ -74,22 +85,18 @@ jobs:
--stack-name nzimageapi-bootstrap \
--query 'Stacks[0].Outputs' --output json) || outputs='[]'
bucket=$(echo "$outputs" | jq -r '[.[]? | select(.OutputKey=="ArtifactBucketName") | .OutputValue] | first // ""')
repo=$(echo "$outputs" | jq -r '[.[]? | select(.OutputKey=="ConverterRepositoryUri") | .OutputValue] | first // ""')
if [ -z "$bucket" ] || [ "$bucket" = "null" ] || [ -z "$repo" ] || [ "$repo" = "null" ]; then
echo "::error::nzimageapi-bootstrap did not return both ArtifactBucketName and ConverterRepositoryUri. Deploy the bootstrap stack first: aws cloudformation deploy --region ap-southeast-2 --stack-name nzimageapi-bootstrap --template-file infra/bootstrap.yaml"
if [ -z "$bucket" ] || [ "$bucket" = "null" ]; then
echo "::error::nzimageapi-bootstrap did not return ArtifactBucketName. Deploy the bootstrap stack first: aws cloudformation deploy --region ap-southeast-2 --stack-name nzimageapi-bootstrap --template-file infra/bootstrap.yaml"
exit 1
fi
echo "bucket=$bucket" >> "$GITHUB_OUTPUT"
echo "repo=$repo" >> "$GITHUB_OUTPUT"
echo "Artefact bucket: $bucket"
echo "Converter repo: $repo"
- name: sam deploy
run: |
sam deploy \
--stack-name nzimageapi \
--region ap-southeast-2 \
--s3-bucket ${{ steps.bootstrap.outputs.bucket }} \
--image-repositories Jp2ConverterFunction=${{ steps.bootstrap.outputs.repo }} \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
Expand Down
Loading
Loading