diff --git a/.github/actions/aws-test-infra/README.md b/.github/actions/aws-test-infra/README.md
index 9f9750e..4ce4f2b 100644
--- a/.github/actions/aws-test-infra/README.md
+++ b/.github/actions/aws-test-infra/README.md
@@ -136,6 +136,96 @@ arbitrary role names, override `instance-roles` and read the IDs from the
The `instance-ids` output (CSV) and the cleanup wiring continue to work
unchanged for any role count.
+### S3 staging, download, and cleanup
+
+For workflows that hand artifacts to EC2 instances over S3 (image tarballs,
+binaries) and collect results back, three S3 subcommands cover the bucket
+lifecycle. The presigned URLs are generated with the AWS SDK, which produces
+both GET **and** PUT URLs natively — no `boto3` shim needed.
+
+**`s3-stage`** ensures (and tags) a run-scoped bucket, uploads local files,
+and returns presigned URLs as a JSON map:
+
+```yaml
+- name: Stage artifacts on S3
+ id: s3
+ uses: loft-sh/github-actions/.github/actions/aws-test-infra@aws-test-infra/v1
+ with:
+ command: s3-stage
+ region: us-west-2
+ run-id: conformance-standalone-${{ github.run_id }}-${{ github.run_attempt }}
+ expires-in: 4h
+ uploads: |
+ vcluster_image=vcluster.tar
+ /tmp/kind-node.tar.gz=kind-node.tar.gz
+ presign: |
+ vcluster.tar:get
+ kind-node.tar.gz:get
+ results.tar.gz:put
+
+- name: Use the URLs
+ env:
+ IMAGE_BUCKET: ${{ steps.s3.outputs.bucket }}
+ VCLUSTER_IMAGE_URL: ${{ fromJSON(steps.s3.outputs.presigned-urls)['vcluster.tar'] }}
+ KIND_NODE_IMAGE_URL: ${{ fromJSON(steps.s3.outputs.presigned-urls)['kind-node.tar.gz'] }}
+ RESULTS_PUT_URL: ${{ fromJSON(steps.s3.outputs.presigned-urls)['results.tar.gz'] }}
+ run: ...
+```
+
+The bucket name defaults to the lowercased `run-id`; pass `bucket` to override.
+Uploads that are produced by earlier steps (e.g. `docker save … | gzip`) are
+written to disk by the caller and then referenced by path in `uploads`.
+
+> **Presigned URL lifetime is capped by the AWS credential lifetime.** A
+> presigned URL only works while *both* its `expires-in` window is open *and*
+> the credentials that signed it are still valid. With OIDC
+> (`aws-actions/configure-aws-credentials`) the STS session defaults to **1
+> hour**, so an `expires-in: 4h` URL silently starts returning `403` after ~1h
+> and the EC2 GET/PUT fails mid-run. For hours-long conformance runs, set
+> `role-duration-seconds` on the credentials step to at least the run length,
+> and make sure the IAM role's **maximum session duration** allows it.
+> `s3-stage` prints a `::warning::` when it detects the requested `expires-in`
+> exceeds the remaining session lifetime.
+>
+> The presigned URLs are also written to `$GITHUB_OUTPUT`, which Actions does
+> **not** mask; `s3-stage` registers each URL with `::add-mask::` so it is
+> scrubbed from subsequent logs. A leaked PUT URL is a write capability for its
+> whole lifetime, so treat these outputs as secrets.
+
+**`s3-download`** pulls an object back to a local path. A missing object is a
+no-op (not an error), matching the "results only exist if the run got far
+enough" semantics:
+
+```yaml
+- name: Fetch results
+ if: always()
+ uses: loft-sh/github-actions/.github/actions/aws-test-infra@aws-test-infra/v1
+ with:
+ command: s3-download
+ region: us-west-2
+ bucket: ${{ steps.s3.outputs.bucket }}
+ key: results.tar.gz
+ dest: ./results.tar.gz
+```
+
+**`s3-cleanup`** empties and deletes the bucket, with a tag-based sweep
+fallback (same shape as EC2 `cleanup`). Run it with `if: always()`:
+
+```yaml
+- name: Teardown S3 bucket
+ if: always()
+ uses: loft-sh/github-actions/.github/actions/aws-test-infra@aws-test-infra/v1
+ with:
+ command: s3-cleanup
+ region: us-west-2
+ bucket: ${{ steps.s3.outputs.bucket }}
+ run-id: conformance-standalone-${{ github.run_id }}-${{ github.run_attempt }}
+ bucket-prefix: conformance-standalone-
+```
+
+If `bucket` is blank (the stage step never ran), the sweep deletes any bucket
+whose name starts with `bucket-prefix` and carries the matching `RunID` tag.
+
## Ingress rule format
Each rule is `protocol:fromPort:toPort:cidr`. To pass several rules at
@@ -152,43 +242,50 @@ once, put one rule per line in the `ingress-rules` input.
-| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
-|--------------------------|--------|----------|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| ami-architecture | string | false | `"x86_64"` | (provision) Architecture filter for AMI lookup
(e.g. x86_64, arm64). Defaults to x86_64 to match
the original Bash workflows; pass an
empty string to disable the filter. |
-| ami-filter | string | false | | (provision) AMI name filter for lookup
(latest CreationDate wins) |
-| ami-id | string | false | | (provision) Use this exact AMI ID
(skips lookup) |
-| ami-owner | string | false | | (provision) AMI owner (account ID or alias) for lookup |
-| ami-virtualization-type | string | false | `"hvm"` | (provision) Virtualization-type filter for AMI lookup
(e.g. hvm, paravirtual). Defaults to hvm to match
the original Bash workflows; pass an
empty string to disable the filter. |
-| availability-zone | string | false | | (provision) AZ for the subnet (defaults to first AZ in region) |
-| command | string | true | | Subcommand: provision or cleanup |
-| consumer-tag | string | false | | (provision) Consumer tag in KEY=VALUE form,
e.g. SELinuxE2E=true |
-| igw-id | string | false | | (cleanup) Internet gateway ID |
-| ingress-rules | string | false | | (provision) Newline-separated ingress rules in protocol:fromPort:toPort:cidr
form. Example: "-1:-1:-1:10.0.0.0/16\ntcp:8443:8443:0.0.0.0/0" |
-| instance-ids | string | false | | (cleanup) Comma-separated list of instance IDs |
-| instance-profile | string | false | | (provision) IAM instance profile name |
-| instance-roles | string | false | `"primary,worker1,worker2"` | (provision) Comma-separated role labels (one instance per role) |
-| instance-running-timeout | string | false | `"30m"` | (provision) Max wait for all instances
to reach running state. Bump for
slow-boot edge cases. |
-| instance-type | string | false | `"m5.xlarge"` | (provision) EC2 instance type |
-| region | string | true | | AWS region |
-| root-device | string | false | `"/dev/sda1"` | (provision) Root block-device name, e.g. /dev/sda1
or /dev/xvda |
-| route-assoc-id | string | false | | (cleanup) Route table association ID |
-| route-table-id | string | false | | (cleanup) Route table ID |
-| run-id | string | true | | Unique run identifier; tagged on every
resource as RunID |
-| security-group-id | string | false | | (cleanup) Security group ID |
-| sg-description | string | false | | (provision) Security group description |
-| sg-name | string | false | | (provision) Security group name |
-| skip-direct | string | false | `"false"` | (cleanup) Skip direct cleanup; only run
the tag-based sweep |
-| skip-ssm-wait | string | false | `"false"` | (provision) Skip waiting for SSM agents |
-| skip-sweep | string | false | `"false"` | (cleanup) Skip the tag-based sweep; only
run direct cleanup with the supplied
IDs |
-| ssm-wait-interval | string | false | `"10s"` | (provision) Polling interval for SSM agent
registration |
-| ssm-wait-timeout | string | false | `"5m"` | (provision) How long to wait for
all SSM agents to register |
-| strict-sweep | string | false | `"false"` | (cleanup) Fail the cleanup step on
sweep errors. Default false matches the
original Bash teardown (set +e). Set true
if you would rather see sweep
failures than silently leak resources on
AWS API hiccups. |
-| subnet-cidr | string | false | `"10.0.1.0/24"` | (provision) Subnet CIDR |
-| subnet-id | string | false | | (cleanup) Subnet ID |
-| user-data | string | false | | (provision) Raw user-data content; written to
a temp file and base64-encoded by
the binary |
-| volume-size-gb | string | false | `"100"` | (provision) Root volume size in GB |
-| vpc-cidr | string | false | `"10.0.0.0/16"` | (provision) VPC CIDR |
-| vpc-id | string | false | | (cleanup) VPC ID |
+| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
+|--------------------------|--------|----------|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| ami-architecture | string | false | `"x86_64"` | (provision) Architecture filter for AMI lookup
(e.g. x86_64, arm64). Defaults to x86_64 to match
the original Bash workflows; pass an
empty string to disable the filter. |
+| ami-filter | string | false | | (provision) AMI name filter for lookup
(latest CreationDate wins) |
+| ami-id | string | false | | (provision) Use this exact AMI ID
(skips lookup) |
+| ami-owner | string | false | | (provision) AMI owner (account ID or alias) for lookup |
+| ami-virtualization-type | string | false | `"hvm"` | (provision) Virtualization-type filter for AMI lookup
(e.g. hvm, paravirtual). Defaults to hvm to match
the original Bash workflows; pass an
empty string to disable the filter. |
+| availability-zone | string | false | | (provision) AZ for the subnet (defaults to first AZ in region) |
+| bucket | string | false | | (s3-*) Bucket name. For s3-stage it
defaults to the lowercased run-id. Required
for s3-download. For s3-cleanup, pass the
bucket to delete directly and/or bucket-prefix
for the tag sweep. |
+| bucket-prefix | string | false | | (s3-cleanup) Bucket name prefix for the
tag-based sweep fallback. |
+| command | string | true | | Subcommand: provision | cleanup | s3-stage
| s3-download | s3-cleanup |
+| consumer-tag | string | false | | (provision) Consumer tag in KEY=VALUE form,
e.g. SELinuxE2E=true |
+| dest | string | false | | (s3-download) Local destination path. |
+| expires-in | string | false | `"4h"` | (s3-stage) Presigned URL lifetime as a
Go duration (e.g. 4h, 14400s). |
+| igw-id | string | false | | (cleanup) Internet gateway ID |
+| ingress-rules | string | false | | (provision) Newline-separated ingress rules in protocol:fromPort:toPort:cidr
form. Example: "-1:-1:-1:10.0.0.0/16\ntcp:8443:8443:0.0.0.0/0" |
+| instance-ids | string | false | | (cleanup) Comma-separated list of instance IDs |
+| instance-profile | string | false | | (provision) IAM instance profile name |
+| instance-roles | string | false | `"primary,worker1,worker2"` | (provision) Comma-separated role labels (one instance per role) |
+| instance-running-timeout | string | false | `"30m"` | (provision) Max wait for all instances
to reach running state. Bump for
slow-boot edge cases. |
+| instance-type | string | false | `"m5.xlarge"` | (provision) EC2 instance type |
+| key | string | false | | (s3-download) Object key to download. |
+| presign | string | false | | (s3-stage) Newline-separated object-key:method pairs to presign
(method get|put). Example: "vcluster.tar:get\nresults.tar.gz:put". Read the result
via fromJSON(outputs.presigned-urls).. |
+| region | string | true | | AWS region |
+| root-device | string | false | `"/dev/sda1"` | (provision) Root block-device name, e.g. /dev/sda1
or /dev/xvda |
+| route-assoc-id | string | false | | (cleanup) Route table association ID |
+| route-table-id | string | false | | (cleanup) Route table ID |
+| run-id | string | false | | Unique run identifier; tagged as RunID
on provisioned resources and on s3-stage
buckets. For s3-stage the bucket name
defaults to the lowercased run-id. |
+| security-group-id | string | false | | (cleanup) Security group ID |
+| sg-description | string | false | | (provision) Security group description |
+| sg-name | string | false | | (provision) Security group name |
+| skip-direct | string | false | `"false"` | (cleanup) Skip direct cleanup; only run
the tag-based sweep |
+| skip-ssm-wait | string | false | `"false"` | (provision) Skip waiting for SSM agents |
+| skip-sweep | string | false | `"false"` | (cleanup) Skip the tag-based sweep; only
run direct cleanup with the supplied
IDs |
+| ssm-wait-interval | string | false | `"10s"` | (provision) Polling interval for SSM agent
registration |
+| ssm-wait-timeout | string | false | `"5m"` | (provision) How long to wait for
all SSM agents to register |
+| strict-sweep | string | false | `"false"` | (cleanup, s3-cleanup) Fail the step on sweep
errors. Default false matches the original
Bash teardown (set +e). Set true if
you would rather see sweep failures
than silently leak resources on AWS
API hiccups. |
+| subnet-cidr | string | false | `"10.0.1.0/24"` | (provision) Subnet CIDR |
+| subnet-id | string | false | | (cleanup) Subnet ID |
+| uploads | string | false | | (s3-stage) Newline-separated local-path=object-key pairs to upload.
Example: "vcluster_image=vcluster.tar\n/tmp/kind-node.tar.gz=kind-node.tar.gz" |
+| user-data | string | false | | (provision) Raw user-data content; written to
a temp file and base64-encoded by
the binary |
+| volume-size-gb | string | false | `"100"` | (provision) Root volume size in GB |
+| vpc-cidr | string | false | `"10.0.0.0/16"` | (provision) VPC CIDR |
+| vpc-id | string | false | | (cleanup) VPC ID |
@@ -199,9 +296,11 @@ once, put one rule per line in the `ingress-rules` input.
| OUTPUT | TYPE | DESCRIPTION |
|---------------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ami-id | string | Resolved AMI ID |
+| bucket | string | (s3-stage) Bucket name that was ensured/used. |
| igw-id | string | Created internet gateway ID |
| instance-id-by-role | string | JSON map of role → instance
ID. Use for arbitrary role names
(anything other than primary/worker1/worker2). Consumer accesses with `fromJSON(steps..outputs.instance-id-by-role).`. |
| instance-ids | string | Comma-separated list of all instance IDs |
+| presigned-urls | string | (s3-stage) JSON map of object-key →
presigned URL. Access with `fromJSON(steps..outputs.presigned-urls).`. |
| primary-instance-id | string | Instance ID of the role labeled
"primary" (empty if not present) |
| primary-public-ip | string | Public IP of the primary instance |
| route-assoc-id | string | Created route table association ID |
diff --git a/.github/actions/aws-test-infra/action.yml b/.github/actions/aws-test-infra/action.yml
index 73a4968..fbc0d83 100644
--- a/.github/actions/aws-test-infra/action.yml
+++ b/.github/actions/aws-test-infra/action.yml
@@ -3,14 +3,15 @@ description: 'Provision or tear down AWS test infrastructure (VPC + subnet + IGW
inputs:
command:
- description: 'Subcommand: provision or cleanup'
+ description: 'Subcommand: provision | cleanup | s3-stage | s3-download | s3-cleanup'
required: true
region:
description: 'AWS region'
required: true
run-id:
- description: 'Unique run identifier; tagged on every resource as RunID'
- required: true
+ description: 'Unique run identifier; tagged as RunID on provisioned resources and on s3-stage buckets. For s3-stage the bucket name defaults to the lowercased run-id.'
+ required: false
+ default: ''
# Provision-only inputs ────────────────────────────────────────────────
consumer-tag:
@@ -140,10 +141,40 @@ inputs:
required: false
default: 'false'
strict-sweep:
- description: '(cleanup) Fail the cleanup step on sweep errors. Default false matches the original Bash teardown (set +e). Set true if you would rather see sweep failures than silently leak resources on AWS API hiccups.'
+ description: '(cleanup, s3-cleanup) Fail the step on sweep errors. Default false matches the original Bash teardown (set +e). Set true if you would rather see sweep failures than silently leak resources on AWS API hiccups.'
required: false
default: 'false'
+ # S3 inputs (s3-stage / s3-download / s3-cleanup) ───────────────────────
+ bucket:
+ description: '(s3-*) Bucket name. For s3-stage it defaults to the lowercased run-id. Required for s3-download. For s3-cleanup, pass the bucket to delete directly and/or bucket-prefix for the tag sweep.'
+ required: false
+ default: ''
+ uploads:
+ description: '(s3-stage) Newline-separated local-path=object-key pairs to upload. Example: "vcluster_image=vcluster.tar\n/tmp/kind-node.tar.gz=kind-node.tar.gz"'
+ required: false
+ default: ''
+ presign:
+ description: '(s3-stage) Newline-separated object-key:method pairs to presign (method get|put). Example: "vcluster.tar:get\nresults.tar.gz:put". Read the result via fromJSON(outputs.presigned-urls)..'
+ required: false
+ default: ''
+ expires-in:
+ description: '(s3-stage) Presigned URL lifetime as a Go duration (e.g. 4h, 14400s).'
+ required: false
+ default: '4h'
+ key:
+ description: '(s3-download) Object key to download.'
+ required: false
+ default: ''
+ dest:
+ description: '(s3-download) Local destination path.'
+ required: false
+ default: ''
+ bucket-prefix:
+ description: '(s3-cleanup) Bucket name prefix for the tag-based sweep fallback.'
+ required: false
+ default: ''
+
outputs:
vpc-id:
description: 'Created VPC ID'
@@ -184,6 +215,12 @@ outputs:
instance-id-by-role:
description: 'JSON map of role → instance ID. Use for arbitrary role names (anything other than primary/worker1/worker2). Consumer accesses with `fromJSON(steps..outputs.instance-id-by-role).`.'
value: ${{ steps.run.outputs.instance_id_by_role }}
+ bucket:
+ description: '(s3-stage) Bucket name that was ensured/used.'
+ value: ${{ steps.run.outputs.bucket }}
+ presigned-urls:
+ description: '(s3-stage) JSON map of object-key → presigned URL. Access with `fromJSON(steps..outputs.presigned-urls).`.'
+ value: ${{ steps.run.outputs.presigned_urls }}
runs:
using: 'composite'
@@ -260,6 +297,14 @@ runs:
INPUT_SKIP_DIRECT: ${{ inputs.skip-direct }}
INPUT_SKIP_SWEEP: ${{ inputs.skip-sweep }}
INPUT_STRICT_SWEEP: ${{ inputs.strict-sweep }}
+ # S3
+ INPUT_BUCKET: ${{ inputs.bucket }}
+ INPUT_UPLOADS: ${{ inputs.uploads }}
+ INPUT_PRESIGN: ${{ inputs.presign }}
+ INPUT_EXPIRES_IN: ${{ inputs.expires-in }}
+ INPUT_KEY: ${{ inputs.key }}
+ INPUT_DEST: ${{ inputs.dest }}
+ INPUT_BUCKET_PREFIX: ${{ inputs.bucket-prefix }}
run: |
set -euo pipefail
ARGS=(-region="$INPUT_REGION" -run-id="$INPUT_RUN_ID")
@@ -306,8 +351,45 @@ runs:
[ "$INPUT_STRICT_SWEEP" = "true" ] && ARGS+=(-strict-sweep)
"$BINARY" cleanup "${ARGS[@]}"
;;
+ s3-stage)
+ # s3 commands have their own flag sets; build a dedicated arg list
+ # rather than reuse the provision/cleanup base (which carries flags
+ # they don't define).
+ S3ARGS=(-region="$INPUT_REGION" -run-id="$INPUT_RUN_ID")
+ [ -n "$INPUT_BUCKET" ] && S3ARGS+=(-bucket="$INPUT_BUCKET")
+ [ -n "$INPUT_EXPIRES_IN" ] && S3ARGS+=(-expires-in="$INPUT_EXPIRES_IN")
+ # Turn the newline-separated uploads/presign inputs into repeatable
+ # flags. parse-list.sh (tested with bats) does the trim/split so this
+ # YAML stays logic-free.
+ PARSE="$GITHUB_ACTION_PATH/src/parse-list.sh"
+ # Capture into a variable first so `set -e`/`pipefail` sees a parse-list.sh
+ # failure; a `< <(...)` process substitution would swallow it and leave
+ # S3ARGS silently missing its -upload/-presign flags.
+ if [ -n "$INPUT_UPLOADS" ]; then
+ _uploads=$(printf '%s\n' "$INPUT_UPLOADS" | bash "$PARSE" -upload)
+ while IFS= read -r arg; do [ -n "$arg" ] && S3ARGS+=("$arg"); done <<< "$_uploads"
+ fi
+ if [ -n "$INPUT_PRESIGN" ]; then
+ _presign=$(printf '%s\n' "$INPUT_PRESIGN" | bash "$PARSE" -presign)
+ while IFS= read -r arg; do [ -n "$arg" ] && S3ARGS+=("$arg"); done <<< "$_presign"
+ fi
+ S3ARGS+=(-output="$GITHUB_OUTPUT" -output-format=github-output)
+ "$BINARY" s3-stage "${S3ARGS[@]}"
+ ;;
+ s3-download)
+ "$BINARY" s3-download -region="$INPUT_REGION" -bucket="$INPUT_BUCKET" -key="$INPUT_KEY" -dest="$INPUT_DEST"
+ ;;
+ s3-cleanup)
+ S3ARGS=(-region="$INPUT_REGION")
+ [ -n "$INPUT_RUN_ID" ] && S3ARGS+=(-run-id="$INPUT_RUN_ID")
+ [ -n "$INPUT_BUCKET" ] && S3ARGS+=(-bucket="$INPUT_BUCKET")
+ [ -n "$INPUT_BUCKET_PREFIX" ] && S3ARGS+=(-bucket-prefix="$INPUT_BUCKET_PREFIX")
+ [ "$INPUT_SKIP_SWEEP" = "true" ] && S3ARGS+=(-skip-sweep)
+ [ "$INPUT_STRICT_SWEEP" = "true" ] && S3ARGS+=(-strict)
+ "$BINARY" s3-cleanup "${S3ARGS[@]}"
+ ;;
*)
- echo "::error::Unknown command: $INPUT_CMD (must be 'provision' or 'cleanup')"
+ echo "::error::Unknown command: $INPUT_CMD (must be provision, cleanup, s3-stage, s3-download, or s3-cleanup)"
exit 1
;;
esac
diff --git a/.github/actions/aws-test-infra/src/go.mod b/.github/actions/aws-test-infra/src/go.mod
index af8e998..6cfd72d 100644
--- a/.github/actions/aws-test-infra/src/go.mod
+++ b/.github/actions/aws-test-infra/src/go.mod
@@ -3,23 +3,27 @@ module github.com/loft-sh/github-actions/aws-test-infra
go 1.24
require (
- github.com/aws/aws-sdk-go-v2 v1.41.7
+ github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/ec2 v1.300.0
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6
+ github.com/aws/smithy-go v1.27.3
)
require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
- github.com/aws/smithy-go v1.25.1 // indirect
)
diff --git a/.github/actions/aws-test-infra/src/go.sum b/.github/actions/aws-test-infra/src/go.sum
index f46fed5..a7a2a07 100644
--- a/.github/actions/aws-test-infra/src/go.sum
+++ b/.github/actions/aws-test-infra/src/go.sum
@@ -1,23 +1,31 @@
-github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
-github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
+github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
+github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.300.0 h1:HgOfUy9Sm2Q9UQAyj9I/7NZhIaymTEakGA/FnLw65lw=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.300.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6 h1:0LPJjbSNEDHidGOXa0LfvSVbdn9/GdlJUQTgE0kFpso=
@@ -28,5 +36,5 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
-github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
-github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
+github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
diff --git a/.github/actions/aws-test-infra/src/main.go b/.github/actions/aws-test-infra/src/main.go
index e873797..099ff35 100644
--- a/.github/actions/aws-test-infra/src/main.go
+++ b/.github/actions/aws-test-infra/src/main.go
@@ -48,6 +48,12 @@ func run(ctx context.Context, stderr io.Writer, args []string) error {
return runProvision(ctx, logger, args[0]+" provision", args[2:])
case "cleanup":
return runCleanup(ctx, logger, args[0]+" cleanup", args[2:])
+ case "s3-stage":
+ return runS3Stage(ctx, logger, args[0]+" s3-stage", args[2:])
+ case "s3-download":
+ return runS3Download(ctx, logger, args[0]+" s3-download", args[2:])
+ case "s3-cleanup":
+ return runS3Cleanup(ctx, logger, args[0]+" s3-cleanup", args[2:])
case "-h", "--help", "help":
printUsage(stderr)
return nil
@@ -61,8 +67,11 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, `Usage: aws-test-infra [flags]
Subcommands:
- provision Create VPC, subnet, IGW, route table, security group, and EC2 instances
- cleanup Tear down resources by ID and run a tag-based fallback sweep
+ provision Create VPC, subnet, IGW, route table, security group, and EC2 instances
+ cleanup Tear down resources by ID and run a tag-based fallback sweep
+ s3-stage Ensure+tag an S3 bucket, upload artifacts, and emit presigned URLs
+ s3-download Download an object to a local path (no-op if the object is absent)
+ s3-cleanup Empty and delete an S3 bucket, with a tag-based fallback sweep
Run "aws-test-infra -h" for subcommand flags.`)
}
diff --git a/.github/actions/aws-test-infra/src/output.go b/.github/actions/aws-test-infra/src/output.go
index ae3cbe6..80451e9 100644
--- a/.github/actions/aws-test-infra/src/output.go
+++ b/.github/actions/aws-test-infra/src/output.go
@@ -41,18 +41,11 @@ func emitOutput(logger *slog.Logger, destination, format string, ids ResourceIDs
}
}
- var w io.Writer
- if destination == "" {
- w = os.Stdout
- } else {
- // GITHUB_OUTPUT / GITHUB_ENV are append-mode files per Actions docs.
- f, err := os.OpenFile(destination, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return fmt.Errorf("open output destination %q: %w", destination, err)
- }
- defer f.Close()
- w = f
+ w, err := openDestWriter(destination)
+ if err != nil {
+ return err
}
+ defer w.Close()
switch format {
case "json":
@@ -80,6 +73,26 @@ func destinationLabel(d string) string {
return d
}
+// openDestWriter returns the writer for an output destination: os.Stdout (with
+// a no-op Close) when empty, otherwise the append-opened file. GITHUB_OUTPUT /
+// GITHUB_ENV are append-mode files per the Actions docs.
+func openDestWriter(destination string) (io.WriteCloser, error) {
+ if destination == "" {
+ return nopWriteCloser{os.Stdout}, nil
+ }
+ f, err := os.OpenFile(destination, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return nil, fmt.Errorf("open output destination %q: %w", destination, err)
+ }
+ return f, nil
+}
+
+// nopWriteCloser adds a no-op Close to a Writer so callers can always defer
+// Close without special-casing os.Stdout.
+type nopWriteCloser struct{ io.Writer }
+
+func (nopWriteCloser) Close() error { return nil }
+
func writeKeyValuePairs(w io.Writer, ids ResourceIDs) error {
pairs := []struct {
k, v string
diff --git a/.github/actions/aws-test-infra/src/parse-list.sh b/.github/actions/aws-test-infra/src/parse-list.sh
new file mode 100755
index 0000000..d852f2e
--- /dev/null
+++ b/.github/actions/aws-test-infra/src/parse-list.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Turn a newline-separated list on stdin into repeatable CLI flags.
+#
+# Usage: parse-list.sh < list
+#
+# Prints one "=" per non-empty line, stripping surrounding
+# whitespace and any trailing CR (CRLF-origin YAML). Values may contain spaces,
+# '=', or ':'. Used by the s3-stage step to build the -upload / -presign flags
+# for the aws-test-infra binary; kept as a script (not inline YAML) so it can be
+# unit-tested with bats.
+set -euo pipefail
+
+flag="${1:?flag name required (e.g. -upload)}"
+
+while IFS= read -r line || [ -n "$line" ]; do
+ line="${line%$'\r'}" # strip trailing CR (CRLF input)
+ line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace
+ line="${line%"${line##*[![:space:]]}"}" # trim trailing whitespace
+ [ -z "$line" ] && continue
+ printf '%s=%s\n' "$flag" "$line"
+done
diff --git a/.github/actions/aws-test-infra/src/s3.go b/.github/actions/aws-test-infra/src/s3.go
new file mode 100644
index 0000000..9d23816
--- /dev/null
+++ b/.github/actions/aws-test-infra/src/s3.go
@@ -0,0 +1,655 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
+)
+
+// deleteObjectsBatchSize is the S3 DeleteObjects limit (1000 keys per request).
+const deleteObjectsBatchSize = 1000
+
+// defaultPresignExpiry is used when the caller does not pass -expires-in.
+const defaultPresignExpiry = 4 * time.Hour
+
+// S3API is the subset of the S3 client we use, as an interface so tests can pass a fake.
+type S3API interface {
+ HeadBucket(ctx context.Context, params *s3.HeadBucketInput, optFns ...func(*s3.Options)) (*s3.HeadBucketOutput, error)
+ CreateBucket(ctx context.Context, params *s3.CreateBucketInput, optFns ...func(*s3.Options)) (*s3.CreateBucketOutput, error)
+ PutBucketTagging(ctx context.Context, params *s3.PutBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.PutBucketTaggingOutput, error)
+ PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
+ HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
+ GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
+ ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
+ DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error)
+ DeleteBucket(ctx context.Context, params *s3.DeleteBucketInput, optFns ...func(*s3.Options)) (*s3.DeleteBucketOutput, error)
+ ListBuckets(ctx context.Context, params *s3.ListBucketsInput, optFns ...func(*s3.Options)) (*s3.ListBucketsOutput, error)
+ GetBucketTagging(ctx context.Context, params *s3.GetBucketTaggingInput, optFns ...func(*s3.Options)) (*s3.GetBucketTaggingOutput, error)
+}
+
+// S3Presigner is the subset of the presign client we use. The SDK signs both GET
+// and PUT URLs, so no boto3 shim is needed.
+type S3Presigner interface {
+ PresignGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
+ PresignPutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error)
+}
+
+// bucketNameForRunID lowercases the run ID, since bucket names must be lowercase.
+// It assumes the run ID is otherwise S3/DNS-safe (no underscores, <=63 chars); the
+// conformance run-id shape (conformance---) satisfies that. A
+// malformed run ID surfaces as a CreateBucket error rather than an early message.
+func bucketNameForRunID(runID string) string {
+ return strings.ToLower(runID)
+}
+
+// uploadSpec is a local-file → object-key pair for s3-stage.
+type uploadSpec struct {
+ Local string
+ Key string
+}
+
+func parseUploadSpec(s string) (uploadSpec, error) {
+ local, key, ok := strings.Cut(s, "=")
+ if !ok || local == "" || key == "" {
+ return uploadSpec{}, fmt.Errorf("upload %q: expected local-path=object-key", s)
+ }
+ return uploadSpec{Local: local, Key: key}, nil
+}
+
+// presignSpec is an object-key + HTTP method (get|put) pair for s3-stage.
+type presignSpec struct {
+ Key string
+ Method string
+}
+
+func parsePresignSpec(s string) (presignSpec, error) {
+ key, method, ok := strings.Cut(s, ":")
+ if !ok || key == "" {
+ return presignSpec{}, fmt.Errorf("presign %q: expected object-key:method", s)
+ }
+ method = strings.ToLower(method)
+ if method != "get" && method != "put" {
+ return presignSpec{}, fmt.Errorf("presign %q: method must be get or put, got %q", s, method)
+ }
+ return presignSpec{Key: key, Method: method}, nil
+}
+
+// uploadFlag / presignFlag implement flag.Value so -upload / -presign repeat.
+type uploadFlag struct{ specs *[]uploadSpec }
+
+func (f *uploadFlag) String() string { return "" }
+func (f *uploadFlag) Set(value string) error {
+ spec, err := parseUploadSpec(value)
+ if err != nil {
+ return err
+ }
+ *f.specs = append(*f.specs, spec)
+ return nil
+}
+
+type presignFlag struct{ specs *[]presignSpec }
+
+func (f *presignFlag) String() string { return "" }
+func (f *presignFlag) Set(value string) error {
+ spec, err := parsePresignSpec(value)
+ if err != nil {
+ return err
+ }
+ *f.specs = append(*f.specs, spec)
+ return nil
+}
+
+// ---- s3-stage ----
+
+// S3StageConfig is the parsed flag set for `s3-stage`.
+type S3StageConfig struct {
+ Region string
+ RunID string
+ Bucket string // derived from RunID when empty
+ Uploads []uploadSpec
+ Presigns []presignSpec
+ ExpiresIn time.Duration
+
+ OutputPath string
+ OutputFormat string
+}
+
+// S3StageResult is what s3-stage emits: the bucket used and each presigned URL by key.
+type S3StageResult struct {
+ Bucket string `json:"bucket"`
+ PresignedURLs map[string]string `json:"presigned_urls"`
+}
+
+func runS3Stage(ctx context.Context, logger *slog.Logger, name string, args []string) error {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ cfg := S3StageConfig{}
+ fs.StringVar(&cfg.Region, "region", "", "AWS region (required)")
+ fs.StringVar(&cfg.RunID, "run-id", "", "Unique run identifier; bucket name derives from it and it is tagged as RunID (required)")
+ fs.StringVar(&cfg.Bucket, "bucket", "", "Bucket name; defaults to the lowercased run-id")
+ fs.Var(&uploadFlag{specs: &cfg.Uploads}, "upload", "Upload in local-path=object-key form; repeatable")
+ fs.Var(&presignFlag{specs: &cfg.Presigns}, "presign", "Presign in object-key:method form (method get|put); repeatable")
+ fs.DurationVar(&cfg.ExpiresIn, "expires-in", defaultPresignExpiry, "Presigned URL lifetime (e.g. 4h, 14400s)")
+ fs.StringVar(&cfg.OutputPath, "output", "", "Output destination; empty means stdout. Set to $GITHUB_OUTPUT to feed into Actions")
+ fs.StringVar(&cfg.OutputFormat, "output-format", "auto", "auto | github-output | json")
+ if err := fs.Parse(args); err != nil {
+ return fmt.Errorf("parse s3-stage flags: %w", err)
+ }
+ if cfg.Region == "" || cfg.RunID == "" {
+ return errors.New("s3-stage: -region and -run-id are required")
+ }
+ if cfg.Bucket == "" {
+ cfg.Bucket = bucketNameForRunID(cfg.RunID)
+ }
+
+ awsCfg, err := loadAWSConfig(ctx, cfg.Region)
+ if err != nil {
+ return fmt.Errorf("load aws config: %w", err)
+ }
+
+ // A presigned URL only works while its signing credentials are still valid.
+ // Warn if -expires-in outlives a short OIDC/STS session, which would 403 mid-run.
+ if creds, cerr := awsCfg.Credentials.Retrieve(ctx); cerr != nil {
+ logger.Warn("could not inspect credential lifetime; cannot verify presigned URLs outlive the session", "error", cerr)
+ } else if msg, ok := presignLifetimeWarning(creds, cfg.ExpiresIn, time.Now()); ok {
+ fmt.Printf("::warning title=Presigned URL may expire early::%s\n", msg)
+ logger.Warn("presigned URL lifetime exceeds credential lifetime", "detail", msg)
+ }
+
+ client := s3.NewFromConfig(awsCfg)
+ presigner := s3.NewPresignClient(client)
+
+ res, err := s3Stage(ctx, logger, client, presigner, cfg)
+ if err != nil {
+ return err
+ }
+ return emitS3StageOutput(logger, cfg.OutputPath, cfg.OutputFormat, res)
+}
+
+// presignLifetimeWarning returns a warning (and true) when the signing credentials
+// expire before the presign window closes; a URL cannot outlive the creds that
+// signed it. Returns ("", false) for the safe cases (non-expiring or long-enough creds).
+func presignLifetimeWarning(creds aws.Credentials, expiresIn time.Duration, now time.Time) (string, bool) {
+ if !creds.CanExpire {
+ return "", false
+ }
+ presignUntil := now.Add(expiresIn)
+ if !creds.Expires.Before(presignUntil) {
+ return "", false
+ }
+ remaining := creds.Expires.Sub(now)
+ if remaining < 0 {
+ remaining = 0
+ }
+ return fmt.Sprintf(
+ "requested presign lifetime %s exceeds remaining credential lifetime ~%s; "+
+ "presigned URLs will start returning 403 once the session expires. "+
+ "Raise role-duration-seconds on configure-aws-credentials (and the role's max session duration) to cover the run.",
+ expiresIn.Round(time.Second), remaining.Round(time.Second),
+ ), true
+}
+
+// s3Stage is the testable core: ensure+tag the bucket, upload files, presign keys.
+func s3Stage(ctx context.Context, logger *slog.Logger, api S3API, presigner S3Presigner, cfg S3StageConfig) (S3StageResult, error) {
+ if err := ensureBucket(ctx, logger, api, cfg.Bucket, cfg.Region, cfg.RunID); err != nil {
+ return S3StageResult{}, err
+ }
+
+ for _, up := range cfg.Uploads {
+ if err := uploadObject(ctx, logger, api, cfg.Bucket, up); err != nil {
+ return S3StageResult{}, err
+ }
+ }
+
+ expires := cfg.ExpiresIn
+ if expires <= 0 {
+ expires = defaultPresignExpiry
+ }
+ urls := make(map[string]string, len(cfg.Presigns))
+ for _, ps := range cfg.Presigns {
+ url, err := presignObject(ctx, presigner, cfg.Bucket, ps, expires)
+ if err != nil {
+ return S3StageResult{}, err
+ }
+ urls[ps.Key] = url
+ logger.Info("presigned URL ready", "bucket", cfg.Bucket, "key", ps.Key, "method", ps.Method)
+ }
+
+ return S3StageResult{Bucket: cfg.Bucket, PresignedURLs: urls}, nil
+}
+
+// ensureBucket creates the bucket if it is missing, then always applies the RunID tag.
+func ensureBucket(ctx context.Context, logger *slog.Logger, api S3API, bucket, region, runID string) error {
+ _, err := api.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
+ if err != nil {
+ // Only a real 404 means "create it"; surface anything else (e.g. a 403)
+ // instead of blindly creating and masking the real cause.
+ if !isNotFound(err) {
+ return fmt.Errorf("head bucket %q: %w", bucket, err)
+ }
+ input := &s3.CreateBucketInput{Bucket: aws.String(bucket)}
+ // us-east-1 rejects a LocationConstraint; every other region requires it.
+ if region != "us-east-1" {
+ input.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
+ LocationConstraint: s3types.BucketLocationConstraint(region),
+ }
+ }
+ if _, cerr := api.CreateBucket(ctx, input); cerr != nil {
+ // An already-owned/existing bucket is success (idempotent).
+ if !bucketAlreadyOurs(cerr) {
+ return fmt.Errorf("create bucket %q: %w", bucket, cerr)
+ }
+ } else {
+ logger.Info("created bucket", "bucket", bucket, "region", region)
+ }
+ }
+
+ _, err = api.PutBucketTagging(ctx, &s3.PutBucketTaggingInput{
+ Bucket: aws.String(bucket),
+ Tagging: &s3types.Tagging{TagSet: []s3types.Tag{{Key: aws.String("RunID"), Value: aws.String(runID)}}},
+ })
+ if err != nil {
+ return fmt.Errorf("tag bucket %q: %w", bucket, err)
+ }
+ return nil
+}
+
+func bucketAlreadyOurs(err error) bool {
+ var owned *s3types.BucketAlreadyOwnedByYou
+ var exists *s3types.BucketAlreadyExists
+ return errors.As(err, &owned) || errors.As(err, &exists)
+}
+
+func uploadObject(ctx context.Context, logger *slog.Logger, api S3API, bucket string, up uploadSpec) error {
+ f, err := os.Open(up.Local)
+ if err != nil {
+ return fmt.Errorf("open upload %q: %w", up.Local, err)
+ }
+ defer f.Close()
+ if _, err := api.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(up.Key),
+ Body: f,
+ }); err != nil {
+ return fmt.Errorf("upload %q to s3://%s/%s: %w", up.Local, bucket, up.Key, err)
+ }
+ logger.Info("uploaded object", "bucket", bucket, "key", up.Key, "local", up.Local)
+ return nil
+}
+
+func presignObject(ctx context.Context, presigner S3Presigner, bucket string, ps presignSpec, expires time.Duration) (string, error) {
+ withExpiry := func(o *s3.PresignOptions) { o.Expires = expires }
+ switch ps.Method {
+ case "get":
+ req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(bucket), Key: aws.String(ps.Key)}, withExpiry)
+ if err != nil {
+ return "", fmt.Errorf("presign GET %q: %w", ps.Key, err)
+ }
+ return req.URL, nil
+ case "put":
+ req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(ps.Key)}, withExpiry)
+ if err != nil {
+ return "", fmt.Errorf("presign PUT %q: %w", ps.Key, err)
+ }
+ return req.URL, nil
+ default:
+ return "", fmt.Errorf("presign %q: unsupported method %q", ps.Key, ps.Method)
+ }
+}
+
+// ---- s3-download ----
+
+// S3DownloadConfig is the parsed flag set for `s3-download`.
+type S3DownloadConfig struct {
+ Region string
+ Bucket string
+ Key string
+ Dest string
+}
+
+func runS3Download(ctx context.Context, logger *slog.Logger, name string, args []string) error {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ cfg := S3DownloadConfig{}
+ fs.StringVar(&cfg.Region, "region", "", "AWS region (required)")
+ fs.StringVar(&cfg.Bucket, "bucket", "", "Bucket name (required)")
+ fs.StringVar(&cfg.Key, "key", "", "Object key to download (required)")
+ fs.StringVar(&cfg.Dest, "dest", "", "Local destination path (required)")
+ if err := fs.Parse(args); err != nil {
+ return fmt.Errorf("parse s3-download flags: %w", err)
+ }
+ if cfg.Region == "" || cfg.Bucket == "" || cfg.Key == "" || cfg.Dest == "" {
+ return errors.New("s3-download: -region, -bucket, -key and -dest are required")
+ }
+
+ awsCfg, err := loadAWSConfig(ctx, cfg.Region)
+ if err != nil {
+ return fmt.Errorf("load aws config: %w", err)
+ }
+ return s3Download(ctx, logger, s3.NewFromConfig(awsCfg), cfg)
+}
+
+// s3Download copies s3://bucket/key to dest. A missing object is a no-op, not an error.
+func s3Download(ctx context.Context, logger *slog.Logger, api S3API, cfg S3DownloadConfig) error {
+ if _, err := api.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(cfg.Bucket), Key: aws.String(cfg.Key)}); err != nil {
+ if isNotFound(err) {
+ logger.Info("object not present; skipping download", "bucket", cfg.Bucket, "key", cfg.Key)
+ return nil
+ }
+ return fmt.Errorf("head s3://%s/%s: %w", cfg.Bucket, cfg.Key, err)
+ }
+
+ out, err := api.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(cfg.Bucket), Key: aws.String(cfg.Key)})
+ if err != nil {
+ return fmt.Errorf("get s3://%s/%s: %w", cfg.Bucket, cfg.Key, err)
+ }
+ defer out.Body.Close()
+
+ if err := os.MkdirAll(filepath.Dir(cfg.Dest), 0o755); err != nil {
+ return fmt.Errorf("create dest dir for %q: %w", cfg.Dest, err)
+ }
+ f, err := os.Create(cfg.Dest)
+ if err != nil {
+ return fmt.Errorf("create dest %q: %w", cfg.Dest, err)
+ }
+ defer f.Close()
+ if _, err := io.Copy(f, out.Body); err != nil {
+ // Don't leave a truncated file behind for a later reader to trip on.
+ _ = os.Remove(cfg.Dest)
+ return fmt.Errorf("write dest %q: %w", cfg.Dest, err)
+ }
+ logger.Info("downloaded object", "bucket", cfg.Bucket, "key", cfg.Key, "dest", cfg.Dest)
+ return nil
+}
+
+// ---- s3-cleanup ----
+
+// S3CleanupConfig is the parsed flag set for `s3-cleanup`.
+type S3CleanupConfig struct {
+ Region string
+ Bucket string
+ RunID string
+ BucketPrefix string
+ SkipSweep bool
+ Strict bool
+}
+
+func runS3Cleanup(ctx context.Context, logger *slog.Logger, name string, args []string) error {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ cfg := S3CleanupConfig{}
+ fs.StringVar(&cfg.Region, "region", "", "AWS region (required)")
+ fs.StringVar(&cfg.Bucket, "bucket", "", "Bucket to delete directly (empty skips direct delete)")
+ fs.StringVar(&cfg.RunID, "run-id", "", "Run ID matched against the RunID tag during the sweep")
+ fs.StringVar(&cfg.BucketPrefix, "bucket-prefix", "", "Name prefix for the tag-based sweep fallback")
+ fs.BoolVar(&cfg.SkipSweep, "skip-sweep", false, "Skip the tag-based sweep; only delete the supplied bucket")
+ fs.BoolVar(&cfg.Strict, "strict", false, "Fail on delete/sweep errors. Default false matches the Bash teardown (best-effort).")
+ if err := fs.Parse(args); err != nil {
+ return fmt.Errorf("parse s3-cleanup flags: %w", err)
+ }
+ if cfg.Region == "" {
+ return errors.New("s3-cleanup: -region is required")
+ }
+
+ awsCfg, err := loadAWSConfig(ctx, cfg.Region)
+ if err != nil {
+ return fmt.Errorf("load aws config: %w", err)
+ }
+ return s3Cleanup(ctx, logger, s3.NewFromConfig(awsCfg), cfg)
+}
+
+// s3Cleanup deletes the given bucket (if any) then sweeps orphans by tag.
+// Best-effort by default: errors are logged; with -strict the first one is returned.
+func s3Cleanup(ctx context.Context, logger *slog.Logger, api S3API, cfg S3CleanupConfig) error {
+ var firstErr error
+ note := func(err error) {
+ if err == nil {
+ return
+ }
+ if cfg.Strict && firstErr == nil {
+ firstErr = err
+ }
+ logger.Warn("s3-cleanup error (best-effort)", "error", err)
+ }
+
+ if cfg.Bucket != "" {
+ note(emptyAndDeleteBucket(ctx, logger, api, cfg.Bucket))
+ }
+
+ if !cfg.SkipSweep && cfg.BucketPrefix != "" {
+ if cfg.RunID == "" {
+ // Without a RunID we cannot tell our orphans apart from anyone
+ // else's, so we skip rather than delete blindly. Mirror the EC2
+ // cleanup guard: surface it (fatal under -strict, warning otherwise).
+ note(errors.New("s3-cleanup: sweep requested (bucket-prefix set) but run-id is empty; skipping sweep"))
+ } else {
+ note(sweepBuckets(ctx, logger, api, cfg.Region, cfg.BucketPrefix, cfg.RunID))
+ }
+ }
+
+ return firstErr
+}
+
+func sweepBuckets(ctx context.Context, logger *slog.Logger, api S3API, region, prefix, runID string) error {
+ var sweepErr error
+ var token *string
+ for {
+ // Filter to this region: ListBuckets is global, but the client (and the
+ // delete calls) are region-bound, so a same-tag bucket elsewhere would
+ // just fail the follow-up calls. Paginate so buckets past the first page
+ // are not silently skipped.
+ out, err := api.ListBuckets(ctx, &s3.ListBucketsInput{
+ BucketRegion: aws.String(region),
+ Prefix: aws.String(prefix), // server-side filter; the HasPrefix below stays as a safety net
+ ContinuationToken: token,
+ })
+ if err != nil {
+ return fmt.Errorf("list buckets: %w", err)
+ }
+ for _, b := range out.Buckets {
+ name := aws.ToString(b.Name)
+ if !strings.HasPrefix(name, prefix) {
+ continue
+ }
+ tags, terr := api.GetBucketTagging(ctx, &s3.GetBucketTaggingInput{Bucket: aws.String(name)})
+ if terr != nil {
+ // NoSuchTagSet just means no RunID tag: skip quietly. Any other error
+ // might hide a bucket that is ours, so warn rather than skip silently.
+ if !isNoSuchTagSet(terr) {
+ logger.Warn("could not read tags during sweep; skipping bucket (may leak if it is ours)",
+ "bucket", name, "error", terr)
+ }
+ continue
+ }
+ if !hasRunIDTag(tags.TagSet, runID) {
+ continue
+ }
+ logger.Info("sweeping orphaned bucket", "bucket", name, "run_id", runID)
+ if derr := emptyAndDeleteBucket(ctx, logger, api, name); derr != nil {
+ // Log every failure so no leaked bucket is invisible; keep the
+ // first as the returned error (mirrors the EC2 cleanup sweep).
+ logger.Warn("failed to sweep orphaned bucket", "bucket", name, "error", derr)
+ if sweepErr == nil {
+ sweepErr = derr
+ }
+ }
+ }
+ if aws.ToString(out.ContinuationToken) == "" {
+ break
+ }
+ token = out.ContinuationToken
+ }
+ return sweepErr
+}
+
+func hasRunIDTag(tags []s3types.Tag, runID string) bool {
+ for _, t := range tags {
+ if aws.ToString(t.Key) == "RunID" && aws.ToString(t.Value) == runID {
+ return true
+ }
+ }
+ return false
+}
+
+// emptyAndDeleteBucket removes every object then deletes the bucket (S3 requires
+// the bucket be empty first).
+func emptyAndDeleteBucket(ctx context.Context, logger *slog.Logger, api S3API, bucket string) error {
+ var token *string
+ for {
+ list, err := api.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
+ Bucket: aws.String(bucket),
+ ContinuationToken: token,
+ })
+ if err != nil {
+ if isNoSuchBucket(err) {
+ return nil // already gone
+ }
+ return fmt.Errorf("list objects in %q: %w", bucket, err)
+ }
+ if err := deleteObjectPage(ctx, api, bucket, list.Contents); err != nil {
+ return err
+ }
+ if aws.ToBool(list.IsTruncated) && list.NextContinuationToken != nil {
+ token = list.NextContinuationToken
+ continue
+ }
+ break
+ }
+
+ if _, err := api.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)}); err != nil {
+ if isNoSuchBucket(err) {
+ return nil
+ }
+ return fmt.Errorf("delete bucket %q: %w", bucket, err)
+ }
+ logger.Info("deleted bucket", "bucket", bucket)
+ return nil
+}
+
+func deleteObjectPage(ctx context.Context, api S3API, bucket string, contents []s3types.Object) error {
+ ids := make([]s3types.ObjectIdentifier, 0, len(contents))
+ for _, obj := range contents {
+ ids = append(ids, s3types.ObjectIdentifier{Key: obj.Key})
+ }
+ for start := 0; start < len(ids); start += deleteObjectsBatchSize {
+ end := min(start+deleteObjectsBatchSize, len(ids))
+ out, err := api.DeleteObjects(ctx, &s3.DeleteObjectsInput{
+ Bucket: aws.String(bucket),
+ Delete: &s3types.Delete{Objects: ids[start:end], Quiet: aws.Bool(true)},
+ })
+ if err != nil {
+ return fmt.Errorf("delete objects in %q: %w", bucket, err)
+ }
+ // DeleteObjects returns 200 even when some keys fail; those are in out.Errors.
+ // Surface them, else the later DeleteBucket fails with BucketNotEmpty.
+ if out != nil && len(out.Errors) > 0 {
+ return fmt.Errorf("delete objects in %q: %s", bucket, summarizeDeleteErrors(out.Errors))
+ }
+ }
+ return nil
+}
+
+// summarizeDeleteErrors renders per-object DeleteObjects failures compactly:
+// the total count plus up to the first few "key (code)" pairs.
+func summarizeDeleteErrors(errs []s3types.Error) string {
+ const maxShown = 3
+ var b strings.Builder
+ fmt.Fprintf(&b, "%d object(s) failed to delete", len(errs))
+ shown := min(len(errs), maxShown)
+ for i := 0; i < shown; i++ {
+ if i == 0 {
+ b.WriteString(": ")
+ } else {
+ b.WriteString(", ")
+ }
+ fmt.Fprintf(&b, "%s (%s)", aws.ToString(errs[i].Key), aws.ToString(errs[i].Code))
+ }
+ if len(errs) > shown {
+ fmt.Fprintf(&b, ", … (%d more)", len(errs)-shown)
+ }
+ return b.String()
+}
+
+// apiErrorCodeIs reports whether err is an AWS API error with one of the given codes.
+func apiErrorCodeIs(err error, codes ...string) bool {
+ var apiErr smithy.APIError
+ if !errors.As(err, &apiErr) {
+ return false
+ }
+ return slices.Contains(codes, apiErr.ErrorCode())
+}
+
+// isNotFound reports whether err is an S3 404 (missing object/bucket for Head*).
+func isNotFound(err error) bool {
+ return apiErrorCodeIs(err, "NotFound", "NoSuchKey", "NoSuchBucket", "404")
+}
+
+func isNoSuchBucket(err error) bool { return apiErrorCodeIs(err, "NoSuchBucket") }
+
+func isNoSuchTagSet(err error) bool { return apiErrorCodeIs(err, "NoSuchTagSet") }
+
+// emitS3StageOutput writes the stage result to GITHUB_OUTPUT (key=value) or stdout (JSON),
+// following the same destination semantics as emitOutput.
+func emitS3StageOutput(logger *slog.Logger, destination, format string, res S3StageResult) error {
+ if format == "" || format == "auto" {
+ if destination == "" {
+ format = "json"
+ } else {
+ format = "github-output"
+ }
+ }
+
+ w, err := openDestWriter(destination)
+ if err != nil {
+ return err
+ }
+ defer w.Close()
+
+ switch format {
+ case "json":
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(res); err != nil {
+ return fmt.Errorf("encode json output: %w", err)
+ }
+ case "github-output":
+ // GITHUB_OUTPUT is not masked, so register each URL with ::add-mask:: first
+ // (a write-capable PUT URL is a secret). Mask commands go to stdout, not the file.
+ maskPresignedURLs(os.Stdout, res.PresignedURLs)
+
+ urlsJSON, err := json.Marshal(res.PresignedURLs)
+ if err != nil {
+ return fmt.Errorf("encode presigned_urls: %w", err)
+ }
+ if _, err := fmt.Fprintf(w, "bucket=%s\npresigned_urls=%s\n", res.Bucket, urlsJSON); err != nil {
+ return fmt.Errorf("write github output: %w", err)
+ }
+ default:
+ return fmt.Errorf("unknown output format: %s", format)
+ }
+ logger.Info("emitted s3-stage output", "destination", destinationLabel(destination), "format", format)
+ return nil
+}
+
+// maskPresignedURLs writes an Actions ::add-mask:: command for each URL so the
+// runner scrubs it from later logs.
+func maskPresignedURLs(w io.Writer, urls map[string]string) {
+ for _, u := range urls {
+ if u == "" {
+ continue
+ }
+ fmt.Fprintf(w, "::add-mask::%s\n", u)
+ }
+}
diff --git a/.github/actions/aws-test-infra/src/s3_mocks_test.go b/.github/actions/aws-test-infra/src/s3_mocks_test.go
new file mode 100644
index 0000000..06cc250
--- /dev/null
+++ b/.github/actions/aws-test-infra/src/s3_mocks_test.go
@@ -0,0 +1,247 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
+)
+
+// fakeS3 is a hand-rolled S3API mock (mirrors fakeEC2): it records calls and
+// returns happy-path responses unless a field stages an error or fixture.
+type fakeS3 struct {
+ mu sync.Mutex
+ calls []string
+
+ // staged behavior
+ headBucketErr error
+ createBucketErr error
+ putTaggingErr error // error from PutBucketTagging
+ putObjectErr error // error from PutObject
+ headObjectErr error
+ getObjectBody string
+ getObjectErr error // error from GetObject itself (Head succeeded)
+ getObjectReadErr error // error raised mid-stream while reading the body
+ listObjectsErr error // error from ListObjectsV2
+ deleteObjectsErr error // transport-level error from DeleteObjects
+ deleteObjectErrs []s3types.Error // per-object failures in a 200 response
+ deleteBucketErr error // error from DeleteBucket
+ listBucketsErr error // error from ListBuckets
+ getBucketTaggingErr error // generic (non-NoSuchTagSet) error from GetBucketTagging
+
+ // object listing for cleanup: one entry per ListObjectsV2 page. Each page
+ // reports IsTruncated=true until the last one so pagination is exercised.
+ listPages [][]s3types.Object
+ listIdx int
+
+ // bucket sweep fixtures
+ buckets []s3types.Bucket
+ bucketTags map[string][]s3types.Tag
+
+ // bucketPages, if set, makes ListBuckets return one page per entry with a
+ // continuation token until the last, so the sweep pagination loop is exercised.
+ bucketPages [][]s3types.Bucket
+ listBucketsIdx int
+
+ // captured inputs / effects
+ createBucketInput *s3.CreateBucketInput
+ putTaggingInput *s3.PutBucketTaggingInput
+ uploadedKeys []string
+ deletedKeys []string
+ deletedBuckets []string
+}
+
+func (f *fakeS3) record(m string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, m)
+}
+
+func (f *fakeS3) called(m string) int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ n := 0
+ for _, c := range f.calls {
+ if c == m {
+ n++
+ }
+ }
+ return n
+}
+
+func (f *fakeS3) HeadBucket(_ context.Context, _ *s3.HeadBucketInput, _ ...func(*s3.Options)) (*s3.HeadBucketOutput, error) {
+ f.record("HeadBucket")
+ if f.headBucketErr != nil {
+ return nil, f.headBucketErr
+ }
+ return &s3.HeadBucketOutput{}, nil
+}
+
+func (f *fakeS3) CreateBucket(_ context.Context, in *s3.CreateBucketInput, _ ...func(*s3.Options)) (*s3.CreateBucketOutput, error) {
+ f.record("CreateBucket")
+ f.createBucketInput = in
+ if f.createBucketErr != nil {
+ return nil, f.createBucketErr
+ }
+ return &s3.CreateBucketOutput{}, nil
+}
+
+func (f *fakeS3) PutBucketTagging(_ context.Context, in *s3.PutBucketTaggingInput, _ ...func(*s3.Options)) (*s3.PutBucketTaggingOutput, error) {
+ f.record("PutBucketTagging")
+ f.putTaggingInput = in
+ if f.putTaggingErr != nil {
+ return nil, f.putTaggingErr
+ }
+ return &s3.PutBucketTaggingOutput{}, nil
+}
+
+func (f *fakeS3) PutObject(_ context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
+ f.record("PutObject")
+ if f.putObjectErr != nil {
+ return nil, f.putObjectErr
+ }
+ f.uploadedKeys = append(f.uploadedKeys, aws.ToString(in.Key))
+ return &s3.PutObjectOutput{}, nil
+}
+
+func (f *fakeS3) HeadObject(_ context.Context, _ *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
+ f.record("HeadObject")
+ if f.headObjectErr != nil {
+ return nil, f.headObjectErr
+ }
+ return &s3.HeadObjectOutput{}, nil
+}
+
+func (f *fakeS3) GetObject(_ context.Context, _ *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
+ f.record("GetObject")
+ if f.getObjectErr != nil {
+ return nil, f.getObjectErr
+ }
+ if f.getObjectReadErr != nil {
+ return &s3.GetObjectOutput{Body: io.NopCloser(&errReader{err: f.getObjectReadErr})}, nil
+ }
+ return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewBufferString(f.getObjectBody))}, nil
+}
+
+// errReader fails on Read, simulating a body that errors mid-stream.
+type errReader struct{ err error }
+
+func (r *errReader) Read([]byte) (int, error) { return 0, r.err }
+
+func (f *fakeS3) ListObjectsV2(_ context.Context, _ *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
+ f.record("ListObjectsV2")
+ if f.listObjectsErr != nil {
+ return nil, f.listObjectsErr
+ }
+ var page []s3types.Object
+ if f.listIdx < len(f.listPages) {
+ page = f.listPages[f.listIdx]
+ }
+ f.listIdx++
+ // Report truncation (with a continuation token) while more pages remain so
+ // the caller's pagination loop is genuinely exercised.
+ truncated := f.listIdx < len(f.listPages)
+ out := &s3.ListObjectsV2Output{Contents: page, IsTruncated: aws.Bool(truncated)}
+ if truncated {
+ out.NextContinuationToken = aws.String(fmt.Sprintf("token-%d", f.listIdx))
+ }
+ return out, nil
+}
+
+func (f *fakeS3) DeleteObjects(_ context.Context, in *s3.DeleteObjectsInput, _ ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) {
+ f.record("DeleteObjects")
+ if f.deleteObjectsErr != nil {
+ return nil, f.deleteObjectsErr
+ }
+ if in.Delete != nil {
+ for _, o := range in.Delete.Objects {
+ f.deletedKeys = append(f.deletedKeys, aws.ToString(o.Key))
+ }
+ }
+ // Per-object failures ride back in a 200 response (nil transport error).
+ return &s3.DeleteObjectsOutput{Errors: f.deleteObjectErrs}, nil
+}
+
+func (f *fakeS3) DeleteBucket(_ context.Context, in *s3.DeleteBucketInput, _ ...func(*s3.Options)) (*s3.DeleteBucketOutput, error) {
+ f.record("DeleteBucket")
+ if f.deleteBucketErr != nil {
+ return nil, f.deleteBucketErr
+ }
+ f.deletedBuckets = append(f.deletedBuckets, aws.ToString(in.Bucket))
+ return &s3.DeleteBucketOutput{}, nil
+}
+
+func (f *fakeS3) ListBuckets(_ context.Context, _ *s3.ListBucketsInput, _ ...func(*s3.Options)) (*s3.ListBucketsOutput, error) {
+ f.record("ListBuckets")
+ if f.listBucketsErr != nil {
+ return nil, f.listBucketsErr
+ }
+ if len(f.bucketPages) > 0 {
+ var page []s3types.Bucket
+ if f.listBucketsIdx < len(f.bucketPages) {
+ page = f.bucketPages[f.listBucketsIdx]
+ }
+ f.listBucketsIdx++
+ out := &s3.ListBucketsOutput{Buckets: page}
+ if f.listBucketsIdx < len(f.bucketPages) {
+ out.ContinuationToken = aws.String(fmt.Sprintf("bkt-token-%d", f.listBucketsIdx))
+ }
+ return out, nil
+ }
+ return &s3.ListBucketsOutput{Buckets: f.buckets}, nil
+}
+
+func (f *fakeS3) GetBucketTagging(_ context.Context, in *s3.GetBucketTaggingInput, _ ...func(*s3.Options)) (*s3.GetBucketTaggingOutput, error) {
+ f.record("GetBucketTagging")
+ if f.getBucketTaggingErr != nil {
+ return nil, f.getBucketTaggingErr
+ }
+ name := aws.ToString(in.Bucket)
+ tags, ok := f.bucketTags[name]
+ if !ok {
+ // Untagged bucket: the real API returns NoSuchTagSet; the sweep treats
+ // that as "no RunID tag → skip quietly".
+ return nil, &smithy.GenericAPIError{Code: "NoSuchTagSet", Message: "The TagSet does not exist"}
+ }
+ return &s3.GetBucketTaggingOutput{TagSet: tags}, nil
+}
+
+// fakePresigner satisfies S3Presigner, returning deterministic URLs and
+// recording the expiry the caller applied via the PresignOptions functions.
+type fakePresigner struct {
+ getErr error
+ putErr error
+ lastExpiry time.Duration
+}
+
+func (p *fakePresigner) applyExpiry(optFns []func(*s3.PresignOptions)) {
+ var o s3.PresignOptions
+ for _, fn := range optFns {
+ fn(&o)
+ }
+ p.lastExpiry = o.Expires
+}
+
+func (p *fakePresigner) PresignGetObject(_ context.Context, in *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
+ p.applyExpiry(optFns)
+ if p.getErr != nil {
+ return nil, p.getErr
+ }
+ return &v4.PresignedHTTPRequest{URL: "https://presigned.example/GET/" + aws.ToString(in.Key), Method: "GET"}, nil
+}
+
+func (p *fakePresigner) PresignPutObject(_ context.Context, in *s3.PutObjectInput, optFns ...func(*s3.PresignOptions)) (*v4.PresignedHTTPRequest, error) {
+ p.applyExpiry(optFns)
+ if p.putErr != nil {
+ return nil, p.putErr
+ }
+ return &v4.PresignedHTTPRequest{URL: "https://presigned.example/PUT/" + aws.ToString(in.Key), Method: "PUT"}, nil
+}
diff --git a/.github/actions/aws-test-infra/src/s3_test.go b/.github/actions/aws-test-infra/src/s3_test.go
new file mode 100644
index 0000000..f0fe347
--- /dev/null
+++ b/.github/actions/aws-test-infra/src/s3_test.go
@@ -0,0 +1,762 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/aws/smithy-go"
+)
+
+// captureStdout runs fn with os.Stdout redirected to a pipe and returns what
+// was written, so tests can assert on stdout-bound output (json / ::add-mask::).
+func captureStdout(t *testing.T, fn func()) string {
+ t.Helper()
+ old := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ os.Stdout = w
+ fn()
+ _ = w.Close()
+ os.Stdout = old
+ out, _ := io.ReadAll(r)
+ return string(out)
+}
+
+func TestBucketNameForRunID(t *testing.T) {
+ if got := bucketNameForRunID("Conformance-Standalone-123"); got != "conformance-standalone-123" {
+ t.Errorf("bucketNameForRunID = %q, want lowercased", got)
+ }
+}
+
+func TestParseUploadSpec(t *testing.T) {
+ tests := []struct {
+ in string
+ want uploadSpec
+ wantErr bool
+ }{
+ {"vcluster_image=vcluster.tar", uploadSpec{Local: "vcluster_image", Key: "vcluster.tar"}, false},
+ {"/tmp/a.tar.gz=kind-node.tar.gz", uploadSpec{Local: "/tmp/a.tar.gz", Key: "kind-node.tar.gz"}, false},
+ {"missing-equals", uploadSpec{}, true},
+ {"=key-only", uploadSpec{}, true},
+ {"local-only=", uploadSpec{}, true},
+ }
+ for _, tt := range tests {
+ got, err := parseUploadSpec(tt.in)
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("parseUploadSpec(%q) expected error", tt.in)
+ }
+ continue
+ }
+ if err != nil || got != tt.want {
+ t.Errorf("parseUploadSpec(%q) = %+v, %v; want %+v", tt.in, got, err, tt.want)
+ }
+ }
+}
+
+func TestParsePresignSpec(t *testing.T) {
+ tests := []struct {
+ in string
+ want presignSpec
+ wantErr bool
+ }{
+ {"vcluster.tar:get", presignSpec{Key: "vcluster.tar", Method: "get"}, false},
+ {"results.tar.gz:put", presignSpec{Key: "results.tar.gz", Method: "put"}, false},
+ {"results.tar.gz:PUT", presignSpec{Key: "results.tar.gz", Method: "put"}, false},
+ {"no-method", presignSpec{}, true},
+ {":get", presignSpec{}, true},
+ {"key:delete", presignSpec{}, true},
+ }
+ for _, tt := range tests {
+ got, err := parsePresignSpec(tt.in)
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("parsePresignSpec(%q) expected error", tt.in)
+ }
+ continue
+ }
+ if err != nil || got != tt.want {
+ t.Errorf("parsePresignSpec(%q) = %+v, %v; want %+v", tt.in, got, err, tt.want)
+ }
+ }
+}
+
+func TestEnsureBucket_CreatesWhenMissing(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ if err := ensureBucket(context.Background(), newTestLogger(), f, "my-bucket", "us-west-2", "RunTag-1"); err != nil {
+ t.Fatalf("ensureBucket: %v", err)
+ }
+ if f.called("CreateBucket") != 1 {
+ t.Errorf("expected CreateBucket to be called once, calls=%v", f.calls)
+ }
+ if f.called("PutBucketTagging") != 1 {
+ t.Errorf("expected PutBucketTagging once, calls=%v", f.calls)
+ }
+ // Non-us-east-1 must carry a LocationConstraint.
+ if f.createBucketInput == nil || f.createBucketInput.CreateBucketConfiguration == nil ||
+ string(f.createBucketInput.CreateBucketConfiguration.LocationConstraint) != "us-west-2" {
+ t.Errorf("expected LocationConstraint=us-west-2, got %+v", f.createBucketInput)
+ }
+ // RunID tag preserves original (un-lowercased) run id.
+ if f.putTaggingInput == nil || len(f.putTaggingInput.Tagging.TagSet) != 1 ||
+ aws.ToString(f.putTaggingInput.Tagging.TagSet[0].Value) != "RunTag-1" {
+ t.Errorf("expected RunID tag value RunTag-1, got %+v", f.putTaggingInput)
+ }
+}
+
+func TestEnsureBucket_SkipsCreateWhenPresent(t *testing.T) {
+ f := &fakeS3{} // HeadBucket succeeds
+ if err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-west-2", "r"); err != nil {
+ t.Fatalf("ensureBucket: %v", err)
+ }
+ if f.called("CreateBucket") != 0 {
+ t.Errorf("did not expect CreateBucket when bucket present, calls=%v", f.calls)
+ }
+ if f.called("PutBucketTagging") != 1 {
+ t.Errorf("expected PutBucketTagging even when bucket present, calls=%v", f.calls)
+ }
+}
+
+func TestEnsureBucket_USEast1NoLocationConstraint(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ if err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-east-1", "r"); err != nil {
+ t.Fatalf("ensureBucket: %v", err)
+ }
+ if f.createBucketInput == nil || f.createBucketInput.CreateBucketConfiguration != nil {
+ t.Errorf("us-east-1 must omit CreateBucketConfiguration, got %+v", f.createBucketInput)
+ }
+}
+
+func TestS3Stage_EndToEnd(t *testing.T) {
+ dir := t.TempDir()
+ local := filepath.Join(dir, "vcluster_image")
+ if err := os.WriteFile(local, []byte("image-bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ p := &fakePresigner{}
+ cfg := S3StageConfig{
+ Region: "us-west-2",
+ RunID: "conformance-standalone-9",
+ Bucket: "conformance-standalone-9",
+ Uploads: []uploadSpec{{Local: local, Key: "vcluster.tar"}},
+ Presigns: []presignSpec{{Key: "vcluster.tar", Method: "get"}, {Key: "results.tar.gz", Method: "put"}},
+ ExpiresIn: 2 * time.Hour,
+ }
+ res, err := s3Stage(context.Background(), newTestLogger(), f, p, cfg)
+ if err != nil {
+ t.Fatalf("s3Stage: %v", err)
+ }
+ if res.Bucket != "conformance-standalone-9" {
+ t.Errorf("bucket = %q", res.Bucket)
+ }
+ if p.lastExpiry != 2*time.Hour {
+ t.Errorf("presign expiry not applied: got %v, want 2h", p.lastExpiry)
+ }
+ if len(f.uploadedKeys) != 1 || f.uploadedKeys[0] != "vcluster.tar" {
+ t.Errorf("uploadedKeys = %v", f.uploadedKeys)
+ }
+ if got := res.PresignedURLs["vcluster.tar"]; got != "https://presigned.example/GET/vcluster.tar" {
+ t.Errorf("GET url = %q", got)
+ }
+ if got := res.PresignedURLs["results.tar.gz"]; got != "https://presigned.example/PUT/results.tar.gz" {
+ t.Errorf("PUT url = %q", got)
+ }
+}
+
+func TestS3Stage_MissingUploadFileErrors(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ cfg := S3StageConfig{
+ Region: "us-west-2",
+ RunID: "r",
+ Bucket: "b",
+ Uploads: []uploadSpec{{Local: "/does/not/exist", Key: "k"}},
+ }
+ if _, err := s3Stage(context.Background(), newTestLogger(), f, &fakePresigner{}, cfg); err == nil {
+ t.Fatal("expected error for missing upload file")
+ }
+}
+
+func TestS3Download_NoOpWhenMissing(t *testing.T) {
+ f := &fakeS3{headObjectErr: &s3types.NotFound{}}
+ dest := filepath.Join(t.TempDir(), "results.tar.gz")
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: dest}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("expected no-op nil, got %v", err)
+ }
+ if f.called("GetObject") != 0 {
+ t.Errorf("did not expect GetObject when object missing")
+ }
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Errorf("dest should not exist when object missing")
+ }
+}
+
+func TestS3Download_WritesFile(t *testing.T) {
+ f := &fakeS3{getObjectBody: "result-archive"}
+ dest := filepath.Join(t.TempDir(), "results.tar.gz")
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: dest}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("s3Download: %v", err)
+ }
+ got, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "result-archive" {
+ t.Errorf("dest content = %q", string(got))
+ }
+}
+
+func TestS3Cleanup_DirectDelete(t *testing.T) {
+ f := &fakeS3{listPages: [][]s3types.Object{{{Key: aws.String("vcluster.tar")}, {Key: aws.String("results.tar.gz")}}}}
+ cfg := S3CleanupConfig{Region: "us-west-2", Bucket: "my-bucket", SkipSweep: true}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("s3Cleanup: %v", err)
+ }
+ if len(f.deletedKeys) != 2 {
+ t.Errorf("expected 2 objects deleted, got %v", f.deletedKeys)
+ }
+ if len(f.deletedBuckets) != 1 || f.deletedBuckets[0] != "my-bucket" {
+ t.Errorf("expected my-bucket deleted, got %v", f.deletedBuckets)
+ }
+ if f.called("ListBuckets") != 0 {
+ t.Errorf("SkipSweep should prevent ListBuckets")
+ }
+}
+
+func TestS3Cleanup_SweepMatchesTagOnly(t *testing.T) {
+ f := &fakeS3{
+ buckets: []s3types.Bucket{
+ {Name: aws.String("conformance-standalone-match")},
+ {Name: aws.String("conformance-standalone-other")},
+ {Name: aws.String("unrelated-bucket")},
+ },
+ bucketTags: map[string][]s3types.Tag{
+ "conformance-standalone-match": {{Key: aws.String("RunID"), Value: aws.String("run-42")}},
+ "conformance-standalone-other": {{Key: aws.String("RunID"), Value: aws.String("run-99")}},
+ },
+ }
+ cfg := S3CleanupConfig{Region: "us-west-2", RunID: "run-42", BucketPrefix: "conformance-standalone-"}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("s3Cleanup: %v", err)
+ }
+ if len(f.deletedBuckets) != 1 || f.deletedBuckets[0] != "conformance-standalone-match" {
+ t.Errorf("expected only the tag-matching bucket deleted, got %v", f.deletedBuckets)
+ }
+}
+
+func TestS3Cleanup_BestEffortByDefault(t *testing.T) {
+ // A cleanup with neither bucket nor sweep inputs is a no-op and must not error.
+ f := &fakeS3{}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, S3CleanupConfig{Region: "us-west-2"}); err != nil {
+ t.Fatalf("empty cleanup should be a no-op, got %v", err)
+ }
+}
+
+func TestEmitS3StageOutput_GitHubOutput(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "GITHUB_OUTPUT")
+ res := S3StageResult{
+ Bucket: "my-bucket",
+ PresignedURLs: map[string]string{"vcluster.tar": "https://x/GET", "results.tar.gz": "https://x/PUT"},
+ }
+ var emitErr error
+ stdout := captureStdout(t, func() {
+ emitErr = emitS3StageOutput(newTestLogger(), path, "github-output", res)
+ })
+ if emitErr != nil {
+ t.Fatalf("emit: %v", emitErr)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(data)
+ if !strings.Contains(out, "bucket=my-bucket\n") {
+ t.Errorf("missing bucket line: %q", out)
+ }
+ if !strings.Contains(out, `"vcluster.tar":"https://x/GET"`) || !strings.Contains(out, `"results.tar.gz":"https://x/PUT"`) {
+ t.Errorf("presigned_urls JSON malformed: %q", out)
+ }
+ // The github-output branch must mask every presigned URL (they are write-capable secrets).
+ if !strings.Contains(stdout, "::add-mask::https://x/GET") || !strings.Contains(stdout, "::add-mask::https://x/PUT") {
+ t.Errorf("expected an ::add-mask:: line per presigned URL on stdout, got %q", stdout)
+ }
+}
+
+func TestIsNotFound(t *testing.T) {
+ if !isNotFound(&s3types.NotFound{}) {
+ t.Error("NotFound type should be recognized")
+ }
+ if isNotFound(errors.New("some other error")) {
+ t.Error("generic error must not be treated as NotFound")
+ }
+}
+
+// A non-404 HeadBucket error (e.g. 403) must surface, not be treated as "missing".
+func TestEnsureBucket_Head403DoesNotCreate(t *testing.T) {
+ f := &fakeS3{headBucketErr: &smithy.GenericAPIError{Code: "Forbidden", Message: "403"}}
+ err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-west-2", "r")
+ if err == nil {
+ t.Fatal("expected error on 403 HeadBucket, got nil")
+ }
+ if f.called("CreateBucket") != 0 {
+ t.Errorf("must not CreateBucket on a non-404 HeadBucket error, calls=%v", f.calls)
+ }
+}
+
+// Per-object DeleteObjects failures (returned in a 200 response) must fail the delete.
+func TestDeleteObjectPage_PerObjectErrorsSurfaced(t *testing.T) {
+ f := &fakeS3{deleteObjectErrs: []s3types.Error{
+ {Key: aws.String("locked.tar"), Code: aws.String("AccessDenied")},
+ }}
+ err := deleteObjectPage(context.Background(), f, "b", []s3types.Object{{Key: aws.String("locked.tar")}})
+ if err == nil {
+ t.Fatal("expected error when DeleteObjects reports per-object failures")
+ }
+ if !strings.Contains(err.Error(), "locked.tar") || !strings.Contains(err.Error(), "AccessDenied") {
+ t.Errorf("error should name the failed key and code, got %v", err)
+ }
+}
+
+// A multi-page object listing must be fully paginated before the bucket is deleted.
+func TestEmptyAndDeleteBucket_Paginates(t *testing.T) {
+ f := &fakeS3{listPages: [][]s3types.Object{
+ {{Key: aws.String("a")}, {Key: aws.String("b")}},
+ {{Key: aws.String("c")}},
+ }}
+ if err := emptyAndDeleteBucket(context.Background(), newTestLogger(), f, "b"); err != nil {
+ t.Fatalf("emptyAndDeleteBucket: %v", err)
+ }
+ if f.called("ListObjectsV2") != 2 {
+ t.Errorf("expected 2 list pages walked, calls=%v", f.calls)
+ }
+ if len(f.deletedKeys) != 3 {
+ t.Errorf("expected all 3 objects across pages deleted, got %v", f.deletedKeys)
+ }
+ if len(f.deletedBuckets) != 1 {
+ t.Errorf("expected bucket deleted after emptying, got %v", f.deletedBuckets)
+ }
+}
+
+// With -strict, a delete error must propagate out of s3Cleanup.
+func TestS3Cleanup_StrictPropagatesError(t *testing.T) {
+ f := &fakeS3{deleteBucketErr: errors.New("boom")}
+ cfg := S3CleanupConfig{Region: "us-west-2", Bucket: "b", SkipSweep: true, Strict: true}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected strict cleanup to propagate the delete error")
+ }
+}
+
+// Without -strict, the same delete error is swallowed (best-effort).
+func TestS3Cleanup_BestEffortSwallowsError(t *testing.T) {
+ f := &fakeS3{deleteBucketErr: errors.New("boom")}
+ cfg := S3CleanupConfig{Region: "us-west-2", Bucket: "b", SkipSweep: true} // Strict=false
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("best-effort cleanup must not fail the step, got %v", err)
+ }
+}
+
+// A sweep-path delete error must also propagate under -strict.
+func TestS3Cleanup_StrictPropagatesSweepError(t *testing.T) {
+ f := &fakeS3{
+ buckets: []s3types.Bucket{{Name: aws.String("conformance-standalone-match")}},
+ bucketTags: map[string][]s3types.Tag{"conformance-standalone-match": {{Key: aws.String("RunID"), Value: aws.String("run-42")}}},
+ deleteBucketErr: errors.New("sweep boom"),
+ }
+ cfg := S3CleanupConfig{Region: "us-west-2", RunID: "run-42", BucketPrefix: "conformance-standalone-", Strict: true}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected strict sweep error to propagate")
+ }
+}
+
+// A presign error must abort s3Stage.
+func TestS3Stage_PresignErrorPropagates(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ p := &fakePresigner{putErr: errors.New("presign failed")}
+ cfg := S3StageConfig{
+ Region: "us-west-2",
+ RunID: "r",
+ Bucket: "b",
+ Presigns: []presignSpec{{Key: "results.tar.gz", Method: "put"}},
+ }
+ if _, err := s3Stage(context.Background(), newTestLogger(), f, p, cfg); err == nil {
+ t.Fatal("expected presign error to propagate")
+ }
+}
+
+// presignLifetimeWarning fires only when the session expires before the presign window closes.
+func TestPresignLifetimeWarning(t *testing.T) {
+ now := time.Unix(1_000_000, 0)
+
+ // Non-expiring (static) creds: never warn.
+ if _, ok := presignLifetimeWarning(aws.Credentials{CanExpire: false}, 4*time.Hour, now); ok {
+ t.Error("static credentials should not produce a warning")
+ }
+ // Session outlives the window: no warning.
+ longCreds := aws.Credentials{CanExpire: true, Expires: now.Add(5 * time.Hour)}
+ if _, ok := presignLifetimeWarning(longCreds, 4*time.Hour, now); ok {
+ t.Error("session longer than presign window should not warn")
+ }
+ // Session shorter than the window (the OIDC 1h vs 4h case): must warn.
+ shortCreds := aws.Credentials{CanExpire: true, Expires: now.Add(1 * time.Hour)}
+ msg, ok := presignLifetimeWarning(shortCreds, 4*time.Hour, now)
+ if !ok {
+ t.Fatal("expected a warning when session is shorter than the presign window")
+ }
+ if !strings.Contains(msg, "role-duration-seconds") {
+ t.Errorf("warning should point at the fix, got %q", msg)
+ }
+}
+
+// Presigned URLs must be registered with ::add-mask:: so the runner scrubs them from logs.
+func TestMaskPresignedURLs(t *testing.T) {
+ var b strings.Builder
+ maskPresignedURLs(&b, map[string]string{
+ "results.tar.gz": "https://presigned.example/PUT/results.tar.gz?sig=secret",
+ "empty": "",
+ })
+ out := b.String()
+ if !strings.Contains(out, "::add-mask::https://presigned.example/PUT/results.tar.gz?sig=secret") {
+ t.Errorf("expected add-mask command for the PUT URL, got %q", out)
+ }
+ if strings.Contains(out, "::add-mask::\n") {
+ t.Errorf("empty URL should not be masked, got %q", out)
+ }
+}
+
+// A create that reports the bucket is already ours is idempotent (no error, still tagged);
+// any other create error surfaces.
+func TestEnsureBucket_AlreadyOwnedIsIdempotent(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}, createBucketErr: &s3types.BucketAlreadyOwnedByYou{}}
+ if err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-west-2", "r"); err != nil {
+ t.Fatalf("already-owned bucket must be treated as success: %v", err)
+ }
+ if f.called("PutBucketTagging") != 1 {
+ t.Errorf("expected the bucket to still be tagged, calls=%v", f.calls)
+ }
+}
+
+func TestEnsureBucket_CreateErrorSurfaces(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}, createBucketErr: errors.New("quota exceeded")}
+ err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-west-2", "r")
+ if err == nil {
+ t.Fatal("expected a generic CreateBucket error to surface")
+ }
+ if f.called("PutBucketTagging") != 0 {
+ t.Errorf("must not tag a bucket that failed to create, calls=%v", f.calls)
+ }
+}
+
+// The delete-objects loop must batch in chunks of 1000 (the S3 API limit).
+func TestDeleteObjectPage_BatchesOver1000(t *testing.T) {
+ objs := make([]s3types.Object, 1001)
+ for i := range objs {
+ objs[i] = s3types.Object{Key: aws.String(fmt.Sprintf("k%d", i))}
+ }
+ f := &fakeS3{}
+ if err := deleteObjectPage(context.Background(), f, "b", objs); err != nil {
+ t.Fatalf("deleteObjectPage: %v", err)
+ }
+ if f.called("DeleteObjects") != 2 {
+ t.Errorf("expected 2 batches (1000 + 1), got %d calls", f.called("DeleteObjects"))
+ }
+ if len(f.deletedKeys) != 1001 {
+ t.Errorf("expected all 1001 keys deleted, got %d", len(f.deletedKeys))
+ }
+}
+
+// The json format writes a valid, round-trippable document.
+func TestEmitS3StageOutput_JSONFormat(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "out.json")
+ res := S3StageResult{Bucket: "b", PresignedURLs: map[string]string{"k": "https://u"}}
+ if err := emitS3StageOutput(newTestLogger(), path, "json", res); err != nil {
+ t.Fatalf("emit: %v", err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got S3StageResult
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("not valid json: %v (%s)", err, data)
+ }
+ if got.Bucket != "b" || got.PresignedURLs["k"] != "https://u" {
+ t.Errorf("json round-trip mismatch: %+v", got)
+ }
+}
+
+// auto resolves to github-output for a file destination and to json for stdout.
+func TestEmitS3StageOutput_AutoResolves(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GITHUB_OUTPUT")
+ if err := emitS3StageOutput(newTestLogger(), path, "auto", S3StageResult{Bucket: "b", PresignedURLs: map[string]string{}}); err != nil {
+ t.Fatalf("emit: %v", err)
+ }
+ data, _ := os.ReadFile(path)
+ if !strings.Contains(string(data), "bucket=b\n") {
+ t.Errorf("auto + file destination should emit github-output, got %q", data)
+ }
+
+ out := captureStdout(t, func() {
+ _ = emitS3StageOutput(newTestLogger(), "", "auto", S3StageResult{Bucket: "b2", PresignedURLs: map[string]string{}})
+ })
+ if !strings.Contains(out, `"bucket": "b2"`) {
+ t.Errorf("auto + empty destination should emit json to stdout, got %q", out)
+ }
+}
+
+func TestEmitS3StageOutput_UnknownFormat(t *testing.T) {
+ if err := emitS3StageOutput(newTestLogger(), "", "xml", S3StageResult{}); err == nil {
+ t.Fatal("expected an error for an unknown output format")
+ }
+}
+
+// The always() teardown shape: a direct bucket delete AND a tag-based sweep in one call.
+func TestS3Cleanup_DirectDeleteAndSweep(t *testing.T) {
+ f := &fakeS3{
+ listPages: [][]s3types.Object{{{Key: aws.String("x")}}},
+ buckets: []s3types.Bucket{{Name: aws.String("conformance-standalone-orphan")}},
+ bucketTags: map[string][]s3types.Tag{"conformance-standalone-orphan": {{Key: aws.String("RunID"), Value: aws.String("run-7")}}},
+ }
+ cfg := S3CleanupConfig{Region: "us-west-2", Bucket: "my-bucket", RunID: "run-7", BucketPrefix: "conformance-standalone-"}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("s3Cleanup: %v", err)
+ }
+ if !slices.Contains(f.deletedBuckets, "my-bucket") || !slices.Contains(f.deletedBuckets, "conformance-standalone-orphan") {
+ t.Errorf("expected both the direct bucket and the tagged orphan deleted, got %v", f.deletedBuckets)
+ }
+}
+
+// A -strict direct-delete error must propagate, and the sweep must still run (no early return).
+func TestS3Cleanup_StrictDirectErrorStillRunsSweep(t *testing.T) {
+ f := &fakeS3{
+ deleteBucketErr: errors.New("boom"),
+ buckets: []s3types.Bucket{{Name: aws.String("conformance-standalone-orphan")}},
+ bucketTags: map[string][]s3types.Tag{"conformance-standalone-orphan": {{Key: aws.String("RunID"), Value: aws.String("run-7")}}},
+ }
+ cfg := S3CleanupConfig{Region: "us-west-2", Bucket: "my-bucket", RunID: "run-7", BucketPrefix: "conformance-standalone-", Strict: true}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected strict cleanup to propagate the direct-delete error")
+ }
+ if f.called("ListBuckets") == 0 {
+ t.Error("sweep must still run after a direct-delete error (no early return)")
+ }
+}
+
+// A sweep requested without a run-id is skipped (best-effort) and errors under -strict.
+func TestS3Cleanup_SweepWithoutRunID(t *testing.T) {
+ cfg := S3CleanupConfig{Region: "us-west-2", BucketPrefix: "conformance-standalone-"} // RunID empty
+ f := &fakeS3{buckets: []s3types.Bucket{{Name: aws.String("conformance-standalone-x")}}}
+ if err := s3Cleanup(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("non-strict must not error: %v", err)
+ }
+ if f.called("ListBuckets") != 0 {
+ t.Error("sweep must be skipped when run-id is empty")
+ }
+
+ cfg.Strict = true
+ if err := s3Cleanup(context.Background(), newTestLogger(), &fakeS3{}, cfg); err == nil {
+ t.Fatal("expected strict cleanup to error when a sweep is requested without run-id")
+ }
+}
+
+// A non-404 HeadObject error (e.g. 403) must surface, not be treated as "object absent".
+func TestS3Download_Non404ErrorSurfaces(t *testing.T) {
+ f := &fakeS3{headObjectErr: &smithy.GenericAPIError{Code: "Forbidden", Message: "403"}}
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: filepath.Join(t.TempDir(), "r.tar.gz")}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected a non-404 HeadObject error to surface")
+ }
+ if f.called("GetObject") != 0 {
+ t.Errorf("must not GetObject when HeadObject failed with a non-404 error")
+ }
+}
+
+// A GetObject failure after a successful HeadObject must surface (before any file is created).
+func TestS3Download_GetObjectErrorSurfaces(t *testing.T) {
+ f := &fakeS3{getObjectErr: errors.New("connection reset")}
+ dest := filepath.Join(t.TempDir(), "r.tar.gz")
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: dest}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected a GetObject error to surface")
+ }
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Errorf("dest must not exist when the download failed")
+ }
+}
+
+// A body that fails mid-stream must surface AND not leave a truncated file behind.
+func TestS3Download_CopyErrorRemovesPartialFile(t *testing.T) {
+ f := &fakeS3{getObjectReadErr: errors.New("stream reset mid-copy")}
+ dest := filepath.Join(t.TempDir(), "r.tar.gz")
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: dest}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err == nil {
+ t.Fatal("expected a mid-stream copy error to surface")
+ }
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Errorf("a truncated dest file must be removed on copy failure")
+ }
+}
+
+// The sweep must paginate ListBuckets and reap tagged orphans on every page.
+func TestSweepBuckets_Paginates(t *testing.T) {
+ f := &fakeS3{
+ bucketPages: [][]s3types.Bucket{
+ {{Name: aws.String("conformance-standalone-a")}},
+ {{Name: aws.String("conformance-standalone-b")}},
+ },
+ bucketTags: map[string][]s3types.Tag{
+ "conformance-standalone-a": {{Key: aws.String("RunID"), Value: aws.String("run-9")}},
+ "conformance-standalone-b": {{Key: aws.String("RunID"), Value: aws.String("run-9")}},
+ },
+ }
+ if err := sweepBuckets(context.Background(), newTestLogger(), f, "us-west-2", "conformance-standalone-", "run-9"); err != nil {
+ t.Fatalf("sweepBuckets: %v", err)
+ }
+ if f.called("ListBuckets") != 2 {
+ t.Errorf("expected 2 ListBuckets pages walked, got %d", f.called("ListBuckets"))
+ }
+ if !slices.Contains(f.deletedBuckets, "conformance-standalone-a") || !slices.Contains(f.deletedBuckets, "conformance-standalone-b") {
+ t.Errorf("expected the tagged orphan on each page swept, got %v", f.deletedBuckets)
+ }
+}
+
+// A ListBuckets error must surface out of the sweep.
+func TestSweepBuckets_ListErrorSurfaces(t *testing.T) {
+ f := &fakeS3{listBucketsErr: errors.New("throttled")}
+ if err := sweepBuckets(context.Background(), newTestLogger(), f, "us-west-2", "conformance-standalone-", "run-9"); err == nil {
+ t.Fatal("expected a ListBuckets error to surface")
+ }
+}
+
+// A transport-level DeleteObjects error must surface (distinct from per-object errors).
+func TestDeleteObjectPage_TransportErrorSurfaces(t *testing.T) {
+ f := &fakeS3{deleteObjectsErr: errors.New("500 InternalError")}
+ err := deleteObjectPage(context.Background(), f, "b", []s3types.Object{{Key: aws.String("k")}})
+ if err == nil {
+ t.Fatal("expected a transport DeleteObjects error to surface")
+ }
+}
+
+// A GET presign failure must abort s3Stage (symmetric with the PUT case).
+func TestS3Stage_PresignGetErrorPropagates(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ p := &fakePresigner{getErr: errors.New("presign get failed")}
+ cfg := S3StageConfig{
+ Region: "us-west-2",
+ RunID: "r",
+ Bucket: "b",
+ Presigns: []presignSpec{{Key: "vcluster.tar", Method: "get"}},
+ }
+ if _, err := s3Stage(context.Background(), newTestLogger(), f, p, cfg); err == nil {
+ t.Fatal("expected a GET presign error to propagate")
+ }
+}
+
+// With no -expires-in, s3Stage must presign using the default lifetime, not zero.
+func TestS3Stage_DefaultExpiryWhenUnset(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}}
+ p := &fakePresigner{}
+ cfg := S3StageConfig{
+ Region: "us-west-2",
+ RunID: "r",
+ Bucket: "b",
+ Presigns: []presignSpec{{Key: "results.tar.gz", Method: "put"}},
+ // ExpiresIn intentionally left zero to exercise the fallback.
+ }
+ if _, err := s3Stage(context.Background(), newTestLogger(), f, p, cfg); err != nil {
+ t.Fatalf("s3Stage: %v", err)
+ }
+ if p.lastExpiry != defaultPresignExpiry {
+ t.Errorf("zero ExpiresIn should fall back to %v, got %v", defaultPresignExpiry, p.lastExpiry)
+ }
+}
+
+// A PutBucketTagging failure must surface (an un-tagged bucket would escape the sweep).
+func TestEnsureBucket_TagErrorSurfaces(t *testing.T) {
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}, putTaggingErr: errors.New("access denied")}
+ if err := ensureBucket(context.Background(), newTestLogger(), f, "b", "us-west-2", "r"); err == nil {
+ t.Fatal("expected a PutBucketTagging error to surface")
+ }
+}
+
+// A PutObject failure must abort s3Stage.
+func TestS3Stage_UploadErrorSurfaces(t *testing.T) {
+ local := filepath.Join(t.TempDir(), "artifact")
+ if err := os.WriteFile(local, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeS3{headBucketErr: &s3types.NotFound{}, putObjectErr: errors.New("upload failed")}
+ cfg := S3StageConfig{Region: "us-west-2", RunID: "r", Bucket: "b", Uploads: []uploadSpec{{Local: local, Key: "k"}}}
+ if _, err := s3Stage(context.Background(), newTestLogger(), f, &fakePresigner{}, cfg); err == nil {
+ t.Fatal("expected a PutObject error to surface")
+ }
+}
+
+// A non-NoSuchTagSet tag-read error during the sweep must skip the bucket without deleting it.
+func TestSweepBuckets_TagReadErrorSkips(t *testing.T) {
+ f := &fakeS3{
+ buckets: []s3types.Bucket{{Name: aws.String("conformance-standalone-x")}},
+ getBucketTaggingErr: errors.New("throttled"),
+ }
+ if err := sweepBuckets(context.Background(), newTestLogger(), f, "us-west-2", "conformance-standalone-", "run-1"); err != nil {
+ t.Fatalf("best-effort sweep should not error on a tag-read failure: %v", err)
+ }
+ if len(f.deletedBuckets) != 0 {
+ t.Errorf("must not delete a bucket whose tags could not be read, got %v", f.deletedBuckets)
+ }
+}
+
+// A ListObjectsV2 error (not NoSuchBucket) must surface from emptyAndDeleteBucket.
+func TestEmptyAndDeleteBucket_ListErrorSurfaces(t *testing.T) {
+ f := &fakeS3{listObjectsErr: errors.New("throttled")}
+ if err := emptyAndDeleteBucket(context.Background(), newTestLogger(), f, "b"); err == nil {
+ t.Fatal("expected a ListObjectsV2 error to surface")
+ }
+ if f.called("DeleteBucket") != 0 {
+ t.Error("must not delete the bucket when listing its objects failed")
+ }
+}
+
+// s3Download creates the dest's parent directory if it does not exist.
+func TestS3Download_CreatesNestedDest(t *testing.T) {
+ f := &fakeS3{getObjectBody: "archive"}
+ dest := filepath.Join(t.TempDir(), "nested", "sub", "results.tar.gz")
+ cfg := S3DownloadConfig{Region: "us-west-2", Bucket: "b", Key: "results.tar.gz", Dest: dest}
+ if err := s3Download(context.Background(), newTestLogger(), f, cfg); err != nil {
+ t.Fatalf("s3Download: %v", err)
+ }
+ got, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatalf("nested dest not written: %v", err)
+ }
+ if string(got) != "archive" {
+ t.Errorf("dest content = %q", got)
+ }
+}
+
+// A NoSuchBucket during listing means the bucket is already gone: a no-op, not an error.
+func TestEmptyAndDeleteBucket_AlreadyGoneIsNoOp(t *testing.T) {
+ f := &fakeS3{listObjectsErr: &s3types.NoSuchBucket{}}
+ if err := emptyAndDeleteBucket(context.Background(), newTestLogger(), f, "b"); err != nil {
+ t.Fatalf("an already-gone bucket must be a no-op, got %v", err)
+ }
+ if f.called("DeleteBucket") != 0 {
+ t.Error("must not call DeleteBucket for an already-gone bucket")
+ }
+}
diff --git a/.github/actions/aws-test-infra/test/parse-list.bats b/.github/actions/aws-test-infra/test/parse-list.bats
new file mode 100644
index 0000000..c0a9fad
--- /dev/null
+++ b/.github/actions/aws-test-infra/test/parse-list.bats
@@ -0,0 +1,44 @@
+#!/usr/bin/env bats
+# Tests for src/parse-list.sh — the newline-list → repeatable-flag parser the
+# s3-stage step uses to build -upload / -presign args.
+
+SCRIPT="$BATS_TEST_DIRNAME/../src/parse-list.sh"
+
+@test "emits one flag per line; values containing '=' are preserved" {
+ run bash "$SCRIPT" -upload <<< $'vcluster_image=vcluster.tar\n/tmp/kind-node.tar.gz=kind-node.tar.gz'
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 2 ]
+ [ "${lines[0]}" = "-upload=vcluster_image=vcluster.tar" ]
+ [ "${lines[1]}" = "-upload=/tmp/kind-node.tar.gz=kind-node.tar.gz" ]
+}
+
+@test "strips trailing CR and surrounding whitespace, skips blank lines" {
+ run bash "$SCRIPT" -presign <<< $' vcluster.tar:get \r\n\r\n\tresults.tar.gz:put\r'
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 2 ]
+ [ "${lines[0]}" = "-presign=vcluster.tar:get" ]
+ [ "${lines[1]}" = "-presign=results.tar.gz:put" ]
+}
+
+@test "preserves spaces inside a value" {
+ run bash "$SCRIPT" -upload <<< '/tmp/my file.tar=obj.tar'
+ [ "$status" -eq 0 ]
+ [ "${lines[0]}" = "-upload=/tmp/my file.tar=obj.tar" ]
+}
+
+@test "empty input produces no output" {
+ run bash "$SCRIPT" -upload <<< ''
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 0 ]
+}
+
+@test "a whitespace-only list produces no output" {
+ run bash "$SCRIPT" -presign <<< $' \n\t\n'
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 0 ]
+}
+
+@test "fails when no flag name is given" {
+ run bash "$SCRIPT" <<< 'x'
+ [ "$status" -ne 0 ]
+}
diff --git a/.github/workflows/test-aws-test-infra.yaml b/.github/workflows/test-aws-test-infra.yaml
index 3a73932..ac9349c 100644
--- a/.github/workflows/test-aws-test-infra.yaml
+++ b/.github/workflows/test-aws-test-infra.yaml
@@ -27,3 +27,16 @@ jobs:
- name: Verify binary builds
working-directory: .github/actions/aws-test-infra/src
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /dev/null .
+
+ bats:
+ name: Run bats tests
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0
+ with:
+ tests: .github/actions/aws-test-infra/test
diff --git a/Makefile b/Makefile
index de3e301..2151db8 100644
--- a/Makefile
+++ b/Makefile
@@ -112,6 +112,7 @@ test-linear-release-sync: ## run linear-release-sync unit tests
test-aws-test-infra: ## run aws-test-infra unit tests
cd $(ACTIONS_DIR)/aws-test-infra/src && go test -v -race -count=1 ./...
+ bats $(ACTIONS_DIR)/aws-test-infra/test
test-cleanup-head-charts: ## run cleanup-head-charts bats tests
bats $(SCRIPTS_DIR)/cleanup-head-charts/test/cleanup-head-charts.bats