From 909bb2ae9057276fc26e19e78fda270b18ec8ede Mon Sep 17 00:00:00 2001 From: anvesh-loft Date: Mon, 13 Jul 2026 18:31:45 -0300 Subject: [PATCH] feat(aws-test-infra): add s3-stage, s3-download, s3-cleanup subcommands Add the S3 half of the AWS test-infra lifecycle to the shared Go binary so the vcluster-pro conformance workflows can drop their duplicated S3 bucket-setup + presigned-URL bash (including the boto3 PUT-presign shim). Additive: provision and cleanup are unchanged. Subcommands: - s3-stage: ensure+tag a run-scoped bucket, upload files, emit presigned URLs as a JSON map (bucket, presigned-urls outputs). GET and PUT URLs are signed natively via aws-sdk-go-v2 (no boto3). URLs are registered with ::add-mask::, and the step warns if -expires-in outlives the STS session. - s3-download: fetch an object to a local path; a missing object is a no-op and a failed copy does not leave a truncated file behind. - s3-cleanup: empty+delete a bucket with a region-scoped, paginated, tag-based sweep fallback; best-effort by default, -strict to fail loudly. The s3-stage upload/presign list parsing lives in src/parse-list.sh (CRLF-safe) with a bats suite, wired into the Makefile and the test workflow. Go unit tests use fake S3 + presigner clients and cover bucket create/idempotency, tagging, region+pagination sweeps, DeleteObjects batching past 1000 keys, output formats + URL masking, and the download error paths. Closes ENGQA-1271 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/aws-test-infra/README.md | 173 +++- .github/actions/aws-test-infra/action.yml | 92 ++- .github/actions/aws-test-infra/src/go.mod | 18 +- .github/actions/aws-test-infra/src/go.sum | 36 +- .github/actions/aws-test-infra/src/main.go | 13 +- .github/actions/aws-test-infra/src/output.go | 35 +- .../actions/aws-test-infra/src/parse-list.sh | 21 + .github/actions/aws-test-infra/src/s3.go | 655 +++++++++++++++ .../aws-test-infra/src/s3_mocks_test.go | 247 ++++++ .github/actions/aws-test-infra/src/s3_test.go | 762 ++++++++++++++++++ .../aws-test-infra/test/parse-list.bats | 44 + .github/workflows/test-aws-test-infra.yaml | 13 + Makefile | 1 + 13 files changed, 2034 insertions(+), 76 deletions(-) create mode 100755 .github/actions/aws-test-infra/src/parse-list.sh create mode 100644 .github/actions/aws-test-infra/src/s3.go create mode 100644 .github/actions/aws-test-infra/src/s3_mocks_test.go create mode 100644 .github/actions/aws-test-infra/src/s3_test.go create mode 100644 .github/actions/aws-test-infra/test/parse-list.bats 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