Skip to content

Commit e9f8699

Browse files
terion-nameclaude
andcommitted
Document S3 compatibility features and extend smoke coverage
Update README and configuration docs for the supported operations and upload payload modes (including the unverified-checksum caveats), add the design spec, extend the opt-in mutation smoke with default-settings PutObject and an `aws s3 cp` multipart round-trip, and add a project verify skill capturing the compose e2e recipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b3ee67 commit e9f8699

5 files changed

Lines changed: 251 additions & 4 deletions

File tree

.claude/skills/verify/SKILL.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
name: verify
3+
description: Build, launch, and drive the air3 compose stack to verify edge/connector changes end-to-end.
4+
---
5+
6+
# Verifying air3 changes end-to-end
7+
8+
The runtime surface is the edge gateway's public HTTPS listener (S3 API +
9+
signed URLs), backed by NATS, the private connector, and versitygw.
10+
11+
## Launch
12+
13+
```bash
14+
# certs already generated in deploy/certs/generated (else: make certs)
15+
export MUTATIONS_ENABLED=true AIR3_S3_API_ENABLED=true \
16+
AIR3_S3_API_ACCESS_KEY_ID=edge-api-key AIR3_S3_API_SECRET_ACCESS_KEY=edge-api-secret
17+
docker compose -f deploy/compose.yaml up -d --build
18+
./deploy/scripts/seed-s3.sh # seeds s3://demo/hello.txt
19+
```
20+
21+
Rebuilding one service after a code change: `docker compose -f deploy/compose.yaml up -d --build private-connector`.
22+
23+
## Drive the S3 API (no aws CLI on host)
24+
25+
Run `amazon/aws-cli` on the compose public network. Two settings are
26+
mandatory or the edge rejects mutations with InvalidRequest: path-style
27+
addressing and unsigned payloads (plus `when_required` checksums):
28+
29+
```bash
30+
printf '[default]\ns3 =\n addressing_style = path\n payload_signing_enabled = false\n' > /tmp/aws-config
31+
docker run --rm --network air3-demo_public \
32+
-v "$PWD/deploy/certs/generated:/certs:ro" -v /tmp:/work \
33+
-e AWS_CONFIG_FILE=/work/aws-config \
34+
-e AWS_ACCESS_KEY_ID=edge-api-key -e AWS_SECRET_ACCESS_KEY=edge-api-secret \
35+
-e AWS_DEFAULT_REGION=us-east-1 \
36+
-e AWS_REQUEST_CHECKSUM_CALCULATION=when_required \
37+
-e AWS_RESPONSE_CHECKSUM_VALIDATION=when_required \
38+
amazon/aws-cli --endpoint-url https://edge-gateway:8443 --ca-bundle /certs/dev-ca.crt \
39+
s3api get-object --bucket demo --key hello.txt /dev/stdout
40+
```
41+
42+
## Drive signed URLs (public HMAC path)
43+
44+
```bash
45+
URL=$(go run ./cmd/signurl -method GET -base-url https://localhost:8443 \
46+
-bucket demo -key hello.txt -secret dev-signing-secret-change-me \
47+
-expiration 2m -default-bucket-path)
48+
curl --cacert deploy/certs/generated/dev-ca.crt \
49+
--resolve edge-gateway:8443:127.0.0.1 "${URL/localhost/edge-gateway}"
50+
```
51+
52+
Raw SigV4 probes (odd methods/headers) work with
53+
`curl --aws-sigv4 "aws:amz:us-east-1:s3" --user edge-api-key:edge-api-secret`.
54+
55+
## Gotchas
56+
57+
- Logs are intentionally redacted (`safeLogError`) — both services log
58+
almost nothing. Debug by watching `docker logs air3-demo-versitygw-1`,
59+
which access-logs every backend request; if an operation never appears
60+
there, it failed before reaching S3.
61+
- A status-only error (no XML `<Code>`) comes from the connector's
62+
fetch-error path; XML errors come from the edge or connector mutation
63+
paths. The aws CLI prints `(503)` for status-only and `(ServiceUnavailable)`
64+
for XML — useful to localize failures.
65+
- Tear down: `docker compose -f deploy/compose.yaml down --remove-orphans`
66+
(volumes persist; delete test objects via the `aws-cli` tools profile
67+
against `http://versitygw:10000`).

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,9 @@ While air3 primarily relies on its highly-secure HMAC signed URLs, you can optio
350350
### Security First
351351

