diff --git a/aws/ami/README.md b/aws/ami/README.md index a920d8b..69e5475 100644 --- a/aws/ami/README.md +++ b/aws/ami/README.md @@ -2,8 +2,8 @@ ## 문서 범위 -이 문서는 `aws/ami` 디렉토리에 구현된 AMI 빌드와 검증의 현재 설정 및 실행 동작을 설명합니다. -AWS Marketplace 판매자 등록, 계정 간 AMI 공유, 리전 간 AMI 복사, 제품 생성은 이 디렉토리에서 자동화하지 않습니다. +이 문서는 `aws/ami` 디렉토리에 구현된 AMI 빌드, 검증 및 Sales 계정 승격의 현재 설정과 실행 동작을 설명합니다. +AWS Marketplace 판매자 등록과 제품 생성은 이 디렉토리에서 자동화하지 않습니다. ## 현재 실행 컨텍스트 @@ -34,7 +34,7 @@ Packer는 Session Manager 플러그인을 사용해 빌드 및 검증 인스턴 ## 파일과 호출 관계 -사용자가 직접 실행하는 명령은 다음 네 개입니다. +사용자가 직접 실행하는 명령은 다음 다섯 개입니다. | 파일 | 현재 동작 | |------|-----------| @@ -42,6 +42,7 @@ Packer는 Session Manager 플러그인을 사용해 빌드 및 검증 인스턴 | `ami-verify.sh` | `ami-validate.sh`를 실행한 후 해당 AMI로 검증용 EC2 인스턴스를 기동 | | `ami-validate.sh` | 현재 계정이 소유한 AMI와 EBS 스냅샷의 구조적 속성을 AWS API로 검사 | | `ami-ls.sh` | 현재 AWS CLI 리전에서 소유자와 이름 조건에 맞는 AMI 목록을 출력 | +| `ami-promote-to-sales.sh` | 검증된 QPE AMI를 Sales 계정의 `us-east-1` 소유 AMI로 복사하고 임시 공유 권한을 회수 | 다음 파일은 위 명령이 내부에서 사용합니다. @@ -67,6 +68,12 @@ ami-verify.sh ├── ami-validate.sh └── ami-verify.pkr.hcl └── validate-image-runtime.sh + +ami-promote-to-sales.sh +├── QPE AMI와 모든 backing snapshot을 Sales에 임시 공유 +├── Sales 계정의 us-east-1 소유 AMI로 비암호화 복사 +├── ami-validate.sh --marketplace-source +└── QPE AMI와 snapshot의 Sales 공유 권한 회수 ``` ## AMI 빌드 @@ -213,8 +220,8 @@ AMI_REGION=us-east-1 \ ``` 이 옵션은 AMI를 판매자 계정으로 공유하거나 복사하지 않습니다. -판매자 계정 ID와 프로파일은 저장소에 설정되어 있지 않습니다. 실행 시점의 AWS 자격 증명이 해당 AMI를 소유한 계정이어야 합니다. +Sales 계정 선택과 복사는 별도의 `ami-promote-to-sales.sh`가 고정된 계정과 프로파일로 수행합니다. Marketplace 제출, 스캔 실행 및 제품 등록도 이 옵션의 동작 범위에 포함되지 않습니다. ## AMI 인스턴스 검증 @@ -243,6 +250,58 @@ AMI_REGION=ap-northeast-2 \ `setup.v2.sh --verify-installation`은 최초 부팅 완료 marker, systemd 서비스 상태, 컨테이너 엔진, 설치 버전 및 QueryPie 컨테이너 준비 상태를 확인합니다. 검증이 끝나면 `manifest.json`에는 검증 타임스탬프, 검증용 이름 및 리전이 기록됩니다. +## Sales 계정으로 Marketplace AMI 승격 + +`ami-promote-to-sales.sh`는 기능 검증을 통과한 QPE AMI를 Marketplace 제출에 사용할 Sales 소유 AMI로 승격합니다. +임의 계정이나 리전에 배포하는 용도가 아니므로 다음 값은 스크립트에 고정되어 있으며 옵션이나 환경 변수로 변경할 수 없습니다. + +| 항목 | 고정값 | +|------|--------| +| QPE 계정 | `142605707876` | +| QPE AWS CLI 프로파일 | `qpe` | +| 소스 리전 | `ap-northeast-2` | +| Sales 계정 | `883790944456` | +| Sales AWS CLI 프로파일 | `sales` | +| 대상 리전 | `us-east-1` | +| 복사 암호화 | 비활성화 | + +AWS CLI의 `qpe`, `sales` 프로파일은 저장소 밖에서 미리 설정하고 로그인해야 합니다. +스크립트는 두 프로파일의 호출 계정 ID를 검사하며 표의 계정과 다르면 어떤 공유 권한도 변경하지 않고 중단합니다. + +승격 전에 QPE 자격 증명으로 기능 검증을 완료해야 합니다. + +```bash +AWS_PROFILE=qpe \ +AMI_REGION=ap-northeast-2 \ + ./ami-verify.sh +``` + +검증에 성공한 동일 AMI ID만 인자로 전달합니다. + +```bash +./ami-promote-to-sales.sh +``` + +스크립트는 다음 순서로 실행됩니다. + +1. `qpe`, `sales` 프로파일의 호출 계정 ID와 Sales `us-east-1`의 EBS 기본 암호화 비활성화를 확인합니다. +2. QPE AMI에 `ami-validate.sh`를 실행하고 모든 backing snapshot ID를 조회합니다. +3. QPE AMI의 launch permission과 모든 snapshot의 create-volume permission에 Sales 계정을 임시로 추가합니다. +4. Sales 자격 증명으로 AMI를 `us-east-1`에 비암호화 상태로 복사하고 AMI와 snapshot에 출처 태그를 적용합니다. +5. Sales AMI가 `available`이 될 때까지 최대 1시간 기다린 뒤 `ami-validate.sh --marketplace-source`를 실행합니다. +6. 검증에 성공하면 QPE AMI와 모든 backing snapshot에서 Sales 공유 권한을 제거합니다. + +동일 QPE AMI에서 생성된 Sales AMI는 `SourceAMI`, `SourceAccount`, `SourceRegion` 태그로 식별합니다. +같은 릴리스 버전을 다시 빌드해도 기존 Sales AMI 이름과 충돌하지 않도록 Sales AMI 이름에는 소스 AMI ID가 자동으로 추가됩니다. +스크립트를 다시 실행했을 때 해당 Sales AMI가 이미 존재하면 새 복사를 만들지 않고 기존 AMI를 검증한 뒤 남은 QPE 공유 권한을 회수합니다. + +`CopyImage` 호출 전에 실패하면 이전 실행에서 남은 권한을 포함한 임시 Sales 공유 권한 전체를 회수하고 결과를 검증합니다. +권한 회수에 실패하면 같은 AMI ID로 다시 실행할 수 있도록 남은 권한과 재시도 방법을 오류로 출력합니다. +Sales AMI가 이미 `available`인 상태에서 최종 검증이 실패해도 원본 접근은 더 이상 필요하지 않으므로 공유 권한을 회수합니다. +복사가 아직 `pending`인 상태에서 타임아웃 또는 상태 확인 오류가 발생하면 진행 중인 복사를 방해하지 않도록 공유 권한을 유지하며, 같은 AMI ID로 명령을 다시 실행해 검증과 권한 회수를 재개할 수 있습니다. +`CopyImage` 호출 자체가 오류로 끝나도 AWS가 요청을 접수했을 가능성이 있으므로 공유 권한을 유지합니다. +동일 AMI ID로 다시 실행하면 같은 client token과 출처 태그로 기존 요청을 재확인하며, 복사 결과가 terminal 상태이면 `retry-N` 이름과 client token으로 대체 AMI를 자동 생성합니다. + ## 최초 부팅 동작 빌드 중 설치되는 `querypie-first-boot.service`는 Docker와 네트워크가 준비된 뒤 실행됩니다. @@ -291,10 +350,5 @@ AMI와 EBS 스냅샷의 구조적 속성은 `ami-validate.sh`가 별도로 검 다음 작업은 현재 `aws/ami` 스크립트가 수행하지 않습니다. - AWS CLI 프로파일 또는 Access Key 생성 -- 호출자 계정 ID와 IAM ARN이 QPE 작업 계정과 일치하는지 강제 - IAM 정책 생성 또는 사용자 연결 -- 판매자 계정 선택과 자격 증명 전환 -- QPE 빌드 계정에서 판매자 계정으로 AMI와 스냅샷 공유 -- 판매자 계정에서 `us-east-1`로 AMI 복사 -- AMI와 스냅샷 태그 재적용 - AWS Marketplace 제품 생성, 스캔 실행 및 공개 범위 변경 diff --git a/aws/ami/ami-promote-to-sales.sh b/aws/ami/ami-promote-to-sales.sh new file mode 100755 index 0000000..7461e82 --- /dev/null +++ b/aws/ami/ami-promote-to-sales.sh @@ -0,0 +1,587 @@ +#!/usr/bin/env bash +# Promote a verified QPE AMI to a Sales-owned Marketplace source AMI. +# +# Usage: +# ./ami-promote-to-sales.sh + +# The AWS accounts, profiles, and Regions are intentionally fixed for the +# QueryPie Marketplace release workflow. + +SCRIPT_VERSION="26.08.1" + +[[ -n "${ZSH_VERSION:-}" ]] && emulate bash +set -o nounset -o errexit -o errtrace -o pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +readonly BOLD_CYAN="\e[1;36m" +readonly BOLD_YELLOW="\e[1;33m" +readonly BOLD_RED="\e[1;91m" +readonly RESET="\e[0m" + +readonly QPE_ACCOUNT_ID="142605707876" +readonly SALES_ACCOUNT_ID="883790944456" +readonly QPE_PROFILE="qpe" +readonly SALES_PROFILE="sales" +readonly SOURCE_REGION="ap-northeast-2" +readonly DESTINATION_REGION="us-east-1" +readonly COPY_POLL_INTERVAL_SECONDS=15 +readonly COPY_TIMEOUT_SECONDS=3600 + +SOURCE_AMI_ID="" +TARGET_AMI_ID="" +SOURCE_AMI_NAME="" +TARGET_AMI_NAME="" +SOURCE_ARCHITECTURE="" +SOURCE_VERSION="" +SOURCE_BUILD_DATE="" +PROMOTION_ATTEMPT=0 +SHARING_ATTEMPTED=false +COPY_IN_PROGRESS=false +COPY_AVAILABLE=false +COPY_TERMINAL=false +declare -a SOURCE_SNAPSHOT_IDS=() + +export AWS_CLI_AUTO_PROMPT=off +export AWS_PAGER="" + +function log::error() { + printf "%bERROR: %s%b\n" "$BOLD_RED" "$*" "$RESET" 1>&2 +} + +function log::warning() { + printf "%bWARNING: %s%b\n" "$BOLD_YELLOW" "$*" "$RESET" 1>&2 +} + +function log::info() { + printf "%b%s%b\n" "$BOLD_CYAN" "$*" "$RESET" 1>&2 +} + +function log::do() { + local line_no status + line_no=$(caller | awk '{print $1}') + printf "%b+ %s%b\n" "$BOLD_CYAN" "$*" "$RESET" 1>&2 + if "$@"; then + return 0 + else + status=$? + fi + + log::error "Failed to run at line $line_no: $*" + return "$status" +} + +function print_usage_and_exit() { + local code=${1:-0} out=2 + [[ $code -eq 0 ]] && out=1 + cat >&"${out}" < + +Promote a verified QPE AMI to a Sales-owned, unencrypted Marketplace source +AMI in us-east-1. The source and destination accounts, profiles, and Regions +are built into this script and cannot be overridden. + +FIXED SETTINGS: + QPE account/profile/Region ${QPE_ACCOUNT_ID} / ${QPE_PROFILE} / ${SOURCE_REGION} + Sales account/profile/Region ${SALES_ACCOUNT_ID} / ${SALES_PROFILE} / ${DESTINATION_REGION} + +OPTIONS: + -h, --help Show this help message + -V, --version Show the script version + -x, --xtrace Enable Bash execution tracing + +PREREQUISITE: + The source AMI must already have passed ami-verify.sh. + +END_OF_USAGE + exit "$code" +} + +function validate::environment() { + if ! command -v aws >/dev/null 2>&1; then + log::error "AWS CLI is not installed. Please install it to continue." + exit 1 + fi +} + +function validate::ami_id() { + local ami_id=$1 + if [[ ! "$ami_id" =~ ^ami-[0-9a-f]{8}([0-9a-f]{9})?$ ]]; then + log::error "AMI ID must use the format ami- followed by 8 or 17 lowercase hexadecimal characters." + exit 1 + fi +} + +function aws::qpe() { + log::do aws --profile "$QPE_PROFILE" --region "$SOURCE_REGION" "$@" +} + +function aws::sales() { + log::do aws --profile "$SALES_PROFILE" --region "$DESTINATION_REGION" "$@" +} + +function validate::account() { + local profile=$1 expected_account_id=$2 actual_account_id + actual_account_id=$(log::do aws --profile "$profile" sts get-caller-identity \ + --query Account \ + --output text) + + if [[ "$actual_account_id" != "$expected_account_id" ]]; then + log::error "AWS profile '$profile' must use account $expected_account_id, but found $actual_account_id." + exit 1 + fi +} + +function validate::sales_encryption_default() { + local encryption_by_default + encryption_by_default=$(aws::sales ec2 get-ebs-encryption-by-default \ + --query EbsEncryptionByDefault \ + --output text) + + if [[ "$encryption_by_default" != "False" ]]; then + log::error "EBS encryption by default must be disabled in Sales $DESTINATION_REGION." + log::error "Current value: $encryption_by_default" + exit 1 + fi +} + +function source::validate() { + log::do env \ + AWS_PROFILE="$QPE_PROFILE" \ + AMI_REGION="$SOURCE_REGION" \ + "$SCRIPT_DIR/ami-validate.sh" "$SOURCE_AMI_ID" +} + +function source::load_metadata() { + local metadata snapshot_ids + metadata=$(aws::qpe ec2 describe-images \ + --owners self \ + --image-ids "$SOURCE_AMI_ID" \ + --query 'Images[0].[Name,Architecture]' \ + --output text) + IFS=$'\t' read -r SOURCE_AMI_NAME SOURCE_ARCHITECTURE <<<"$metadata" + + if [[ -z "$SOURCE_AMI_NAME" || "$SOURCE_AMI_NAME" == "None" ]]; then + log::error "Could not determine the name of source AMI $SOURCE_AMI_ID." + exit 1 + fi + + SOURCE_VERSION=$(aws::qpe ec2 describe-images \ + --owners self \ + --image-ids "$SOURCE_AMI_ID" \ + --query "Images[0].Tags[?Key=='Version'].Value | [0]" \ + --output text) + SOURCE_BUILD_DATE=$(aws::qpe ec2 describe-images \ + --owners self \ + --image-ids "$SOURCE_AMI_ID" \ + --query "Images[0].Tags[?Key=='BuildDate'].Value | [0]" \ + --output text) + + snapshot_ids=$(aws::qpe ec2 describe-images \ + --owners self \ + --image-ids "$SOURCE_AMI_ID" \ + --query 'Images[0].BlockDeviceMappings[?Ebs.SnapshotId!=`null`].Ebs.SnapshotId' \ + --output text) + # AWS CLI text output separates multiple snapshot IDs with whitespace. + read -r -a SOURCE_SNAPSHOT_IDS <<<"$snapshot_ids" + + if [[ ${#SOURCE_SNAPSHOT_IDS[@]} -eq 0 || "${SOURCE_SNAPSHOT_IDS[0]}" == "None" ]]; then + log::error "AMI $SOURCE_AMI_ID does not have an EBS snapshot." + exit 1 + fi +} + +function permissions::contains_sales_account() { + local permissions=$1 + [[ "$permissions" =~ (^|[[:space:]])${SALES_ACCOUNT_ID}($|[[:space:]]) ]] +} + +function permissions::image_state() { + local permissions + if ! permissions=$(aws::qpe ec2 describe-image-attribute \ + --image-id "$SOURCE_AMI_ID" \ + --attribute launchPermission \ + --query 'LaunchPermissions[].UserId' \ + --output text); then + log::error "Could not inspect launch permissions for AMI $SOURCE_AMI_ID." + return 1 + fi + + if permissions::contains_sales_account "$permissions"; then + echo "present" + else + echo "absent" + fi +} + +function permissions::snapshot_state() { + local snapshot_id=$1 permissions + if ! permissions=$(aws::qpe ec2 describe-snapshot-attribute \ + --snapshot-id "$snapshot_id" \ + --attribute createVolumePermission \ + --query 'CreateVolumePermissions[].UserId' \ + --output text); then + log::error "Could not inspect create-volume permissions for snapshot $snapshot_id." + return 1 + fi + + if permissions::contains_sales_account "$permissions"; then + echo "present" + else + echo "absent" + fi +} + +function permissions::grant() { + local image_state snapshot_id snapshot_state + log::info "### Grant temporary Sales access to the QPE AMI ###" + SHARING_ATTEMPTED=true + + if ! image_state=$(permissions::image_state); then + return 1 + fi + if [[ "$image_state" == "present" ]]; then + log::info "AMI launch permission already includes Sales account $SALES_ACCOUNT_ID." + else + aws::qpe ec2 modify-image-attribute \ + --image-id "$SOURCE_AMI_ID" \ + --launch-permission "Add=[{UserId=${SALES_ACCOUNT_ID}}]" + fi + + for snapshot_id in "${SOURCE_SNAPSHOT_IDS[@]}"; do + if ! snapshot_state=$(permissions::snapshot_state "$snapshot_id"); then + return 1 + fi + if [[ "$snapshot_state" == "present" ]]; then + log::info "Snapshot $snapshot_id already includes Sales account $SALES_ACCOUNT_ID." + else + aws::qpe ec2 modify-snapshot-attribute \ + --snapshot-id "$snapshot_id" \ + --attribute createVolumePermission \ + --operation-type add \ + --user-ids "$SALES_ACCOUNT_ID" + fi + done + + if ! image_state=$(permissions::image_state); then + return 1 + fi + if [[ "$image_state" != "present" ]]; then + log::error "Sales launch permission was not applied to AMI $SOURCE_AMI_ID." + return 1 + fi + for snapshot_id in "${SOURCE_SNAPSHOT_IDS[@]}"; do + if ! snapshot_state=$(permissions::snapshot_state "$snapshot_id"); then + return 1 + fi + if [[ "$snapshot_state" != "present" ]]; then + log::error "Sales create-volume permission was not applied to snapshot $snapshot_id." + return 1 + fi + done +} + +function permissions::revoke_image() { + local image_state + if ! image_state=$(permissions::image_state); then + return 1 + fi + if [[ "$image_state" == "present" ]]; then + aws::qpe ec2 modify-image-attribute \ + --image-id "$SOURCE_AMI_ID" \ + --launch-permission "Remove=[{UserId=${SALES_ACCOUNT_ID}}]" + fi +} + +function permissions::revoke_snapshot() { + local snapshot_id=$1 snapshot_state + if ! snapshot_state=$(permissions::snapshot_state "$snapshot_id"); then + return 1 + fi + if [[ "$snapshot_state" == "present" ]]; then + aws::qpe ec2 modify-snapshot-attribute \ + --snapshot-id "$snapshot_id" \ + --attribute createVolumePermission \ + --operation-type remove \ + --user-ids "$SALES_ACCOUNT_ID" + fi +} + +function permissions::revoke_all() { + local image_state snapshot_id snapshot_state status=0 + log::info "### Revoke Sales access from the QPE AMI ###" + + permissions::revoke_image || status=$((status + 1)) + for snapshot_id in "${SOURCE_SNAPSHOT_IDS[@]}"; do + permissions::revoke_snapshot "$snapshot_id" || status=$((status + 1)) + done + + if ! image_state=$(permissions::image_state); then + status=$((status + 1)) + elif [[ "$image_state" == "present" ]]; then + log::error "Sales launch permission remains on AMI $SOURCE_AMI_ID." + status=$((status + 1)) + fi + for snapshot_id in "${SOURCE_SNAPSHOT_IDS[@]}"; do + if ! snapshot_state=$(permissions::snapshot_state "$snapshot_id"); then + status=$((status + 1)) + elif [[ "$snapshot_state" == "present" ]]; then + log::error "Sales create-volume permission remains on snapshot $snapshot_id." + status=$((status + 1)) + fi + done + + ((status == 0)) +} + +function target::find_existing() { + local image_id image_records state terminal_count=0 + local -a active_image_ids=() + image_records=$(aws::sales ec2 describe-images \ + --owners self \ + --filters \ + "Name=tag:SourceAMI,Values=$SOURCE_AMI_ID" \ + "Name=tag:SourceAccount,Values=$QPE_ACCOUNT_ID" \ + "Name=tag:SourceRegion,Values=$SOURCE_REGION" \ + --query 'Images[].[ImageId,State]' \ + --output text) + + if [[ -z "$image_records" || "$image_records" == "None" ]]; then + return 0 + fi + + while IFS=$'\t' read -r image_id state; do + [[ -z "$image_id" || "$image_id" == "None" ]] && continue + case "$state" in + failed | error | invalid | deregistered) + terminal_count=$((terminal_count + 1)) + ;; + *) + active_image_ids+=("$image_id") + ;; + esac + done <<<"$image_records" + + if [[ ${#active_image_ids[@]} -gt 1 ]]; then + log::error "Found ${#active_image_ids[@]} active Sales AMIs promoted from $SOURCE_AMI_ID; expected at most one." + return 1 + fi + + if [[ ${#active_image_ids[@]} -eq 1 ]]; then + TARGET_AMI_ID=${active_image_ids[0]} + return 0 + fi + + PROMOTION_ATTEMPT=$terminal_count + log::warning "Found $terminal_count terminal Sales AMI(s) promoted from $SOURCE_AMI_ID; creating replacement attempt $PROMOTION_ATTEMPT." +} + +function target::copy() { + local client_token description image_tags max_source_name_length name_suffix snapshot_tags + name_suffix="-${SOURCE_AMI_ID}" + if ((PROMOTION_ATTEMPT > 0)); then + name_suffix+="-retry-${PROMOTION_ATTEMPT}" + fi + max_source_name_length=$((128 - ${#name_suffix})) + TARGET_AMI_NAME="${SOURCE_AMI_NAME:0:max_source_name_length}${name_suffix}" + + client_token="querypie-${SOURCE_AMI_ID}-${DESTINATION_REGION}" + if ((PROMOTION_ATTEMPT > 0)); then + client_token+="-retry-${PROMOTION_ATTEMPT}" + fi + description="Marketplace copy of ${SOURCE_AMI_NAME} from ${QPE_ACCOUNT_ID}/${SOURCE_REGION}/${SOURCE_AMI_ID}" + + image_tags="ResourceType=image,Tags=[{Key=Name,Value=${TARGET_AMI_NAME}},{Key=SourceAMI,Value=${SOURCE_AMI_ID}},{Key=SourceAccount,Value=${QPE_ACCOUNT_ID}},{Key=SourceRegion,Value=${SOURCE_REGION}},{Key=Architecture,Value=${SOURCE_ARCHITECTURE}}" + snapshot_tags="ResourceType=snapshot,Tags=[{Key=Name,Value=${TARGET_AMI_NAME}-snapshot},{Key=SourceAMI,Value=${SOURCE_AMI_ID}},{Key=SourceAccount,Value=${QPE_ACCOUNT_ID}},{Key=SourceRegion,Value=${SOURCE_REGION}},{Key=Architecture,Value=${SOURCE_ARCHITECTURE}}" + if [[ -n "$SOURCE_VERSION" && "$SOURCE_VERSION" != "None" ]]; then + image_tags+=",{Key=Version,Value=${SOURCE_VERSION}}" + snapshot_tags+=",{Key=Version,Value=${SOURCE_VERSION}}" + fi + if [[ -n "$SOURCE_BUILD_DATE" && "$SOURCE_BUILD_DATE" != "None" ]]; then + image_tags+=",{Key=BuildDate,Value=${SOURCE_BUILD_DATE}}" + snapshot_tags+=",{Key=BuildDate,Value=${SOURCE_BUILD_DATE}}" + fi + image_tags+="]" + snapshot_tags+="]" + + log::info "### Copy the shared AMI into the Sales account ###" + # AWS may accept CopyImage even when the CLI loses the response. Until a + # later invocation reconciles the idempotent request, preserve source access. + COPY_IN_PROGRESS=true + TARGET_AMI_ID=$(aws::sales ec2 copy-image \ + --source-region "$SOURCE_REGION" \ + --source-image-id "$SOURCE_AMI_ID" \ + --name "$TARGET_AMI_NAME" \ + --description "$description" \ + --no-encrypted \ + --client-token "$client_token" \ + --tag-specifications "$image_tags" "$snapshot_tags" \ + --query ImageId \ + --output text) + + if [[ -z "$TARGET_AMI_ID" || "$TARGET_AMI_ID" == "None" ]]; then + log::error "Sales copy request did not return an AMI ID." + return 1 + fi +} + +function target::state() { + aws::sales ec2 describe-images \ + --owners self \ + --image-ids "$TARGET_AMI_ID" \ + --query 'Images[0].State' \ + --output text +} + +function target::wait_until_available() { + local deadline state + deadline=$((SECONDS + COPY_TIMEOUT_SECONDS)) + + while ((SECONDS < deadline)); do + state=$(target::state) + case "$state" in + available) + COPY_IN_PROGRESS=false + COPY_AVAILABLE=true + return 0 + ;; + pending) + log::info "Sales AMI $TARGET_AMI_ID is pending. Waiting ${COPY_POLL_INTERVAL_SECONDS}s." + log::do sleep "$COPY_POLL_INTERVAL_SECONDS" + ;; + failed | error | invalid) + COPY_IN_PROGRESS=false + COPY_TERMINAL=true + log::error "Sales AMI $TARGET_AMI_ID entered terminal state '$state'." + return 1 + ;; + *) + log::error "Sales AMI $TARGET_AMI_ID has unexpected state '$state'." + return 1 + ;; + esac + done + + log::error "Timed out after ${COPY_TIMEOUT_SECONDS}s waiting for Sales AMI $TARGET_AMI_ID." + return 1 +} + +function target::validate() { + log::do env \ + AWS_PROFILE="$SALES_PROFILE" \ + AMI_REGION="$DESTINATION_REGION" \ + "$SCRIPT_DIR/ami-validate.sh" --marketplace-source "$TARGET_AMI_ID" +} + +function cleanup::on_exit() { + local status=$? + trap - EXIT + + if [[ $status -ne 0 && -n "$SOURCE_AMI_ID" && ${#SOURCE_SNAPSHOT_IDS[@]} -gt 0 ]]; then + if [[ "$COPY_AVAILABLE" == true || "$COPY_TERMINAL" == true ]]; then + log::warning "The Sales copy is no longer in progress, so the source sharing permissions will be revoked." + if ! permissions::revoke_all; then + log::error "Some source sharing permissions could not be revoked. Re-run this command with $SOURCE_AMI_ID after resolving the AWS error." + fi + elif [[ "$COPY_IN_PROGRESS" == true ]]; then + log::warning "The Sales copy may still be in progress. Source sharing permissions were left in place." + log::warning "Re-run this command with $SOURCE_AMI_ID to resume validation and revoke sharing." + elif [[ "$SHARING_ATTEMPTED" == true ]]; then + log::warning "Promotion stopped before the Sales copy started. Revoking temporary Sales access." + if ! permissions::revoke_all; then + log::error "Some source sharing permissions could not be revoked. Re-run this command with $SOURCE_AMI_ID after resolving the AWS error." + fi + fi + fi + + exit "$status" +} + +function main() { + local -a arguments=() + + while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + print_usage_and_exit 0 + ;; + -V | --version) + echo "$SCRIPT_VERSION" + exit 0 + ;; + -x | --xtrace) + set -o xtrace + shift + ;; + --) + shift + arguments+=("$@") + break + ;; + -*) + log::error "Unexpected option: $1" + print_usage_and_exit 1 + ;; + *) + arguments+=("$1") + shift + ;; + esac + done + + if [[ ${#arguments[@]} -ne 1 ]]; then + print_usage_and_exit 1 + fi + SOURCE_AMI_ID=${arguments[0]} + + validate::ami_id "$SOURCE_AMI_ID" + validate::environment + trap cleanup::on_exit EXIT + + log::info "### Validate fixed AWS account contexts ###" + validate::account "$QPE_PROFILE" "$QPE_ACCOUNT_ID" + validate::account "$SALES_PROFILE" "$SALES_ACCOUNT_ID" + validate::sales_encryption_default + + source::validate + source::load_metadata + target::find_existing + + if [[ -z "$TARGET_AMI_ID" ]]; then + permissions::grant + target::copy + else + log::info "Found existing Sales AMI $TARGET_AMI_ID promoted from $SOURCE_AMI_ID." + local existing_state + existing_state=$(target::state) + case "$existing_state" in + available) + COPY_AVAILABLE=true + ;; + pending) + COPY_IN_PROGRESS=true + permissions::grant + ;; + failed | error | invalid) + COPY_TERMINAL=true + log::error "Existing Sales AMI $TARGET_AMI_ID is in terminal state '$existing_state'." + return 1 + ;; + *) + log::error "Existing Sales AMI $TARGET_AMI_ID has unexpected state '$existing_state'." + return 1 + ;; + esac + fi + + if [[ "$COPY_AVAILABLE" != true ]]; then + target::wait_until_available + fi + target::validate + permissions::revoke_all + + echo "Promoted AMI: $TARGET_AMI_ID" + echo "Sales sharing revoked from QPE AMI: $SOURCE_AMI_ID" +} + +main "$@" diff --git a/aws/ami/tests/ami-promote-to-sales.bats b/aws/ami/tests/ami-promote-to-sales.bats new file mode 100644 index 0000000..3cf4466 --- /dev/null +++ b/aws/ami/tests/ami-promote-to-sales.bats @@ -0,0 +1,337 @@ +#!/usr/bin/env bats + +setup() { + TEST_ROOT=$(mktemp -d) + MOCK_BIN="$TEST_ROOT/bin" + AWS_INVOCATIONS_FILE="$TEST_ROOT/aws-invocations" + export AWS_INVOCATIONS_FILE + mkdir -p "$MOCK_BIN" + : >"$AWS_INVOCATIONS_FILE" + + cat >"$MOCK_BIN/aws" <<'EOF' +#!/usr/bin/env bash +set -o nounset -o errexit -o pipefail + +printf 'profile=%s %s\n' "${AWS_PROFILE:-}" "$*" >>"$AWS_INVOCATIONS_FILE" + +arguments=" $* " +profile="${AWS_PROFILE:-}" +previous="" +snapshot_id="" +for argument in "$@"; do + if [[ "$previous" == "--profile" ]]; then + profile=$argument + elif [[ "$previous" == "--snapshot-id" ]]; then + snapshot_id=$argument + fi + previous=$argument +done + +if [[ "$arguments" == *" sts get-caller-identity "* ]]; then + if [[ "$profile" == "qpe" ]]; then + printf '%s\n' "${MOCK_QPE_ACCOUNT_ID:-142605707876}" + elif [[ "$profile" == "sales" ]]; then + printf '%s\n' "${MOCK_SALES_ACCOUNT_ID:-883790944456}" + else + printf 'Unexpected profile for STS: %s\n' "$profile" >&2 + exit 2 + fi +elif [[ "$arguments" == *" ec2 get-ebs-encryption-by-default "* ]]; then + printf '%s\n' "${MOCK_SALES_EBS_ENCRYPTION_BY_DEFAULT:-False}" +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"State,Architecture,RootDeviceType"* ]]; then + printf '%b\n' 'available\tx86_64\tebs\thvm\tv2.0\tQueryPie Suite' +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"VolumeSize"* ]]; then + printf '32\n' +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"BlockDeviceMappings"* ]]; then + printf '%s\n' "${MOCK_SNAPSHOT_IDS:-snap-0123456789abcdef0 snap-0fedcba9876543210}" +elif [[ "$arguments" == *" ec2 describe-snapshots "* ]]; then + if [[ "$profile" == "sales" ]]; then + printf '%s\n' "${MOCK_SALES_SNAPSHOT_ENCRYPTED:-False}" + else + printf 'False\n' + fi +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"Images[0].[Name,Architecture]"* ]]; then + printf '%b\n' 'QueryPie-Suite-11.6.5-202608071519\tx86_64' +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"Key=='Version'"* ]]; then + printf '11.6.5\n' +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"Key=='BuildDate'"* ]]; then + printf '20260807062224\n' +elif [[ "$arguments" == *" ec2 describe-image-attribute "* ]]; then + if [[ "${MOCK_IMAGE_PERMISSION_DESCRIBE_FAILURE_AFTER_REVOKE:-false}" == "true" && -f "${AWS_INVOCATIONS_FILE}.image-permission-revoked" ]]; then + printf 'image permission verification failed\n' >&2 + exit 2 + elif [[ -f "${AWS_INVOCATIONS_FILE}.image-permission-revoked" ]]; then + printf 'None\n' + elif [[ "${MOCK_PERMISSIONS_GRANTED:-false}" == "true" || "${MOCK_IMAGE_PERMISSION_GRANTED:-false}" == "true" || -f "${AWS_INVOCATIONS_FILE}.image-permission-added" ]]; then + printf '883790944456\n' + else + printf 'None\n' + fi +elif [[ "$arguments" == *" ec2 describe-snapshot-attribute "* ]]; then + if [[ -f "${AWS_INVOCATIONS_FILE}.${snapshot_id}.permission-revoked" ]]; then + printf 'None\n' + elif [[ "${MOCK_PERMISSIONS_GRANTED:-false}" == "true" || -f "${AWS_INVOCATIONS_FILE}.${snapshot_id}.permission-added" ]]; then + printf '883790944456\n' + else + printf 'None\n' + fi +elif [[ "$arguments" == *" ec2 modify-image-attribute "* && "$arguments" == *"Add="* ]]; then + printf 'true\n' >"${AWS_INVOCATIONS_FILE}.image-permission-added" +elif [[ "$arguments" == *" ec2 modify-snapshot-attribute "* && "$arguments" == *"--operation-type add"* ]]; then + if [[ "${MOCK_SNAPSHOT_GRANT_FAILURE:-false}" == "true" && "$snapshot_id" == "snap-0123456789abcdef0" ]]; then + printf 'snapshot grant failed\n' >&2 + exit 2 + fi + printf 'true\n' >"${AWS_INVOCATIONS_FILE}.${snapshot_id}.permission-added" +elif [[ "$arguments" == *" ec2 modify-image-attribute "* && "$arguments" == *"Remove="* ]]; then + if [[ "${MOCK_IMAGE_REVOKE_FAILURE:-false}" == "true" ]]; then + printf 'image revoke failed\n' >&2 + exit 2 + fi + printf 'true\n' >"${AWS_INVOCATIONS_FILE}.image-permission-revoked" +elif [[ "$arguments" == *" ec2 modify-snapshot-attribute "* && "$arguments" == *"--operation-type remove"* ]]; then + printf 'true\n' >"${AWS_INVOCATIONS_FILE}.${snapshot_id}.permission-revoked" +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"Name=tag:SourceAMI"* ]]; then + printf '%b\n' "${MOCK_EXISTING_TARGET_AMIS:-None}" +elif [[ "$arguments" == *" ec2 copy-image "* ]]; then + if [[ "${MOCK_COPY_FAILURE:-false}" == "true" ]]; then + printf 'copy failed\n' >&2 + exit 2 + fi + printf 'ami-0fedcba9876543210\n' +elif [[ "$arguments" == *" ec2 describe-images "* && "$arguments" == *"Images[0].State"* ]]; then + printf '%s\n' "${MOCK_TARGET_STATE:-available}" +else + printf 'Unexpected aws invocation: profile=%s %s\n' "$profile" "$*" >&2 + exit 2 +fi +EOF + chmod +x "$MOCK_BIN/aws" +} + +teardown() { + rm -rf "$TEST_ROOT" +} + +@test "promotion uses only the AMI ID and fixed account profiles and Regions" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 0 ] + [[ "$output" == *"Promoted AMI: ami-0fedcba9876543210"* ]] + [[ "$output" == *"Sales sharing revoked from QPE AMI: ami-0123456789abcdef0"* ]] + grep -F -- '--profile qpe' "$AWS_INVOCATIONS_FILE" + grep -F -- '--profile sales' "$AWS_INVOCATIONS_FILE" + grep -F -- '--region ap-northeast-2' "$AWS_INVOCATIONS_FILE" + grep -F -- '--region us-east-1' "$AWS_INVOCATIONS_FILE" + grep -F -- '--name QueryPie-Suite-11.6.5-202608071519-ami-0123456789abcdef0' "$AWS_INVOCATIONS_FILE" + grep -F -- '--no-encrypted' "$AWS_INVOCATIONS_FILE" + grep -F -- 'Key=SourceAMI,Value=ami-0123456789abcdef0' "$AWS_INVOCATIONS_FILE" +} + +@test "promotion grants and then revokes AMI and every snapshot permission" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 0 ] + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Add=[{UserId=883790944456}]' + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=[{UserId=883790944456}]' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type add')" -eq 2 ] + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove')" -eq 2 ] +} + +@test "promotion aborts before sharing when the QPE profile uses the wrong account" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_QPE_ACCOUNT_ID=111122223333 \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 1 ] + [[ "$output" == *"profile 'qpe' must use account 142605707876"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" + ! grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" +} + +@test "promotion aborts before sharing when the Sales profile uses the wrong account" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_SALES_ACCOUNT_ID=111122223333 \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 1 ] + [[ "$output" == *"profile 'sales' must use account 883790944456"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" + ! grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" +} + +@test "promotion aborts before sharing when Sales default EBS encryption is enabled" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_SALES_EBS_ENCRYPTION_BY_DEFAULT=True \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 1 ] + [[ "$output" == *"EBS encryption by default must be disabled in Sales us-east-1"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" + ! grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" +} + +@test "promotion reuses an available Sales AMI and only revokes stale source sharing" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_EXISTING_TARGET_AMIS=$'ami-0fedcba9876543210\tavailable' \ + MOCK_PERMISSIONS_GRANTED=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 0 ] + ! grep -F -- 'ec2 copy-image' "$AWS_INVOCATIONS_FILE" + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Add=' + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=[{UserId=883790944456}]' +} + +@test "promotion preserves sharing when the copy-image request outcome is unknown" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_COPY_FAILURE=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"copy may still be in progress"* ]] + [[ "$output" == *"Re-run this command"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove' || true)" -eq 0 ] +} + +@test "promotion preserves restored access when an existing Sales copy is pending" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_EXISTING_TARGET_AMIS=$'ami-0fedcba9876543210\tpending' \ + MOCK_TARGET_STATE=pending \ + MOCK_SNAPSHOT_GRANT_FAILURE=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"copy may still be in progress"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove' || true)" -eq 0 ] +} + +@test "promotion retries cleanup for pre-existing sharing when permission grant fails" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_IMAGE_PERMISSION_GRANTED=true \ + MOCK_SNAPSHOT_GRANT_FAILURE=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"Revoking temporary Sales access"* ]] + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=[{UserId=883790944456}]' + ! grep -F -- 'ec2 copy-image' "$AWS_INVOCATIONS_FILE" +} + +@test "promotion reports an incomplete permission cleanup for retry" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_IMAGE_PERMISSION_GRANTED=true \ + MOCK_SNAPSHOT_GRANT_FAILURE=true \ + MOCK_IMAGE_REVOKE_FAILURE=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"Sales launch permission remains on AMI"* ]] + [[ "$output" == *"Some source sharing permissions could not be revoked"* ]] + [[ "$output" == *"Re-run this command"* ]] +} + +@test "promotion treats permission verification API errors as cleanup failures" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_IMAGE_PERMISSION_DESCRIBE_FAILURE_AFTER_REVOKE=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"Could not inspect launch permissions"* ]] + [[ "$output" == *"Some source sharing permissions could not be revoked"* ]] + [[ "$output" == *"Re-run this command"* ]] +} + +@test "promotion revokes source sharing when the completed Sales copy fails final validation" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_SALES_SNAPSHOT_ENCRYPTED=True \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"source sharing permissions will be revoked"* ]] + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=[{UserId=883790944456}]' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove')" -eq 2 ] +} + +@test "promotion leaves source sharing in place while the Sales copy state is uncertain" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_TARGET_STATE=transient \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"copy may still be in progress"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove' || true)" -eq 0 ] +} + +@test "promotion replaces a terminal Sales AMI and revokes sharing after success" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_EXISTING_TARGET_AMIS=$'ami-0fedcba9876543210\tfailed' \ + MOCK_PERMISSIONS_GRANTED=true \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -eq 0 ] + [[ "$output" == *"creating replacement attempt 1"* ]] + grep -F -- '--name QueryPie-Suite-11.6.5-202608071519-ami-0123456789abcdef0-retry-1' "$AWS_INVOCATIONS_FILE" + grep -F -- '--client-token querypie-ami-0123456789abcdef0-us-east-1-retry-1' "$AWS_INVOCATIONS_FILE" + grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -- 'Remove=[{UserId=883790944456}]' + [ "$(grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" | grep -F -c -- '--operation-type remove')" -eq 2 ] +} + +@test "promotion rejects account and Region override options" { + run "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" \ + --qpe-profile another-profile \ + ami-0123456789abcdef0 + + [ "$status" -eq 1 ] + [[ "$output" == *"Unexpected option: --qpe-profile"* ]] +} + +@test "promotion accepts the AMI ID after the option delimiter" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" -- ami-0123456789abcdef0 + + [ "$status" -eq 0 ] + [[ "$output" == *"Promoted AMI: ami-0fedcba9876543210"* ]] +} + +@test "promotion rejects invalid AMI IDs before calling AWS" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" not-an-ami + + [ "$status" -eq 1 ] + [[ "$output" == *"AMI ID must use the format"* ]] + [ ! -s "$AWS_INVOCATIONS_FILE" ] +} + +@test "promotion rejects duplicate Sales AMIs before changing source permissions" { + run env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_EXISTING_TARGET_AMIS=$'ami-0fedcba9876543210\tavailable\nami-0abcdef0123456789\tpending' \ + "$BATS_TEST_DIRNAME/../ami-promote-to-sales.sh" ami-0123456789abcdef0 + + [ "$status" -ne 0 ] + [[ "$output" == *"Found 2 active Sales AMIs"* ]] + ! grep -F -- 'modify-image-attribute' "$AWS_INVOCATIONS_FILE" + ! grep -F -- 'modify-snapshot-attribute' "$AWS_INVOCATIONS_FILE" +}