352352
- **No Real S3 Credentials at the Edge:** You must define a *brand new, gateway-only* set of credentials (`AIR3_S3_API_ACCESS_KEY_ID` and `AIR3_S3_API_SECRET_ACCESS_KEY`). The Edge Gateway only uses these to verify incoming AWS SigV4 requests. It **does not** give the Edge access to the backend storage. These are completely separate from your real, private S3 credentials and your HMAC `AIR3_SIGNING_SECRET`.
353-
- **Supported v1 Operations:** The API supports read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, and `HeadBucket`, plus gated `PutObject` and `DeleteObject`.
354-
- **Mutations Off by Default:** S3-compatible `PUT`/`DELETE` handling must be explicitly gated with `MUTATIONS_ENABLED=true`. `PutObject` requires `Content-Length` and `x-amz-content-sha256: UNSIGNED-PAYLOAD`; multipart upload, ACLs, tagging, versioning, AWS chunked/streaming payloads, and signed-payload `PUT` requests are unsupported.
353+
- **Supported Operations:** The API supports read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, and `HeadBucket`, plus gated mutations `PutObject`, `DeleteObject`, and multipart upload (`CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`, `AbortMultipartUpload`) — enough for `aws s3 cp` of large files. ACLs, tagging, versioning, `ListParts`, and `ListMultipartUploads` are unsupported.
354+
- **Mutations Off by Default:** S3-compatible mutation handling must be explicitly gated with `MUTATIONS_ENABLED=true`.
355+
- **Works with Default Client Settings:** Uploads accept `x-amz-content-sha256: UNSIGNED-PAYLOAD`, a signed SHA-256 payload hash, or the `STREAMING-UNSIGNED-PAYLOAD-TRAILER` aws-chunked mode that modern AWS CLI/SDK versions use by default (the framing is decoded at the edge). Two caveats: a signed payload hash is verified as part of the request signature but the streamed body is **not** re-hashed against it, and aws-chunked checksum trailers (`x-amz-checksum-*`) are discarded, not verified — end-to-end body integrity relies on TLS. Signed-chunk streaming (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD*`) stays rejected; clients only use it on plain-HTTP endpoints.
355356
- **Independent Edge/Connector Gates:** Routed mutations require `MUTATIONS_ENABLED=true` on both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate. `AIR3_MUTATIONS_ENABLED` remains as a compatibility alias; if both names are non-empty, their boolean values must match.
356357

357358
### Using the API (Path-Style)

deploy/scripts/smoke.sh

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ aws_s3api() {
107107
return "$rc"
108108
}
109109

110+
# Like aws_s3api but with stock CLI settings (no payload-signing or checksum
111+
# overrides), proving default-configured clients work against the edge.
112+
aws_default() {
113+
AWS_CONFIG_FILE=/dev/null \
114+
AWS_ACCESS_KEY_ID="$AIR3_S3_API_ACCESS_KEY_ID" \
115+
AWS_SECRET_ACCESS_KEY="$AIR3_S3_API_SECRET_ACCESS_KEY" \
116+
AWS_DEFAULT_REGION="${AIR3_S3_API_REGION:-us-east-1}" \
117+
AWS_PAGER="" \
118+
aws --endpoint-url "$BASE_URL" --ca-bundle "$CERT_DIR/dev-ca.crt" "$@"
119+
}
120+
110121
mutation_gate_enabled() {
111122
local primary=${MUTATIONS_ENABLED:-}
112123
local alias=${AIR3_MUTATIONS_ENABLED:-}
@@ -207,6 +218,81 @@ run_optional_s3_mutation_smoke() {
207218

208219
deleted_url=$(sign_url GET "$mutation_key" 2m)
209220
assert_status "signed GET after S3 API DeleteObject" "404" "$deleted_url"
221+
222+
run_default_client_mutation_smoke
223+
run_multipart_mutation_smoke
224+
}
225+
226+
run_default_client_mutation_smoke() {
227+
echo "Running default-settings AWS CLI mutation smoke checks..."
228+
local key body readback
229+
key="air3-smoke-default-$(date +%s)-$$.txt"
230+
body=$(mktemp)
231+
readback=$(mktemp)
232+
printf 'default client settings\n' >"$body"
233+
234+
if ! aws_default s3api put-object --bucket "$BUCKET" --key "$key" --body "$body" --content-type text/plain >/dev/null; then
235+
rm -f "$body" "$readback"
236+
echo "error: default-settings PutObject (aws-chunked) failed" >&2
237+
exit 1
238+
fi
239+
echo "ok: default-settings PutObject (aws-chunked trailer mode) succeeded"
240+
241+
if ! aws_default s3api get-object --bucket "$BUCKET" --key "$key" "$readback" >/dev/null; then
242+
rm -f "$body" "$readback"
243+
echo "error: default-settings GetObject failed" >&2
244+
exit 1
245+
fi
246+
if ! cmp -s "$body" "$readback"; then
247+
rm -f "$body" "$readback"
248+
echo "error: default-settings readback did not match uploaded content" >&2
249+
exit 1
250+
fi
251+
rm -f "$body" "$readback"
252+
echo "ok: default-settings readback matched uploaded content"
253+
254+
if ! aws_default s3api delete-object --bucket "$BUCKET" --key "$key" >/dev/null; then
255+
echo "error: default-settings DeleteObject failed" >&2
256+
exit 1
257+
fi
258+
echo "ok: default-settings DeleteObject removed temporary object"
259+
}
260+
261+
run_multipart_mutation_smoke() {
262+
echo "Running multipart upload smoke checks (aws s3 cp)..."
263+
local key big_body big_readback size_mb
264+
key="air3-smoke-multipart-$(date +%s)-$$.bin"
265+
size_mb=${AIR3_DEMO_MULTIPART_MB:-16}
266+
big_body=$(mktemp)
267+
big_readback=$(mktemp)
268+
dd if=/dev/urandom of="$big_body" bs=1048576 count="$size_mb" status=none
269+
270+
# aws s3 cp switches to multipart upload above the 8 MiB threshold.
271+
if ! aws_default s3 cp "$big_body" "s3://$BUCKET/$key" >/dev/null; then
272+
rm -f "$big_body" "$big_readback"
273+
echo "error: multipart upload via aws s3 cp failed" >&2
274+
exit 1
275+
fi
276+
echo "ok: multipart upload via aws s3 cp succeeded (${size_mb} MiB)"
277+
278+
if ! aws_default s3 cp "s3://$BUCKET/$key" "$big_readback" >/dev/null; then
279+
rm -f "$big_body" "$big_readback"
280+
echo "error: multipart download via aws s3 cp failed" >&2
281+
exit 1
282+
fi
283+
if ! cmp -s "$big_body" "$big_readback"; then
284+
rm -f "$big_body" "$big_readback"
285+
echo "error: multipart readback did not match uploaded content" >&2
286+
exit 1
287+
fi
288+
rm -f "$big_body" "$big_readback"
289+
echo "ok: multipart readback matched uploaded content"
290+
291+
if ! aws_default s3api delete-object --bucket "$BUCKET" --key "$key" >/dev/null; then
292+
echo "error: multipart cleanup DeleteObject failed" >&2
293+
exit 1
294+
fi
295+
echo "ok: multipart cleanup removed temporary object"
210296
}
211297

212298
wait_for_edge() {

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ The Edge Gateway can expose a **path-style S3-compatible API** for clients and S
102102
You must provide a brand new set of *gateway verifier credentials*: `AIR3_S3_API_ACCESS_KEY_ID` and `AIR3_S3_API_SECRET_ACCESS_KEY`.
103103
- These credentials **only** live on the Edge and are used merely to authenticate incoming AWS SigV4 requests.
104104
- They **do not** grant access to backend S3 storage, and they are completely separate from your real S3 backend credentials.
105-
- Supported v1 operations are read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, `HeadBucket`, and gated mutations `PutObject` and `DeleteObject`. S3-compatible mutations (`PUT`/`DELETE`) are disabled unless `MUTATIONS_ENABLED=true` is set.
106-
- `PutObject` accepts only known-length, unsigned-payload uploads: clients must send `Content-Length` and use `x-amz-content-sha256: UNSIGNED-PAYLOAD`. Multipart upload, ACLs, tagging, versioning, AWS chunked transfer (`aws-chunked`), streaming payloads, and signed-payload `PUT` requests are not supported.
105+
- Supported operations are read paths `GET`/`GetObject`, `HEAD`/`HeadObject`, `ListObjectsV2`, `HeadBucket`, and gated mutations `PutObject`, `DeleteObject`, `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`, and `AbortMultipartUpload`. All mutations are disabled unless `MUTATIONS_ENABLED=true` is set. ACLs, tagging, versioning, `ListParts`, and `ListMultipartUploads` are not supported.
106+
- Uploads (`PutObject`, `UploadPart`, `CompleteMultipartUpload`) need a known decoded length and accept three payload-hash modes: `x-amz-content-sha256: UNSIGNED-PAYLOAD` with `Content-Length`; a signed SHA-256 payload hash (verified as part of the SigV4 signature, but the streamed body is not re-hashed against it); or `STREAMING-UNSIGNED-PAYLOAD-TRAILER` with `X-Amz-Decoded-Content-Length`, the aws-chunked default of modern AWS CLI/SDK releases — the edge decodes the framing and discards (does not verify) checksum trailers. Signed-chunk streaming (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD*`) is rejected. The `CompleteMultipartUpload` part-list body is capped at 1 MiB.
107107
- Routed S3 mutations require `MUTATIONS_ENABLED=true` on both the Edge and the target Connector. Direct-server aliases bypass the Connector, so direct alias mutations require only the Edge gate. `AIR3_MUTATIONS_ENABLED` is a compatibility alias; if both names are non-empty, their boolean values must match.
108108

109109
### Path-Style Mapping Examples
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# S3 compatibility upgrades: aws-chunked, signed payloads, multipart upload
2+
3+
Date: 2026-07-06. Approved scope: features 1, 2, 4 from the compatibility
4+
review; all defaults confirmed (signed payloads always-on, trailers parsed
5+
and discarded, four core multipart operations).
6+
7+
## Goal
8+
9+
A stock AWS CLI/SDK with no custom configuration can upload through the
10+
edge's S3-compatible API: `aws s3api put-object` works with default
11+
checksum/signing settings, and `aws s3 cp` of files >8 MiB works via
12+
multipart upload.
13+
14+
## Feature 1 — aws-chunked upload decoding (edge)
15+
16+
Modern clients send upload bodies as
17+
`x-amz-content-sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER` with
18+
aws-chunked framing and a checksum trailer (e.g. `x-amz-checksum-crc32`).
19+
20+
- The edge accepts this mode for upload operations (PutObject, UploadPart,
21+
CompleteMultipartUpload body). `X-Amz-Decoded-Content-Length` is
22+
required and becomes the object/part content length.
23+
- Unsigned aws-chunked framing is identical to HTTP/1.1 chunked framing;
24+
decoding wraps `httputil.NewChunkedReader` plus exact decoded-length
25+
enforcement (`internal/s3api`).
26+
- Trailers are parsed for framing validity and discarded. By the time the
27+
trailer arrives the payload has already streamed to the backend, so a
28+
checksum mismatch cannot fail the upload; this is documented.
29+
- Signed-chunk streaming modes (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD*`)
30+
remain rejected: clients use them only on plain-HTTP endpoints and the
31+
edge is HTTPS-only.
32+
- One body-resolution step in the edge feeds both the routed
33+
(upload-source) and direct-server paths.
34+
35+
## Feature 2 — signed-payload uploads (edge)
36+
37+
`ValidatePayloadHashForOperation` additionally accepts a 64-hex-digit
38+
payload hash for mutation operations. The hash is covered by the verified
39+
SigV4 signature; the edge does not re-hash the streaming body (impossible
40+
without buffering). Transport integrity remains TLS's job. Always-on, no
41+
config flag. Documented as "accepted but not verified".
42+
43+
## Feature 4 — multipart upload
44+
45+
Operations: CreateMultipartUpload, UploadPart, CompleteMultipartUpload,
46+
AbortMultipartUpload — the set `aws s3 cp` needs. ListParts and
47+
ListMultipartUploads are deferred.
48+
49+
- **Classification** (`internal/s3api`): by query params — POST `?uploads`
50+
→ Create; PUT `?partNumber&uploadId` → UploadPart; POST `?uploadId`
51+
Complete; DELETE `?uploadId` → Abort.
52+
- **Gating**: all four are mutations, gated by `MUTATIONS_ENABLED` on both
53+
edge and connector, same as PutObject/DeleteObject.
54+
- **Tickets** (`internal/tickets`): new operations plus a `Multipart`
55+
envelope: `UploadID`, `PartNumber`, and `Rewrite{Bucket,Key}` carrying
56+
the public names for response XML. Part lists never ride NATS tickets,
57+
keeping message size bounded.
58+
- **Body transport**: UploadPart streams its body through the existing
59+
upload-source channel exactly like PutObject. CompleteMultipartUpload's
60+
part-list XML travels the same way, capped at 1 MiB (S3 allows at most
61+
10,000 parts ≈ <1 MiB of XML).
62+
- **Backend calls** (`internal/s3fetch`): SDK CreateMultipartUpload /
63+
UploadPart (UNSIGNED-PAYLOAD, same as PutObject) / Complete (parses the
64+
XML via `internal/s3api`) / Abort. Create and Complete responses are
65+
rendered deterministically with public bucket/key (same pattern as the
66+
ListObjectsV2 rewrite).
67+
- **Statuses**: Create 200 + XML, UploadPart 200 + ETag header,
68+
Complete 200 + XML, Abort 204.
69+
- **Direct-server aliases** get all four operations through the shared
70+
s3fetch code (edge-only gate, as with other direct mutations).
71+
72+
## Error handling
73+
74+
- Malformed chunked framing or decoded-length mismatch surfaces as a read
75+
error mid-stream; the backend PUT aborts (short body) and the client
76+
receives the standard stream-failure error.
77+
- Unknown/oversized Complete bodies → 400 InvalidRequest at the edge.
78+
- Multipart requests while mutations are disabled → 405, `Allow`
79+
reflecting the gate.
80+
81+
## Testing
82+
83+
Unit tests per package (chunked decoder incl. malformed framing and length
84+
mismatches; classification; ticket validation; s3fetch operations against
85+
stub servers; edge/connector wiring). End-to-end: compose stack driven by
86+
a default-configured `aws` CLI — put-object without custom config, and
87+
`aws s3 cp` of a >8 MiB file (multipart), plus abort and gate-off probes.
88+
89+
## Docs
90+
91+
README limitations updated (multipart/aws-chunked/signed-payload lines),
92+
`docs/configuration.md` operation list, and a short "Using the AWS CLI"
93+
note. `deploy/scripts/smoke.sh` gains default-config and multipart checks.

0 commit comments

Comments
 (0)