From 5b0171f454ad19fafc3ba2429932ffc0f17d3b4e Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sun, 23 Aug 2026 22:40:14 +0800 Subject: [PATCH 01/12] feat: validate Windows VM batch requests Co-authored-by: Codex --- .github/workflows/launch-windows-a11y-ec2.yml | 20 +++++-- .../tests/validate-stack-operation.Tests.ps1 | 59 +++++++++++++++---- .../windows-a11y/validate-stack-operation.sh | 18 +++++- vms_portal/tests/test_infrastructure.py | 14 +++++ 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/.github/workflows/launch-windows-a11y-ec2.yml b/.github/workflows/launch-windows-a11y-ec2.yml index 7a2fb30..2ab77a3 100644 --- a/.github/workflows/launch-windows-a11y-ec2.yml +++ b/.github/workflows/launch-windows-a11y-ec2.yml @@ -19,9 +19,10 @@ on: description: "Delete only: enter the full name, including prefix (example: windows-a11y-anson-test)" required: false type: string - ami_name: - description: "Launch only: AMI version label used by build-windows-a11y-ami" - required: false + instance_count: + description: "Launch only: number of VMs to create (1-20)" + required: true + default: "1" type: string instance_name: description: "Launch only: EC2 Name tag" @@ -59,15 +60,19 @@ jobs: id: stack env: ACTION: ${{ inputs.action }} - AMI_NAME: ${{ inputs.ami_name }} CONFIRM_STACK_NAME: ${{ inputs.confirm_stack_name }} + DISK_SIZE: ${{ inputs.disk_size }} + INSTANCE_COUNT: ${{ inputs.instance_count }} + INSTANCE_TYPE: ${{ inputs.instance_type }} STACK_SUFFIX: ${{ inputs.stack_suffix }} run: | STACK_NAME=$(bash scripts/windows-a11y/validate-stack-operation.sh \ "${ACTION}" \ "${STACK_SUFFIX}" \ "${CONFIRM_STACK_NAME}" \ - "${AMI_NAME}") + "${INSTANCE_COUNT}" \ + "${INSTANCE_TYPE}" \ + "${DISK_SIZE}") echo "Resolved stack name: ${STACK_NAME}" echo "stack_name=${STACK_NAME}" >> "$GITHUB_OUTPUT" @@ -76,6 +81,9 @@ jobs: echo "- Action: \`${ACTION}\`" echo "- Stack prefix: \`windows-a11y-\`" echo "- Full stack name: \`${STACK_NAME}\`" + if [ "${ACTION}" = "launch" ]; then + echo "- Instance count: \`${INSTANCE_COUNT}\`" + fi } >> "$GITHUB_STEP_SUMMARY" launch: @@ -100,7 +108,7 @@ jobs: - name: Find the built Windows A11y AMI id: ami env: - AMI_NAME: windows-a11y-${{ inputs.ami_name }} + AMI_NAME: windows-a11y-* run: | IMAGE_ID=$(aws ec2 describe-images \ --owners self \ diff --git a/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 b/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 index 4a62db5..230022b 100644 --- a/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 +++ b/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 @@ -4,23 +4,58 @@ Describe 'Windows A11y stack operation validation' { } It 'builds the prefixed stack name for a launch request' { - $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '2026-08-15' 2>&1) + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '1' 'm5.xlarge' '100' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 0 $output | Should -Be @('windows-a11y-anson-test') } - It 'requires an AMI name for a launch request' { - $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '' 2>&1) - $exitCode = $LASTEXITCODE + It 'accepts batch boundaries for launch' -ForEach @( + @{ Count = '1' } + @{ Count = '20' } + ) { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' $Count 'm5.xlarge' '100' 2>&1) - $exitCode | Should -Be 1 - ($output -join "`n") | Should -Match 'AMI name is required when action is launch\.' + $LASTEXITCODE | Should -Be 0 + $output | Should -Be @('windows-a11y-anson-test') + } + + It 'rejects invalid launch counts' -ForEach @( + @{ Count = '0' } + @{ Count = '21' } + @{ Count = '1.5' } + @{ Count = 'many' } + @{ Count = '' } + ) { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' $Count 'm5.xlarge' '100' 2>&1) + + $LASTEXITCODE | Should -Be 1 + ($output -join "`n") | Should -Match 'Instance count must be an integer from 1 through 20\.' + } + + It 'requires an instance type for launch' { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '1' '' '100' 2>&1) + + $LASTEXITCODE | Should -Be 1 + ($output -join "`n") | Should -Match 'Instance type is required when action is launch\.' + } + + It 'requires a positive integer disk size for launch' -ForEach @( + @{ DiskSize = '0' } + @{ DiskSize = '-1' } + @{ DiskSize = '100.5' } + @{ DiskSize = 'large' } + @{ DiskSize = '' } + ) { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '1' 'm5.xlarge' $DiskSize 2>&1) + + $LASTEXITCODE | Should -Be 1 + ($output -join "`n") | Should -Match 'Disk size must be a positive integer when action is launch\.' } It 'accepts deletion only when the full prefixed stack name is confirmed' { - $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'windows-a11y-anson-test' '' 2>&1) + $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'windows-a11y-anson-test' '' '' '' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 0 @@ -28,7 +63,7 @@ Describe 'Windows A11y stack operation validation' { } It 'rejects deletion when the confirmation does not match the full stack name' { - $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'anson-test' '' 2>&1) + $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'anson-test' '' '' '' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 1 @@ -36,7 +71,7 @@ Describe 'Windows A11y stack operation validation' { } It 'rejects a suffix that already includes the managed prefix' { - $output = @(& bash $script:validatorPath 'launch' 'windows-a11y-anson-test' '' '2026-08-15' 2>&1) + $output = @(& bash $script:validatorPath 'launch' 'windows-a11y-anson-test' '' '1' 'm5.xlarge' '100' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 1 @@ -47,7 +82,7 @@ Describe 'Windows A11y stack operation validation' { $invalidSuffixes = @('Anson', 'anson_test', '-anson', 'anson-', 'anson test') foreach ($suffix in $invalidSuffixes) { - $output = @(& bash $script:validatorPath 'launch' $suffix '' '2026-08-15' 2>&1) + $output = @(& bash $script:validatorPath 'launch' $suffix '' '1' 'm5.xlarge' '100' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 1 -Because "'$suffix' is not a valid stack suffix" @@ -58,7 +93,7 @@ Describe 'Windows A11y stack operation validation' { It 'rejects a suffix that would exceed the CloudFormation stack name limit' { $tooLongSuffix = 'a' * 116 - $output = @(& bash $script:validatorPath 'launch' $tooLongSuffix '' '2026-08-15' 2>&1) + $output = @(& bash $script:validatorPath 'launch' $tooLongSuffix '' '1' 'm5.xlarge' '100' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 1 @@ -66,7 +101,7 @@ Describe 'Windows A11y stack operation validation' { } It 'rejects unsupported actions' { - $output = @(& bash $script:validatorPath 'replace' 'anson-test' '' '2026-08-15' 2>&1) + $output = @(& bash $script:validatorPath 'replace' 'anson-test' '' '1' 'm5.xlarge' '100' 2>&1) $exitCode = $LASTEXITCODE $exitCode | Should -Be 1 diff --git a/scripts/windows-a11y/validate-stack-operation.sh b/scripts/windows-a11y/validate-stack-operation.sh index 5be0822..5f687f9 100644 --- a/scripts/windows-a11y/validate-stack-operation.sh +++ b/scripts/windows-a11y/validate-stack-operation.sh @@ -4,7 +4,9 @@ set -euo pipefail ACTION="${1:-}" STACK_SUFFIX="${2:-}" CONFIRM_STACK_NAME="${3:-}" -AMI_NAME="${4:-}" +INSTANCE_COUNT="${4:-}" +INSTANCE_TYPE="${5:-}" +DISK_SIZE="${6:-}" STACK_PREFIX="windows-a11y-" MAX_SUFFIX_LENGTH=115 @@ -31,8 +33,18 @@ fi STACK_NAME="${STACK_PREFIX}${STACK_SUFFIX}" -if [[ "${ACTION}" == "launch" && -z "${AMI_NAME}" ]]; then - fail 'AMI name is required when action is launch.' +if [[ "${ACTION}" == "launch" ]]; then + if [[ ! "${INSTANCE_COUNT}" =~ ^([1-9]|1[0-9]|20)$ ]]; then + fail 'Instance count must be an integer from 1 through 20.' + fi + + if [[ -z "${INSTANCE_TYPE}" ]]; then + fail 'Instance type is required when action is launch.' + fi + + if [[ ! "${DISK_SIZE}" =~ ^[1-9][0-9]*$ ]]; then + fail 'Disk size must be a positive integer when action is launch.' + fi fi if [[ "${ACTION}" == "delete" && "${CONFIRM_STACK_NAME}" != "${STACK_NAME}" ]]; then diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 8473ace..e6f2f23 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -26,6 +26,20 @@ def test_windows_template_applies_management_tag() -> None: assert {tag["Key"]: tag["Value"] for tag in tags}["VmPortalManaged"] == "true" +def test_windows_launch_workflow_uses_batch_count_and_latest_managed_ami() -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/launch-windows-a11y-ec2.yml").read_text() + ) + inputs = workflow[True]["workflow_dispatch"]["inputs"] + assert inputs["instance_count"]["default"] == "1" + assert "ami_name" not in inputs + + steps = workflow["jobs"]["launch"]["steps"] + find_ami = next(step for step in steps if step.get("id") == "ami") + assert find_ami["env"]["AMI_NAME"] == "windows-a11y-*" + assert "reverse(sort_by(Images, &CreationDate))[0].ImageId" in find_ami["run"] + + def test_access_policy_restricts_mutation_by_tag() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ From 283fe0d937c1d021f121312269186f2223a39e36 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sun, 23 Aug 2026 22:44:43 +0800 Subject: [PATCH 02/12] feat: create atomic Windows VM batches with EIPs Co-authored-by: Codex --- .github/workflows/launch-windows-a11y-ec2.yml | 92 +- .../windows-a11y-instance-template.yml | 1216 ++++++++++++++++- docs/windows-a11y-aws-manual-setup.md | 29 + vms_portal/tests/test_infrastructure.py | 75 +- 4 files changed, 1359 insertions(+), 53 deletions(-) diff --git a/.github/workflows/launch-windows-a11y-ec2.yml b/.github/workflows/launch-windows-a11y-ec2.yml index 2ab77a3..77da4d6 100644 --- a/.github/workflows/launch-windows-a11y-ec2.yml +++ b/.github/workflows/launch-windows-a11y-ec2.yml @@ -24,11 +24,6 @@ on: required: true default: "1" type: string - instance_name: - description: "Launch only: EC2 Name tag" - required: true - default: "windows-a11y" - type: string instance_type: description: "Launch only: EC2 instance type" required: true @@ -124,49 +119,75 @@ jobs: echo "Using ${AMI_NAME} (${IMAGE_ID})." echo "image_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT" - - name: Deploy EC2 instance + - name: Create VM batch stack + id: stack env: AMI_ID: ${{ steps.ami.outputs.image_id }} DISK_SIZE: ${{ inputs.disk_size }} - INSTANCE_NAME: ${{ inputs.instance_name }} + INSTANCE_COUNT: ${{ inputs.instance_count }} INSTANCE_TYPE: ${{ inputs.instance_type }} STACK_NAME: ${{ needs.validate.outputs.stack_name }} run: | - aws cloudformation deploy \ + if aws cloudformation describe-stacks --stack-name "${STACK_NAME}" >/dev/null 2>&1; then + echo "::error::Stack ${STACK_NAME} already exists. Choose another suffix." + exit 1 + fi + + BATCH_CREATED_AT=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + STACK_ID=$(aws cloudformation create-stack \ --stack-name "${STACK_NAME}" \ - --template-file cloudformation/windows-a11y-instance-template.yml \ - --parameter-overrides \ - AmiId="${AMI_ID}" \ - InstanceType="${INSTANCE_TYPE}" \ - DiskSize="${DISK_SIZE}" \ - SubnetId="${{ vars.SUBNET_ID }}" \ - SecurityGroupId="${{ vars.SECURITY_GROUP_ID }}" \ - InstanceProfileName="${{ vars.INSTANCE_PROFILE_NAME }}" \ - KeyName="${{ vars.KEY_NAME }}" \ - InstanceName="${INSTANCE_NAME}" \ - --no-fail-on-empty-changeset - - - name: Wait for instance and publish connection details + --template-body file://cloudformation/windows-a11y-instance-template.yml \ + --parameters \ + ParameterKey=AmiId,ParameterValue="${AMI_ID}" \ + ParameterKey=InstanceCount,ParameterValue="${INSTANCE_COUNT}" \ + ParameterKey=InstanceType,ParameterValue="${INSTANCE_TYPE}" \ + ParameterKey=DiskSize,ParameterValue="${DISK_SIZE}" \ + ParameterKey=SubnetId,ParameterValue="${{ vars.SUBNET_ID }}" \ + ParameterKey=SecurityGroupId,ParameterValue="${{ vars.SECURITY_GROUP_ID }}" \ + ParameterKey=InstanceProfileName,ParameterValue="${{ vars.INSTANCE_PROFILE_NAME }}" \ + ParameterKey=KeyName,ParameterValue="${{ vars.KEY_NAME }}" \ + ParameterKey=BatchCreatedAt,ParameterValue="${BATCH_CREATED_AT}" \ + --on-failure DELETE \ + --query StackId \ + --output text) + + echo "stack_id=${STACK_ID}" >> "$GITHUB_OUTPUT" + if ! aws cloudformation wait stack-create-complete --stack-name "${STACK_ID}"; then + echo "::error::CloudFormation failed to create ${STACK_NAME}; the batch is being deleted." + aws cloudformation describe-stack-events \ + --stack-name "${STACK_ID}" \ + --max-items 20 \ + --query "StackEvents[?contains(ResourceStatus, 'FAILED')].{Time:Timestamp,Status:ResourceStatus,LogicalId:LogicalResourceId,Type:ResourceType,Reason:ResourceStatusReason}" \ + --output table || true + exit 1 + fi + + - name: Publish batch connection details env: AMI_ID: ${{ steps.ami.outputs.image_id }} + INSTANCE_COUNT: ${{ inputs.instance_count }} + STACK_ID: ${{ steps.stack.outputs.stack_id }} STACK_NAME: ${{ needs.validate.outputs.stack_name }} run: | - INSTANCE_ID=$(aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" \ - --output text) - aws ec2 wait instance-status-ok --instance-ids "${INSTANCE_ID}" - PUBLIC_IP=$(aws cloudformation describe-stacks \ - --stack-name "${STACK_NAME}" \ - --query "Stacks[0].Outputs[?OutputKey=='PublicIp'].OutputValue" \ - --output text) + OUTPUTS=$(aws cloudformation describe-stacks \ + --stack-name "${STACK_ID}" \ + --query 'Stacks[0].Outputs' \ + --output json) { - echo "## Windows A11y EC2 launched" + echo "## Windows A11y VM batch launched" echo "- AMI: \`${AMI_ID}\`" - echo "- Instance: \`${INSTANCE_ID}\`" - echo "- Public IP: \`${PUBLIC_IP}\`" echo "- Stack: \`${STACK_NAME}\`" + echo "- Instance count: \`${INSTANCE_COUNT}\`" + echo + echo "| Name | Instance ID | Elastic IP |" + echo "| --- | --- | --- |" + for INDEX in $(seq -w 1 "${INSTANCE_COUNT}"); do + printf -v SUFFIX '%03d' "$((10#${INDEX}))" + INSTANCE_ID=$(jq -r --arg key "InstanceId${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") + ELASTIC_IP=$(jq -r --arg key "ElasticIp${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") + echo "| ${STACK_NAME}-${SUFFIX} | \`${INSTANCE_ID}\` | \`${ELASTIC_IP}\` |" + done } >> "$GITHUB_STEP_SUMMARY" delete: @@ -193,6 +214,10 @@ jobs: --stack-name "${STACK_NAME}" \ --query 'Stacks[0].StackStatus' \ --output text) + INSTANCE_COUNT=$(aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --query "Stacks[0].Parameters[?ParameterKey=='InstanceCount'].ParameterValue" \ + --output text) echo "Deleting ${STACK_NAME} (current status: ${STACK_STATUS})." aws cloudformation delete-stack --stack-name "${STACK_NAME}" @@ -211,4 +236,5 @@ jobs: echo "## Windows A11y stack deleted" echo "- Stack: \`${STACK_NAME}\`" echo "- Previous status: \`${STACK_STATUS}\`" + echo "- Deleted VM count: \`${INSTANCE_COUNT}\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/cloudformation/windows-a11y-instance-template.yml b/cloudformation/windows-a11y-instance-template.yml index cb5e91e..c1f8ecd 100644 --- a/cloudformation/windows-a11y-instance-template.yml +++ b/cloudformation/windows-a11y-instance-template.yml @@ -1,21 +1,47 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: Windows A11y build/verification EC2 instance (ordinary shared-tenancy - Windows Server does not require a Dedicated Host) +Description: Atomic batch of 1-20 Windows A11y EC2 instances with managed Elastic IPs Parameters: AmiId: Type: AWS::EC2::Image::Id - Description: Windows AMI to launch (AWS public Traditional Chinese Windows Server 2025 base AMI for builds, or a windows-a11y-* AMI for verification) + Description: Latest available self-owned windows-a11y-* AMI selected by the workflow + InstanceCount: + Type: String + Default: "1" + AllowedValues: + - "1" + - "2" + - "3" + - "4" + - "5" + - "6" + - "7" + - "8" + - "9" + - "10" + - "11" + - "12" + - "13" + - "14" + - "15" + - "16" + - "17" + - "18" + - "19" + - "20" + Description: Number of VM and Elastic IP pairs to create InstanceType: Type: String Default: m5.xlarge - Description: EC2 instance type + Description: EC2 instance type shared by this batch DiskSize: Type: Number Default: 100 - Description: Size of the root EBS volume in GB + MinValue: 1 + Description: Size of each root EBS volume in GB SubnetId: Type: AWS::EC2::Subnet::Id - Description: Public subnet ID to launch the instance into + Description: Public subnet ID to launch the instances into SecurityGroupId: Type: AWS::EC2::SecurityGroup::Id Description: RDP security group ID created manually per docs/windows-a11y-aws-manual-setup.md @@ -25,13 +51,855 @@ Parameters: KeyName: Type: AWS::EC2::KeyPair::KeyName Description: EC2 KeyPair for emergency access - InstanceName: + BatchCreatedAt: Type: String - Description: Name tag for the instance + AllowedPattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + Description: UTC creation timestamp used for Elastic IP cost estimates + +Mappings: + InstanceCountToSlots: + "1": + Enabled: ["true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "2": + Enabled: ["true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "3": + Enabled: ["true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "4": + Enabled: ["true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "5": + Enabled: ["true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "6": + Enabled: ["true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "7": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "8": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "9": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "10": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "11": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false", "false"] + "12": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false", "false"] + "13": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false", "false"] + "14": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false", "false"] + "15": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false", "false"] + "16": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false", "false"] + "17": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false", "false"] + "18": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false", "false"] + "19": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "false"] + "20": + Enabled: ["true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true", "true"] + +Conditions: + CreateSlot001: + Fn::Equals: + - Fn::Select: + - 0 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot002: + Fn::Equals: + - Fn::Select: + - 1 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot003: + Fn::Equals: + - Fn::Select: + - 2 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot004: + Fn::Equals: + - Fn::Select: + - 3 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot005: + Fn::Equals: + - Fn::Select: + - 4 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot006: + Fn::Equals: + - Fn::Select: + - 5 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot007: + Fn::Equals: + - Fn::Select: + - 6 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot008: + Fn::Equals: + - Fn::Select: + - 7 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot009: + Fn::Equals: + - Fn::Select: + - 8 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot010: + Fn::Equals: + - Fn::Select: + - 9 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot011: + Fn::Equals: + - Fn::Select: + - 10 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot012: + Fn::Equals: + - Fn::Select: + - 11 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot013: + Fn::Equals: + - Fn::Select: + - 12 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot014: + Fn::Equals: + - Fn::Select: + - 13 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot015: + Fn::Equals: + - Fn::Select: + - 14 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot016: + Fn::Equals: + - Fn::Select: + - 15 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot017: + Fn::Equals: + - Fn::Select: + - 16 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot018: + Fn::Equals: + - Fn::Select: + - 17 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot019: + Fn::Equals: + - Fn::Select: + - 18 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" + CreateSlot020: + Fn::Equals: + - Fn::Select: + - 19 + - Fn::FindInMap: [InstanceCountToSlots, !Ref InstanceCount, Enabled] + - "true" Resources: - WindowsInstance: + WindowsInstance001: + Type: AWS::EC2::Instance + Condition: CreateSlot001 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-001" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "001" + ElasticIp001: + Type: AWS::EC2::EIP + Condition: CreateSlot001 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance001 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-001" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "001" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance002: + Type: AWS::EC2::Instance + Condition: CreateSlot002 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-002" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "002" + ElasticIp002: + Type: AWS::EC2::EIP + Condition: CreateSlot002 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance002 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-002" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "002" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance003: + Type: AWS::EC2::Instance + Condition: CreateSlot003 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-003" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "003" + ElasticIp003: + Type: AWS::EC2::EIP + Condition: CreateSlot003 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance003 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-003" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "003" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance004: + Type: AWS::EC2::Instance + Condition: CreateSlot004 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-004" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "004" + ElasticIp004: + Type: AWS::EC2::EIP + Condition: CreateSlot004 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance004 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-004" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "004" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance005: + Type: AWS::EC2::Instance + Condition: CreateSlot005 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-005" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "005" + ElasticIp005: + Type: AWS::EC2::EIP + Condition: CreateSlot005 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance005 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-005" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "005" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance006: + Type: AWS::EC2::Instance + Condition: CreateSlot006 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-006" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "006" + ElasticIp006: + Type: AWS::EC2::EIP + Condition: CreateSlot006 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance006 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-006" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "006" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance007: + Type: AWS::EC2::Instance + Condition: CreateSlot007 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-007" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "007" + ElasticIp007: + Type: AWS::EC2::EIP + Condition: CreateSlot007 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance007 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-007" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "007" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance008: + Type: AWS::EC2::Instance + Condition: CreateSlot008 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-008" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "008" + ElasticIp008: + Type: AWS::EC2::EIP + Condition: CreateSlot008 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance008 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-008" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "008" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance009: + Type: AWS::EC2::Instance + Condition: CreateSlot009 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-009" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "009" + ElasticIp009: + Type: AWS::EC2::EIP + Condition: CreateSlot009 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance009 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-009" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "009" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance010: + Type: AWS::EC2::Instance + Condition: CreateSlot010 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-010" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "010" + ElasticIp010: + Type: AWS::EC2::EIP + Condition: CreateSlot010 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance010 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-010" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "010" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance011: + Type: AWS::EC2::Instance + Condition: CreateSlot011 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-011" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "011" + ElasticIp011: + Type: AWS::EC2::EIP + Condition: CreateSlot011 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance011 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-011" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "011" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance012: + Type: AWS::EC2::Instance + Condition: CreateSlot012 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-012" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "012" + ElasticIp012: + Type: AWS::EC2::EIP + Condition: CreateSlot012 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance012 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-012" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "012" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance013: + Type: AWS::EC2::Instance + Condition: CreateSlot013 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-013" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "013" + ElasticIp013: + Type: AWS::EC2::EIP + Condition: CreateSlot013 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance013 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-013" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "013" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance014: + Type: AWS::EC2::Instance + Condition: CreateSlot014 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-014" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "014" + ElasticIp014: + Type: AWS::EC2::EIP + Condition: CreateSlot014 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance014 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-014" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "014" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance015: + Type: AWS::EC2::Instance + Condition: CreateSlot015 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-015" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "015" + ElasticIp015: + Type: AWS::EC2::EIP + Condition: CreateSlot015 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance015 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-015" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "015" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance016: Type: AWS::EC2::Instance + Condition: CreateSlot016 Properties: InstanceType: !Ref InstanceType ImageId: !Ref AmiId @@ -42,7 +910,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: true + AssociatePublicIpAddress: false BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -50,15 +918,329 @@ Resources: VolumeType: gp3 Tags: - Key: Name - Value: !Ref InstanceName + Value: !Sub "${AWS::StackName}-016" - Key: VmPortalManaged Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "016" + ElasticIp016: + Type: AWS::EC2::EIP + Condition: CreateSlot016 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance016 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-016" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "016" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance017: + Type: AWS::EC2::Instance + Condition: CreateSlot017 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-017" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "017" + ElasticIp017: + Type: AWS::EC2::EIP + Condition: CreateSlot017 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance017 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-017" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "017" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance018: + Type: AWS::EC2::Instance + Condition: CreateSlot018 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-018" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "018" + ElasticIp018: + Type: AWS::EC2::EIP + Condition: CreateSlot018 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance018 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-018" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "018" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance019: + Type: AWS::EC2::Instance + Condition: CreateSlot019 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-019" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "019" + ElasticIp019: + Type: AWS::EC2::EIP + Condition: CreateSlot019 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance019 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-019" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "019" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt + WindowsInstance020: + Type: AWS::EC2::Instance + Condition: CreateSlot020 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: false + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-020" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "020" + ElasticIp020: + Type: AWS::EC2::EIP + Condition: CreateSlot020 + Properties: + Domain: vpc + InstanceId: !Ref WindowsInstance020 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-020" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "020" + - Key: VmPortalCreatedAt + Value: !Ref BatchCreatedAt Outputs: - InstanceId: - Description: The Instance ID - Value: !Ref WindowsInstance - - PublicIp: - Description: Public IP address of the instance - Value: !GetAtt WindowsInstance.PublicIp + InstanceId001: + Condition: CreateSlot001 + Value: !Ref WindowsInstance001 + ElasticIp001: + Condition: CreateSlot001 + Value: !Ref ElasticIp001 + InstanceId002: + Condition: CreateSlot002 + Value: !Ref WindowsInstance002 + ElasticIp002: + Condition: CreateSlot002 + Value: !Ref ElasticIp002 + InstanceId003: + Condition: CreateSlot003 + Value: !Ref WindowsInstance003 + ElasticIp003: + Condition: CreateSlot003 + Value: !Ref ElasticIp003 + InstanceId004: + Condition: CreateSlot004 + Value: !Ref WindowsInstance004 + ElasticIp004: + Condition: CreateSlot004 + Value: !Ref ElasticIp004 + InstanceId005: + Condition: CreateSlot005 + Value: !Ref WindowsInstance005 + ElasticIp005: + Condition: CreateSlot005 + Value: !Ref ElasticIp005 + InstanceId006: + Condition: CreateSlot006 + Value: !Ref WindowsInstance006 + ElasticIp006: + Condition: CreateSlot006 + Value: !Ref ElasticIp006 + InstanceId007: + Condition: CreateSlot007 + Value: !Ref WindowsInstance007 + ElasticIp007: + Condition: CreateSlot007 + Value: !Ref ElasticIp007 + InstanceId008: + Condition: CreateSlot008 + Value: !Ref WindowsInstance008 + ElasticIp008: + Condition: CreateSlot008 + Value: !Ref ElasticIp008 + InstanceId009: + Condition: CreateSlot009 + Value: !Ref WindowsInstance009 + ElasticIp009: + Condition: CreateSlot009 + Value: !Ref ElasticIp009 + InstanceId010: + Condition: CreateSlot010 + Value: !Ref WindowsInstance010 + ElasticIp010: + Condition: CreateSlot010 + Value: !Ref ElasticIp010 + InstanceId011: + Condition: CreateSlot011 + Value: !Ref WindowsInstance011 + ElasticIp011: + Condition: CreateSlot011 + Value: !Ref ElasticIp011 + InstanceId012: + Condition: CreateSlot012 + Value: !Ref WindowsInstance012 + ElasticIp012: + Condition: CreateSlot012 + Value: !Ref ElasticIp012 + InstanceId013: + Condition: CreateSlot013 + Value: !Ref WindowsInstance013 + ElasticIp013: + Condition: CreateSlot013 + Value: !Ref ElasticIp013 + InstanceId014: + Condition: CreateSlot014 + Value: !Ref WindowsInstance014 + ElasticIp014: + Condition: CreateSlot014 + Value: !Ref ElasticIp014 + InstanceId015: + Condition: CreateSlot015 + Value: !Ref WindowsInstance015 + ElasticIp015: + Condition: CreateSlot015 + Value: !Ref ElasticIp015 + InstanceId016: + Condition: CreateSlot016 + Value: !Ref WindowsInstance016 + ElasticIp016: + Condition: CreateSlot016 + Value: !Ref ElasticIp016 + InstanceId017: + Condition: CreateSlot017 + Value: !Ref WindowsInstance017 + ElasticIp017: + Condition: CreateSlot017 + Value: !Ref ElasticIp017 + InstanceId018: + Condition: CreateSlot018 + Value: !Ref WindowsInstance018 + ElasticIp018: + Condition: CreateSlot018 + Value: !Ref ElasticIp018 + InstanceId019: + Condition: CreateSlot019 + Value: !Ref WindowsInstance019 + ElasticIp019: + Condition: CreateSlot019 + Value: !Ref ElasticIp019 + InstanceId020: + Condition: CreateSlot020 + Value: !Ref WindowsInstance020 + ElasticIp020: + Condition: CreateSlot020 + Value: !Ref ElasticIp020 diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index 1e57022..173bf8e 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -188,3 +188,32 @@ in **Secrets Manager** console under that prefix. No environment secrets are needed — the office/VPN CIDR was only needed once, to type into the security group's inbound rule in step 2. + +## 7. Launch or delete a VM batch + +Run **Manage Windows A11y EC2** from GitHub Actions after at least one +`windows-a11y-*` AMI is available. For launch: + +- Enter only `stack_suffix`; `anson-test` resolves to the stack + `windows-a11y-anson-test`. +- Choose `instance_count` from 1 through 20. The default is 1. +- Keep or change the `m5.xlarge` instance type and 100 GiB root disk defaults. +- The workflow automatically selects the newest available self-owned + `windows-a11y-*` AMI. + +The stack creates VM names `windows-a11y-anson-test-001` through the selected +count. Every VM receives one CloudFormation-managed Elastic IP and no temporary +public IPv4. The successful Job Summary contains a table of all VM names, +Instance IDs, and Elastic IPs. + +Before launching, make sure the Elastic IP quota in `ap-northeast-1` covers the +selected count plus addresses already allocated in the account. The GitHub OIDC +role used by the `windows-a11y` environment must allow the existing EC2 and +CloudFormation launch operations plus allocating, associating, disassociating, +and releasing EIPs. If any VM or EIP fails to provision, CloudFormation deletes +the new batch instead of preserving a partial result. + +Deletion always targets the whole batch. Select `delete`, enter the same suffix, +and type the complete generated stack name in `confirm_stack_name`. Deleting the +stack terminates every VM in that batch and releases all of its Elastic IPs; an +individual VM cannot be deleted through this workflow. diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index e6f2f23..9999aff 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -20,10 +20,58 @@ def load_cfn(path: Path): return yaml.load(path.read_text(), Loader=CfnLoader) -def test_windows_template_applies_management_tag() -> None: +def test_windows_template_defines_twenty_conditional_vm_eip_slots() -> None: + template = load_cfn(ROOT / "cloudformation/windows-a11y-instance-template.yml") + assert template["Parameters"]["InstanceCount"]["AllowedValues"] == [ + str(value) for value in range(1, 21) + ] + + for index in range(1, 21): + suffix = f"{index:03d}" + condition = f"CreateSlot{suffix}" + instance = template["Resources"][f"WindowsInstance{suffix}"] + eip = template["Resources"][f"ElasticIp{suffix}"] + + assert condition in template["Conditions"] + assert instance["Condition"] == condition + assert eip["Condition"] == condition + assert ( + instance["Properties"]["NetworkInterfaces"][0][ + "AssociatePublicIpAddress" + ] + is False + ) + assert eip["Properties"]["InstanceId"] == f"WindowsInstance{suffix}" + assert template["Outputs"][f"InstanceId{suffix}"]["Condition"] == condition + assert template["Outputs"][f"ElasticIp{suffix}"]["Condition"] == condition + + +def test_windows_batch_resources_have_portal_and_cost_tags() -> None: template = load_cfn(ROOT / "cloudformation/windows-a11y-instance-template.yml") - tags = template["Resources"]["WindowsInstance"]["Properties"]["Tags"] - assert {tag["Key"]: tag["Value"] for tag in tags}["VmPortalManaged"] == "true" + + for index in range(1, 21): + suffix = f"{index:03d}" + instance_tags = { + tag["Key"]: tag["Value"] + for tag in template["Resources"][f"WindowsInstance{suffix}"][ + "Properties" + ]["Tags"] + } + eip_tags = { + tag["Key"]: tag["Value"] + for tag in template["Resources"][f"ElasticIp{suffix}"]["Properties"][ + "Tags" + ] + } + + expected_common = { + "Name": f"${{AWS::StackName}}-{suffix}", + "VmPortalManaged": "true", + "VmPortalStack": "AWS::StackName", + "VmPortalInstanceIndex": suffix, + } + assert instance_tags == expected_common + assert eip_tags == {**expected_common, "VmPortalCreatedAt": "BatchCreatedAt"} def test_windows_launch_workflow_uses_batch_count_and_latest_managed_ami() -> None: @@ -40,6 +88,27 @@ def test_windows_launch_workflow_uses_batch_count_and_latest_managed_ami() -> No assert "reverse(sort_by(Images, &CreationDate))[0].ImageId" in find_ami["run"] +def test_windows_launch_workflow_creates_atomic_batch_and_lists_all_ips() -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/launch-windows-a11y-ec2.yml").read_text() + ) + steps = workflow["jobs"]["launch"]["steps"] + create = next(step for step in steps if step.get("name") == "Create VM batch stack") + assert "aws cloudformation create-stack" in create["run"] + assert "--on-failure DELETE" in create["run"] + assert 'ParameterKey=InstanceCount,ParameterValue="${INSTANCE_COUNT}"' in create["run"] + assert 'ParameterKey=BatchCreatedAt,ParameterValue="${BATCH_CREATED_AT}"' in create["run"] + assert "aws cloudformation deploy" not in create["run"] + + publish = next( + step for step in steps if step.get("name") == "Publish batch connection details" + ) + assert 'seq -w 1 "${INSTANCE_COUNT}"' in publish["run"] + assert "InstanceId${SUFFIX}" in publish["run"] + assert "ElasticIp${SUFFIX}" in publish["run"] + assert "| Name | Instance ID | Elastic IP |" in publish["run"] + + def test_access_policy_restricts_mutation_by_tag() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ From 6250a2a4cecaf6d20dc1e921c6800f40cad1e5c0 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sun, 23 Aug 2026 22:46:46 +0800 Subject: [PATCH 03/12] feat: discover managed Elastic IP metadata Co-authored-by: Codex --- cloudformation/vms-portal-access-template.yml | 5 ++ vms_portal/src/vms_portal/ec2.py | 45 ++++++++++- vms_portal/tests/test_ec2.py | 80 ++++++++++++++++++- vms_portal/tests/test_infrastructure.py | 26 ++++++ 4 files changed, 151 insertions(+), 5 deletions(-) diff --git a/cloudformation/vms-portal-access-template.yml b/cloudformation/vms-portal-access-template.yml index 47362d9..7b550d2 100644 --- a/cloudformation/vms-portal-access-template.yml +++ b/cloudformation/vms-portal-access-template.yml @@ -33,6 +33,11 @@ Resources: Action: - ec2:DescribeInstances Resource: "*" + - Sid: DescribeManagedElasticIps + Effect: Allow + Action: + - ec2:DescribeAddresses + Resource: "*" - Sid: StartStopManagedInstances Effect: Allow Action: diff --git a/vms_portal/src/vms_portal/ec2.py b/vms_portal/src/vms_portal/ec2.py index 037cf68..0852684 100644 --- a/vms_portal/src/vms_portal/ec2.py +++ b/vms_portal/src/vms_portal/ec2.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, replace -from datetime import datetime +from datetime import UTC, datetime from ipaddress import IPv4Address from typing import Any @@ -34,6 +34,8 @@ class VmInstance: instance_type: str state: str launch_time: datetime + eip_allocation_id: str | None = None + eip_created_at: datetime | None = None _ACTIVE_STATES = ["pending", "running", "stopping", "stopped"] @@ -55,6 +57,7 @@ def list_managed(self) -> list[VmInstance]: for page in paginator.paginate(Filters=filters) for vm in _normalize_page(page) ] + instances = self._with_eip_metadata(instances) return sorted(instances, key=lambda vm: (vm.name.casefold(), vm.instance_id)) def find_managed_by_public_ip(self, ip: IPv4Address) -> VmInstance | None: @@ -68,7 +71,7 @@ def find_managed_by_public_ip(self, ip: IPv4Address) -> VmInstance | None: ] if len(instances) > 1: raise VmError("multiple instances returned for one public IP") - return instances[0] if instances else None + return self._with_eip_metadata(instances)[0] if instances else None def start( self, instance_id: str, expected_public_ip: IPv4Address | None = None @@ -98,6 +101,32 @@ def _managed_filters(self) -> list[dict[str, object]]: {"Name": "instance-state-name", "Values": _ACTIVE_STATES}, ] + def _with_eip_metadata(self, instances: list[VmInstance]) -> list[VmInstance]: + response = self._client.describe_addresses( + Filters=[ + {"Name": f"tag:{self._tag_key}", "Values": [self._tag_value]} + ] + ) + metadata: dict[str, tuple[str, datetime | None]] = {} + for address in response.get("Addresses", []): + instance_id = address.get("InstanceId") + allocation_id = address.get("AllocationId") + if not instance_id or not allocation_id: + continue + tags = {tag["Key"]: tag["Value"] for tag in address.get("Tags", [])} + metadata[instance_id] = ( + allocation_id, + _parse_utc_timestamp(tags.get("VmPortalCreatedAt")), + ) + return [ + replace( + vm, + eip_allocation_id=metadata.get(vm.instance_id, (None, None))[0], + eip_created_at=metadata.get(vm.instance_id, (None, None))[1], + ) + for vm in instances + ] + def _revalidate( self, instance_id: str, expected_public_ip: IPv4Address | None ) -> VmInstance: @@ -132,3 +161,15 @@ def _normalize_page(page: dict[str, Any]) -> list[VmInstance]: ) ) return result + + +def _parse_utc_timestamp(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(UTC) diff --git a/vms_portal/tests/test_ec2.py b/vms_portal/tests/test_ec2.py index 2669bb7..fff6178 100644 --- a/vms_portal/tests/test_ec2.py +++ b/vms_portal/tests/test_ec2.py @@ -46,6 +46,23 @@ def instance( } +def managed_address(*, created_at: str = "2026-08-20T00:00:00Z"): + return { + "AllocationId": "eipalloc-1234567890abcdef0", + "AssociationId": "eipassoc-1234567890abcdef0", + "Domain": "vpc", + "InstanceId": "i-1234567890abcdef0", + "NetworkInterfaceId": "eni-1234567890abcdef0", + "NetworkInterfaceOwnerId": "123456789012", + "PrivateIpAddress": "10.0.0.4", + "PublicIp": "198.51.100.9", + "Tags": [ + {"Key": "VmPortalManaged", "Value": "true"}, + {"Key": "VmPortalCreatedAt", "Value": created_at}, + ], + } + + def test_list_managed_uses_tag_and_active_state_filters() -> None: ec2 = client() stubber = Stubber(ec2) @@ -61,13 +78,35 @@ def test_list_managed_uses_tag_and_active_state_filters() -> None: stubber.add_response( "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected ) + stubber.add_response( + "describe_addresses", + {"Addresses": [managed_address()]}, + {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, + ) with stubber: result = Ec2Service(ec2).list_managed() assert [ - (vm.instance_id, vm.name, str(vm.public_ip), vm.state) for vm in result - ] == [("i-1234567890abcdef0", "windows-a11y-demo", "198.51.100.9", "running")] + ( + vm.instance_id, + vm.name, + str(vm.public_ip), + vm.state, + vm.eip_allocation_id, + vm.eip_created_at, + ) + for vm in result + ] == [ + ( + "i-1234567890abcdef0", + "windows-a11y-demo", + "198.51.100.9", + "running", + "eipalloc-1234567890abcdef0", + datetime(2026, 8, 20, tzinfo=UTC), + ) + ] def test_user_lookup_requires_exact_public_ip_and_tag() -> None: @@ -86,11 +125,46 @@ def test_user_lookup_requires_exact_public_ip_and_tag() -> None: stubber.add_response( "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected ) + stubber.add_response( + "describe_addresses", + {"Addresses": [managed_address()]}, + {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, + ) with stubber: found = Ec2Service(ec2).find_managed_by_public_ip(IPv4Address("198.51.100.9")) - assert found is not None and found.instance_id == "i-1234567890abcdef0" + assert found is not None + assert found.instance_id == "i-1234567890abcdef0" + assert found.eip_allocation_id == "eipalloc-1234567890abcdef0" + + +def test_eip_metadata_is_optional_and_malformed_timestamp_is_ignored() -> None: + ec2 = client() + stubber = Stubber(ec2) + expected = { + "Filters": [ + {"Name": "tag:VmPortalManaged", "Values": ["true"]}, + { + "Name": "instance-state-name", + "Values": ["pending", "running", "stopping", "stopped"], + }, + ] + } + stubber.add_response( + "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected + ) + stubber.add_response( + "describe_addresses", + {"Addresses": [managed_address(created_at="not-a-timestamp")]}, + {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, + ) + + with stubber: + result = Ec2Service(ec2).list_managed() + + assert result[0].eip_allocation_id == "eipalloc-1234567890abcdef0" + assert result[0].eip_created_at is None def test_stop_revalidates_tag_and_uses_normal_stop() -> None: diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 9999aff..7f3fbc9 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -128,6 +128,32 @@ def test_access_policy_restricts_mutation_by_tag() -> None: assert describe["Resource"] == "*" +def test_portal_policy_can_describe_eips_but_cannot_provision_resources() -> None: + template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") + statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ + "Statement" + ] + describe_addresses = next( + item for item in statements if item["Action"] == ["ec2:DescribeAddresses"] + ) + assert describe_addresses["Resource"] == "*" + + actions = { + action for statement in statements for action in statement.get("Action", []) + } + assert actions.isdisjoint( + { + "ec2:RunInstances", + "ec2:AllocateAddress", + "ec2:AssociateAddress", + "ec2:ReleaseAddress", + "cloudformation:CreateStack", + "cloudformation:DeleteStack", + "iam:PassRole", + } + ) + + def test_portal_runtime_policy_does_not_duplicate_shared_ecr_access() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ From 535e1a05b56275b61578e41de9954ce88afa41de Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sun, 23 Aug 2026 22:53:02 +0800 Subject: [PATCH 04/12] feat: include Elastic IP estimates in VM costs Co-authored-by: Codex --- ansible_yaml/vms-portal-playbook.yml | 1 + docs/vms-portal-deployment-sop.md | 9 +- vms_portal/src/vms_portal/config.py | 17 ++ vms_portal/src/vms_portal/costs.py | 82 ++++++--- .../src/vms_portal/templates/admin.html | 24 ++- vms_portal/src/vms_portal/templates/user.html | 23 ++- vms_portal/src/vms_portal/web.py | 18 +- vms_portal/tests/test_config.py | 27 +++ vms_portal/tests/test_costs.py | 173 +++++++++++++----- vms_portal/tests/test_infrastructure.py | 1 + vms_portal/tests/test_web.py | 53 +++++- 11 files changed, 335 insertions(+), 93 deletions(-) diff --git a/ansible_yaml/vms-portal-playbook.yml b/ansible_yaml/vms-portal-playbook.yml index 2acec58..784d51f 100644 --- a/ansible_yaml/vms-portal-playbook.yml +++ b/ansible_yaml/vms-portal-playbook.yml @@ -23,6 +23,7 @@ content: | AWS_REGION=ap-northeast-1 AUTH_SECRET_ID={{ auth_secret_id }} + PUBLIC_IPV4_HOURLY_USD=0.005 TRUSTED_PROXY_IPS=127.0.0.1 - name: Write portal Compose file diff --git a/docs/vms-portal-deployment-sop.md b/docs/vms-portal-deployment-sop.md index b9efc00..ed3eec2 100644 --- a/docs/vms-portal-deployment-sop.md +++ b/docs/vms-portal-deployment-sop.md @@ -50,6 +50,7 @@ OIDC role 必須允許 workflow 執行下列範圍: | AWS 權限 | Resource 限制 | 用途 | | --- | --- | --- | | `ec2:DescribeInstances` | `*` | admin 列出 VM、user 依 Public IPv4 查詢,以及每次開關機前重新驗證 tag/IP/狀態。此 API 不支援限制到單一 instance ARN。 | +| `ec2:DescribeAddresses` | `*` | 讀取帶有 `VmPortalManaged=true` 的 EIP,依 Instance ID 對應 VM 並取得估算所需的建立時間 tag。此 API 不支援限制到單一 EIP ARN。 | | `ec2:StartInstances`、`ec2:StopInstances` | 本帳號、本 Region 的 EC2 instance ARN,另要求 `VmPortalManaged=true` | 只允許控制明確交由 Portal 管理的 Windows VM。 | | `secretsmanager:DescribeSecret`、`secretsmanager:GetSecretValue` | 建立部署時指定的單一 Secret ARN | 啟動與定期更新共用登入帳密;Secret 只保留於記憶體 cache。 | | `ce:GetCostAndUsageWithResources` | `*` | 查詢每台 EC2 最近 14 天的 Cost Explorer resource-level cost;此 API 不支援 resource ARN 限制。 | @@ -71,9 +72,11 @@ Linux EC2 不需要 `ec2:*`、`iam:*`、`secretsmanager:*` 或 ECR push 權限 ### 4. DNS、Windows tag 與 Cost Explorer - DNS:將 `vms.coseeing.org` 的 A/AAAA 記錄指向既有 Traefik Linux EC2。 -- Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。 +- Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。由 batch workflow 建立的 EIP 也會帶管理 tag、Instance index 與 `VmPortalCreatedAt`;既有 EIP 缺少建立時間時 Portal 會顯示「估算資料不足」。 - Cost Explorer:用 payer/management account 開啟 **Billing and Cost Management** → **Cost Management preferences** → **Granular data**,啟用 EC2 resource-level data。資料只涵蓋最近 14 天,可能需等待 48 小時,且 granular data/API request 可能產生費用。 +Portal 的 EC2 欄位是 Cost Explorer `UnblendedCost` 實際資料;EIP 欄位不是 Cost Explorer line item,而是依 `VmPortalCreatedAt`、最近 14 天持有小時及 `PUBLIC_IPV4_HOURLY_USD` 計算的估算。部署預設單價為 `0.005` USD/IP/hour。若 AWS 調價,先更新 `ansible_yaml/vms-portal-playbook.yml` 內的環境值再重新部署。VM 停止後 EIP 仍由帳號持有,因此估算會繼續累計。 + 以上三項無法由本 repository 的 deploy workflow 安全代辦。 ## B. 日常部署:全部在 GitHub Actions @@ -103,7 +106,7 @@ Deploy job 會自動完成: 2. 使用 admin 登入:只應列出有 `VmPortalManaged=true` 的 VM。 3. 使用 user 登入:不應直接出現清單;輸入已知 Public IPv4 後才能看到該 VM。 4. 第一次開關機前,人工確認完整 instance ID、Public IPv4、目前狀態與預定動作。 -5. Cost Explorer 尚未準備完成時,畫面顯示「成本資料尚未提供」屬正常情況。 +5. 確認每台 VM 分開顯示 EC2 實際、EIP 估算及合計。Cost Explorer 尚未準備完成時,EC2 顯示「成本資料尚未提供」屬正常情況;有完整 EIP tag 時仍應顯示 EIP 估算,但不顯示合計。 ## D. Rollback 與故障排除 @@ -114,6 +117,6 @@ Rollback:checkout 上一個已知正常 commit,從該 commit 手動執行同 - readiness 503:檢查 Secret JSON schema、instance role 與 IMDSv2 hop limit。 - VM 不出現:檢查 Region、Public IPv4 與 `VmPortalManaged=true`。 - AccessDenied:以 CloudTrail request ID 確認缺少的動作,不要擴大成 `ec2:*`。 -- 成本空白:確認 granular data 已啟用並等待最多 48 小時。 +- EC2 成本空白:確認 granular data 已啟用並等待最多 48 小時。EIP 顯示「估算資料不足」時檢查 EIP 的 `VmPortalCreatedAt` tag 與 `PUBLIC_IPV4_HOURLY_USD` 部署值。 - 帳密輪替未生效:確認 `auth_version` 已增加且 Secret stage 是 `AWSCURRENT`。 - Traefik 502:檢查 container health、`entry` network 與 `/data/entry/traefik.yml`。 diff --git a/vms_portal/src/vms_portal/config.py b/vms_portal/src/vms_portal/config.py index 29b15a4..8d1b8ea 100644 --- a/vms_portal/src/vms_portal/config.py +++ b/vms_portal/src/vms_portal/config.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from dataclasses import dataclass +from decimal import Decimal, InvalidOperation class ConfigurationError(ValueError): @@ -19,6 +20,18 @@ def _positive_int(env: Mapping[str, str], name: str, default: int) -> int: return value +def _non_negative_decimal( + env: Mapping[str, str], name: str, default: str +) -> Decimal: + try: + value = Decimal(env.get(name, default)) + except InvalidOperation as exc: + raise ConfigurationError(f"{name} must be a non-negative decimal") from exc + if not value.is_finite() or value < 0: + raise ConfigurationError(f"{name} must be a non-negative decimal") + return value + + @dataclass(frozen=True, slots=True) class Settings: auth_secret_id: str @@ -28,6 +41,7 @@ class Settings: session_cookie_name: str = "vms_portal_session" trusted_proxy_ips: tuple[str, ...] = ("127.0.0.1", "::1") cost_cache_seconds: int = 21_600 + public_ipv4_hourly_usd: Decimal = Decimal("0.005") @classmethod def from_env(cls, env: Mapping[str, str]) -> Settings: @@ -45,4 +59,7 @@ def from_env(cls, env: Mapping[str, str]) -> Settings: auth_secret_id=secret_id, trusted_proxy_ips=proxy_ips, cost_cache_seconds=_positive_int(env, "COST_CACHE_SECONDS", 21_600), + public_ipv4_hourly_usd=_non_negative_decimal( + env, "PUBLIC_IPV4_HOURLY_USD", "0.005" + ), ) diff --git a/vms_portal/src/vms_portal/costs.py b/vms_portal/src/vms_portal/costs.py index c6627c8..f63aa89 100644 --- a/vms_portal/src/vms_portal/costs.py +++ b/vms_portal/src/vms_portal/costs.py @@ -8,33 +8,49 @@ from botocore.exceptions import ClientError - -class CostUnavailable(RuntimeError): - pass +from .ec2 import VmInstance @dataclass(frozen=True, slots=True) class InstanceCost: instance_id: str - amount: Decimal + ec2_amount: Decimal | None + eip_amount: Decimal | None currency: str estimated: bool retrieved_at: datetime + @property + def total_amount(self) -> Decimal | None: + if self.ec2_amount is None or self.eip_amount is None: + return None + return self.ec2_amount + self.eip_amount + class CostService: - def __init__(self, client: Any, cache_seconds: int = 21_600) -> None: + def __init__( + self, + client: Any, + cache_seconds: int = 21_600, + public_ipv4_hourly_usd: Decimal = Decimal("0.005"), + ) -> None: self._client = client self._cache_seconds = cache_seconds - self._cache_key: frozenset[str] = frozenset() + self._public_ipv4_hourly_usd = public_ipv4_hourly_usd + self._cache_key: frozenset[tuple[str, str | None, datetime | None]] = ( + frozenset() + ) self._cache_at: datetime | None = None self._cache: dict[str, InstanceCost] = {} def get_costs( - self, instance_ids: Sequence[str], now: datetime + self, instances: Sequence[VmInstance], now: datetime ) -> Mapping[str, InstanceCost]: now = now.astimezone(UTC) - key = frozenset(instance_ids) + key = frozenset( + (vm.instance_id, vm.eip_allocation_id, vm.eip_created_at) + for vm in instances + ) if not key: return {} if ( @@ -43,6 +59,7 @@ def get_costs( and (now - self._cache_at).total_seconds() < self._cache_seconds ): return dict(self._cache) + instance_ids = {vm.instance_id for vm in instances} end = now.date() start = end - timedelta(days=14) request: dict[str, Any] = { @@ -56,15 +73,21 @@ def get_costs( "Values": ["Amazon Elastic Compute Cloud - Compute"], } }, - {"Dimensions": {"Key": "RESOURCE_ID", "Values": sorted(key)}}, + { + "Dimensions": { + "Key": "RESOURCE_ID", + "Values": sorted(instance_ids), + } + }, ] }, "GroupBy": [{"Type": "DIMENSION", "Key": "RESOURCE_ID"}], "Metrics": ["UnblendedCost"], } - amounts = {instance_id: Decimal(0) for instance_id in key} - currencies = {instance_id: "USD" for instance_id in key} - estimated = {instance_id: False for instance_id in key} + amounts = {instance_id: Decimal(0) for instance_id in instance_ids} + currencies = {instance_id: "USD" for instance_id in instance_ids} + estimated = {instance_id: False for instance_id in instance_ids} + ec2_available = True try: while True: response = self._client.get_cost_and_usage_with_resources(**request) @@ -83,20 +106,37 @@ def get_costs( if not token: break request["NextPageToken"] = token - except ClientError as exc: - code = exc.response.get("Error", {}).get("Code", "Unknown") - raise CostUnavailable(f"Cost Explorer unavailable: {code}") from exc + except ClientError: + ec2_available = False result = { - instance_id: InstanceCost( - instance_id, - amounts[instance_id], - currencies[instance_id], - estimated[instance_id], + vm.instance_id: InstanceCost( + vm.instance_id, + amounts[vm.instance_id] if ec2_available else None, + _estimate_eip_cost(vm, now, self._public_ipv4_hourly_usd), + currencies[vm.instance_id], + estimated[vm.instance_id], now, ) - for instance_id in key + for vm in instances } self._cache_key = key self._cache_at = now self._cache = result return dict(result) + + +def _estimate_eip_cost( + vm: VmInstance, now: datetime, hourly_rate: Decimal +) -> Decimal | None: + if vm.eip_allocation_id is None: + return Decimal(0) + if vm.eip_created_at is None: + return None + start = max(vm.eip_created_at.astimezone(UTC), now - timedelta(days=14)) + elapsed = now - start + if elapsed.total_seconds() <= 0: + return Decimal(0) + seconds = Decimal(elapsed.days * 86_400 + elapsed.seconds) + ( + Decimal(elapsed.microseconds) / Decimal(1_000_000) + ) + return (seconds / Decimal(3_600)) * hourly_rate diff --git a/vms_portal/src/vms_portal/templates/admin.html b/vms_portal/src/vms_portal/templates/admin.html index b32dac2..e32b72a 100644 --- a/vms_portal/src/vms_portal/templates/admin.html +++ b/vms_portal/src/vms_portal/templates/admin.html @@ -1 +1,23 @@ -{% extends "base.html" %}{% block content %}

所有受管 VM

{% for vm in vms %}{% endfor %}
名稱Instance IDPublic IPv4狀態最近 14 天 EC2 運算成本操作
{{ vm.name }}{{ vm.instance_id }}{{ vm.public_ip or '—' }}{{ vm.state }}{% if vm.instance_id in costs %}{{ costs[vm.instance_id].amount }} {{ costs[vm.instance_id].currency }}{% else %}成本資料尚未提供{% endif %}{% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}狀態轉換中{% endif %}
{% endblock %} +{% extends "base.html" %} +{% block content %} +
+

所有受管 VM

+ + + + {% for vm in vms %} + {% set cost = costs.get(vm.instance_id) %} + + + + + + + + + + + {% endfor %} + +
名稱Instance IDPublic IPv4狀態最近 14 天 EC2 實際成本最近 14 天 EIP 估算成本最近 14 天合計操作
{{ vm.name }}{{ vm.instance_id }}{{ vm.public_ip or '—' }}{{ vm.state }}{% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}{% if not vm.eip_allocation_id %}未綁定受管 EIP{% elif cost and cost.eip_amount is not none %}{{ cost.eip_amount }} {{ cost.currency }}(估算){% else %}估算資料不足{% endif %}{% if cost and cost.total_amount is not none %}{{ cost.total_amount }} {{ cost.currency }}{% else %}合計資料尚未提供{% endif %}{% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}狀態轉換中{% endif %}
+{% endblock %} diff --git a/vms_portal/src/vms_portal/templates/user.html b/vms_portal/src/vms_portal/templates/user.html index b13aa56..2b97f4c 100644 --- a/vms_portal/src/vms_portal/templates/user.html +++ b/vms_portal/src/vms_portal/templates/user.html @@ -1 +1,22 @@ -{% extends "base.html" %}{% block content %}

查詢 VM

{% if error %}

{{ error }}

{% endif %}{% if vm %}

{{ vm.name }}

Instance ID
{{ vm.instance_id }}
Public IPv4
{{ vm.public_ip }}
狀態
{{ vm.state }}
最近 14 天 EC2 運算成本
{% if vm.instance_id in costs %}{{ costs[vm.instance_id].amount }} {{ costs[vm.instance_id].currency }}{% else %}成本資料尚未提供{% endif %}
{% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}

狀態轉換中

{% endif %}
{% endif %}{% endblock %} +{% extends "base.html" %} +{% block content %} +
+

查詢 VM

+
+{% if error %}

{{ error }}

{% endif %} +{% if vm %} +{% set cost = costs.get(vm.instance_id) %} +
+

{{ vm.name }}

+
+
Instance ID
{{ vm.instance_id }}
+
Public IPv4
{{ vm.public_ip }}
+
狀態
{{ vm.state }}
+
最近 14 天 EC2 實際成本
{% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}
+
最近 14 天 EIP 估算成本
{% if not vm.eip_allocation_id %}未綁定受管 EIP{% elif cost and cost.eip_amount is not none %}{{ cost.eip_amount }} {{ cost.currency }}(估算){% else %}估算資料不足{% endif %}
+
最近 14 天合計
{% if cost and cost.total_amount is not none %}{{ cost.total_amount }} {{ cost.currency }}{% else %}合計資料尚未提供{% endif %}
+
+ {% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}

狀態轉換中

{% endif %} +
+{% endif %} +{% endblock %} diff --git a/vms_portal/src/vms_portal/web.py b/vms_portal/src/vms_portal/web.py index de6719c..fd2dc12 100644 --- a/vms_portal/src/vms_portal/web.py +++ b/vms_portal/src/vms_portal/web.py @@ -18,7 +18,7 @@ from .audit import AuditEvent, AuditLogger from .config import Settings -from .costs import CostService, CostUnavailable +from .costs import CostService from .ec2 import Ec2Service, VmError from .secrets import SecretCache, SecretUnavailable from .sessions import ( @@ -52,7 +52,9 @@ def create_app( boto3.client("ec2", region_name=settings.aws_region) ) cost_service = cost_service or CostService( - boto3.client("ce", region_name="us-east-1"), settings.cost_cache_seconds + boto3.client("ce", region_name="us-east-1"), + settings.cost_cache_seconds, + settings.public_ipv4_hourly_usd, ) audit_logger = audit_logger or AuditLogger( logging.getLogger("vms_portal.audit").warning @@ -210,12 +212,7 @@ def home(request: Request): request, "user.html", {"identity": current, "vm": None, "error": None} ) vms = ec2_service.list_managed() - try: - costs = cost_service.get_costs( - [vm.instance_id for vm in vms], datetime.now(UTC) - ) - except CostUnavailable: - costs = {} + costs = cost_service.get_costs(vms, datetime.now(UTC)) return render( request, "admin.html", {"identity": current, "vms": vms, "costs": costs} ) @@ -257,10 +254,7 @@ async def lookup(request: Request): error = None if vm else "找不到符合條件的機器。" costs = {} if vm: - try: - costs = cost_service.get_costs([vm.instance_id], datetime.now(UTC)) - except CostUnavailable: - pass + costs = cost_service.get_costs([vm], datetime.now(UTC)) return render( request, "user.html", diff --git a/vms_portal/tests/test_config.py b/vms_portal/tests/test_config.py index f79c8a8..83c0814 100644 --- a/vms_portal/tests/test_config.py +++ b/vms_portal/tests/test_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from decimal import Decimal + import pytest from vms_portal.config import ConfigurationError, Settings @@ -17,6 +19,7 @@ def test_defaults_are_safe_and_region_is_fixed() -> None: assert settings.managed_tag_value == "true" assert settings.session_cookie_name == "vms_portal_session" assert settings.cost_cache_seconds == 21_600 + assert settings.public_ipv4_hourly_usd == Decimal("0.005") assert settings.trusted_proxy_ips == ("127.0.0.1", "::1") @@ -39,3 +42,27 @@ def test_proxy_ips_are_trimmed_and_empty_entries_removed() -> None: ) assert settings.trusted_proxy_ips == ("127.0.0.1", "172.18.0.2", "::1") + + +def test_public_ipv4_hourly_rate_can_be_overridden() -> None: + settings = Settings.from_env( + { + "AUTH_SECRET_ID": "prod/vms-portal/auth", + "PUBLIC_IPV4_HOURLY_USD": "0.00625", + } + ) + + assert settings.public_ipv4_hourly_usd == Decimal("0.00625") + + +@pytest.mark.parametrize("value", ["-0.001", "NaN", "Infinity", "not-a-price"]) +def test_public_ipv4_hourly_rate_must_be_a_non_negative_finite_decimal( + value: str, +) -> None: + with pytest.raises(ConfigurationError, match="PUBLIC_IPV4_HOURLY_USD"): + Settings.from_env( + { + "AUTH_SECRET_ID": "prod/vms-portal/auth", + "PUBLIC_IPV4_HOURLY_USD": value, + } + ) diff --git a/vms_portal/tests/test_costs.py b/vms_portal/tests/test_costs.py index 5b5b755..f19da7a 100644 --- a/vms_portal/tests/test_costs.py +++ b/vms_portal/tests/test_costs.py @@ -2,10 +2,12 @@ from datetime import UTC, datetime from decimal import Decimal +from ipaddress import IPv4Address import boto3 from botocore.stub import Stubber from vms_portal.costs import CostService +from vms_portal.ec2 import VmInstance def client(): @@ -18,10 +20,27 @@ def client(): ) -def test_costs_use_trailing_fourteen_days_resource_grouping_and_decimal_sum() -> None: - ce = client() - stubber = Stubber(ce) - expected = { +def vm( + instance_id: str, + *, + state: str = "running", + allocation_id: str | None = None, + eip_created_at: datetime | None = None, +) -> VmInstance: + return VmInstance( + instance_id, + f"windows-{instance_id}", + IPv4Address("198.51.100.9"), + "m5.xlarge", + state, + datetime(2026, 8, 1, tzinfo=UTC), + allocation_id, + eip_created_at, + ) + + +def expected_request(instance_ids: list[str]) -> dict[str, object]: + return { "TimePeriod": {"Start": "2026-08-06", "End": "2026-08-20"}, "Granularity": "DAILY", "Filter": { @@ -32,12 +51,22 @@ def test_costs_use_trailing_fourteen_days_resource_grouping_and_decimal_sum() -> "Values": ["Amazon Elastic Compute Cloud - Compute"], } }, - {"Dimensions": {"Key": "RESOURCE_ID", "Values": ["i-a", "i-b"]}}, + { + "Dimensions": { + "Key": "RESOURCE_ID", + "Values": sorted(instance_ids), + } + }, ] }, "GroupBy": [{"Type": "DIMENSION", "Key": "RESOURCE_ID"}], "Metrics": ["UnblendedCost"], } + + +def test_costs_combine_ec2_actual_and_stopped_vm_eip_estimate() -> None: + ce = client() + stubber = Stubber(ce) stubber.add_response( "get_cost_and_usage_with_resources", { @@ -49,42 +78,101 @@ def test_costs_use_trailing_fourteen_days_resource_grouping_and_decimal_sum() -> { "Keys": ["i-a"], "Metrics": { - "UnblendedCost": {"Amount": "0.10", "Unit": "USD"} + "UnblendedCost": {"Amount": "0.30", "Unit": "USD"} }, } ], - }, - { - "TimePeriod": {"Start": "2026-08-07", "End": "2026-08-08"}, - "Estimated": True, - "Groups": [ - { - "Keys": ["i-a"], - "Metrics": { - "UnblendedCost": {"Amount": "0.20", "Unit": "USD"} - }, - }, - { - "Keys": ["i-b"], - "Metrics": { - "UnblendedCost": {"Amount": "1.25", "Unit": "USD"} - }, - }, - ], - }, + } ] }, - expected, + expected_request(["i-a"]), + ) + instance = vm( + "i-a", + state="stopped", + allocation_id="eipalloc-a", + eip_created_at=datetime(2026, 8, 19, tzinfo=UTC), ) + with stubber: + cost = CostService(ce, public_ipv4_hourly_usd=Decimal("0.005")).get_costs( + [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) + )["i-a"] + + assert cost.ec2_amount == Decimal("0.30") + assert cost.eip_amount == Decimal("0.180") + assert cost.total_amount == Decimal("0.480") + + +def test_eip_estimate_is_capped_at_fourteen_days() -> None: + ce = client() + stubber = Stubber(ce) + stubber.add_response( + "get_cost_and_usage_with_resources", + {"ResultsByTime": []}, + expected_request(["i-a"]), + ) + instance = vm( + "i-a", + allocation_id="eipalloc-a", + eip_created_at=datetime(2026, 8, 1, tzinfo=UTC), + ) + + with stubber: + cost = CostService(ce).get_costs( + [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) + )["i-a"] + + assert cost.eip_amount == Decimal("1.680") + + +def test_missing_eip_is_zero_but_missing_eip_timestamp_is_unavailable() -> None: + ce = client() + stubber = Stubber(ce) + stubber.add_response( + "get_cost_and_usage_with_resources", + {"ResultsByTime": []}, + expected_request(["i-no-eip", "i-no-time"]), + ) + instances = [ + vm("i-no-eip"), + vm("i-no-time", allocation_id="eipalloc-no-time"), + ] + with stubber: costs = CostService(ce).get_costs( - ["i-b", "i-a"], datetime(2026, 8, 20, 12, tzinfo=UTC) + instances, datetime(2026, 8, 20, 12, tzinfo=UTC) ) - assert costs["i-a"].amount == Decimal("0.30") - assert costs["i-a"].estimated is True - assert costs["i-b"].amount == Decimal("1.25") + assert costs["i-no-eip"].eip_amount == Decimal(0) + assert costs["i-no-eip"].total_amount == Decimal(0) + assert costs["i-no-time"].eip_amount is None + assert costs["i-no-time"].total_amount is None + + +def test_cost_explorer_failure_keeps_eip_estimate_available() -> None: + ce = client() + stubber = Stubber(ce) + stubber.add_client_error( + "get_cost_and_usage_with_resources", + service_error_code="AccessDeniedException", + service_message="denied", + expected_params=expected_request(["i-a"]), + ) + instance = vm( + "i-a", + allocation_id="eipalloc-a", + eip_created_at=datetime(2026, 8, 19, tzinfo=UTC), + ) + + with stubber: + cost = CostService(ce).get_costs( + [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) + )["i-a"] + + assert cost.ec2_amount is None + assert cost.eip_amount == Decimal("0.180") + assert cost.total_amount is None def test_cost_cache_avoids_second_api_request_for_six_hours() -> None: @@ -93,28 +181,15 @@ def test_cost_cache_avoids_second_api_request_for_six_hours() -> None: stubber.add_response( "get_cost_and_usage_with_resources", {"ResultsByTime": []}, - { - "TimePeriod": {"Start": "2026-08-06", "End": "2026-08-20"}, - "Granularity": "DAILY", - "Filter": { - "And": [ - { - "Dimensions": { - "Key": "SERVICE", - "Values": ["Amazon Elastic Compute Cloud - Compute"], - } - }, - {"Dimensions": {"Key": "RESOURCE_ID", "Values": ["i-a"]}}, - ] - }, - "GroupBy": [{"Type": "DIMENSION", "Key": "RESOURCE_ID"}], - "Metrics": ["UnblendedCost"], - }, + expected_request(["i-a"]), ) service = CostService(ce) + instance = vm("i-a") with stubber: - first = service.get_costs(["i-a"], datetime(2026, 8, 20, 1, tzinfo=UTC)) - second = service.get_costs(["i-a"], datetime(2026, 8, 20, 6, 59, tzinfo=UTC)) + first = service.get_costs([instance], datetime(2026, 8, 20, 1, tzinfo=UTC)) + second = service.get_costs( + [instance], datetime(2026, 8, 20, 6, 59, tzinfo=UTC) + ) assert first == second diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 7f3fbc9..3d149e1 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -177,6 +177,7 @@ def test_deployment_is_non_root_read_only_and_uses_exact_domain() -> None: assert "read_only: true" in playbook assert "no-new-privileges:true" in playbook assert "AUTH_SECRET_ID" in playbook and "secret_data" not in playbook + assert "PUBLIC_IPV4_HOURLY_USD=0.005" in playbook assert "vms.coseeing.org" in traefik diff --git a/vms_portal/tests/test_web.py b/vms_portal/tests/test_web.py index 7a8d9af..318a905 100644 --- a/vms_portal/tests/test_web.py +++ b/vms_portal/tests/test_web.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime +from decimal import Decimal from ipaddress import IPv4Address from argon2 import PasswordHasher @@ -55,6 +56,8 @@ def __init__(self) -> None: "m5.xlarge", "running", datetime(2026, 8, 20, tzinfo=UTC), + "eipalloc-123", + datetime(2026, 8, 20, tzinfo=UTC), ) self.list_calls = 0 self.stop_calls = 0 @@ -75,23 +78,43 @@ def start(self, instance_id, expected_public_ip=None): class FakeCosts: - def get_costs(self, instance_ids, now): + def get_costs(self, vms, now): + return { + vm.instance_id: InstanceCost( + vm.instance_id, + Decimal("1.25"), + Decimal("0.18"), + "USD", + False, + datetime(2026, 8, 20, tzinfo=UTC), + ) + for vm in vms + } + + +class FakeCostsWithoutEc2: + def get_costs(self, vms, now): return { - instance_id: InstanceCost( - instance_id, 1.25, "USD", False, datetime(2026, 8, 20, tzinfo=UTC) + vm.instance_id: InstanceCost( + vm.instance_id, + None, + Decimal("0.18"), + "USD", + False, + datetime(2026, 8, 20, tzinfo=UTC), ) - for instance_id in instance_ids + for vm in vms } -def make_client(): +def make_client(cost_service=None): ec2 = FakeEc2() audit_events = [] app = create_app( Settings.from_env({"AUTH_SECRET_ID": "test"}), secret_cache=FakeSecretCache(), ec2_service=ec2, - cost_service=FakeCosts(), + cost_service=cost_service or FakeCosts(), audit_logger=AuditLogger(audit_events.append), clock=lambda: 1_000.0, ) @@ -144,7 +167,12 @@ def test_admin_home_lists_managed_instances() -> None: assert "198.51.100.9" in response.text assert 'action="/instances/i-123/stop"' in response.text assert 'data-confirm="停止 windows-demo?"' in response.text + assert "最近 14 天 EC2 實際成本" in response.text assert "1.25 USD" in response.text + assert "最近 14 天 EIP 估算成本" in response.text + assert "0.18 USD(估算)" in response.text + assert "最近 14 天合計" in response.text + assert "1.43 USD" in response.text assert ec2.list_calls == 1 @@ -169,6 +197,19 @@ def test_user_home_never_lists_and_exact_ip_lookup_returns_one_vm() -> None: assert 'action="/instances/i-123/stop"' in result.text assert 'name="public_ip" value="198.51.100.9"' in result.text assert "1.25 USD" in result.text + assert "0.18 USD(估算)" in result.text + assert "1.43 USD" in result.text + + +def test_cost_explorer_failure_still_shows_eip_estimate_without_total() -> None: + client, _, _ = make_client(FakeCostsWithoutEc2()) + login(client, "admin", "admin-pass") + + response = client.get("/") + + assert "成本資料尚未提供" in response.text + assert "0.18 USD(估算)" in response.text + assert "合計資料尚未提供" in response.text def test_user_invalid_or_unknown_ip_gets_generic_message() -> None: From 97930b225cc2fca8716ea76b9f5777b1f1aae023 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 22:51:05 +0800 Subject: [PATCH 05/12] feat: remove elastic IPs from Windows VM batches Co-authored-by: Codex --- .github/workflows/launch-windows-a11y-ec2.yml | 11 +- .../windows-a11y-instance-template.yml | 529 ++++-------------- docs/windows-a11y-aws-manual-setup.md | 26 +- vms_portal/src/vms_portal/ec2.py | 69 +-- vms_portal/tests/test_ec2.py | 77 +-- vms_portal/tests/test_infrastructure.py | 34 +- 6 files changed, 187 insertions(+), 559 deletions(-) diff --git a/.github/workflows/launch-windows-a11y-ec2.yml b/.github/workflows/launch-windows-a11y-ec2.yml index 77da4d6..bbf497c 100644 --- a/.github/workflows/launch-windows-a11y-ec2.yml +++ b/.github/workflows/launch-windows-a11y-ec2.yml @@ -133,7 +133,6 @@ jobs: exit 1 fi - BATCH_CREATED_AT=$(date -u +'%Y-%m-%dT%H:%M:%SZ') STACK_ID=$(aws cloudformation create-stack \ --stack-name "${STACK_NAME}" \ --template-body file://cloudformation/windows-a11y-instance-template.yml \ @@ -146,7 +145,6 @@ jobs: ParameterKey=SecurityGroupId,ParameterValue="${{ vars.SECURITY_GROUP_ID }}" \ ParameterKey=InstanceProfileName,ParameterValue="${{ vars.INSTANCE_PROFILE_NAME }}" \ ParameterKey=KeyName,ParameterValue="${{ vars.KEY_NAME }}" \ - ParameterKey=BatchCreatedAt,ParameterValue="${BATCH_CREATED_AT}" \ --on-failure DELETE \ --query StackId \ --output text) @@ -180,13 +178,14 @@ jobs: echo "- Stack: \`${STACK_NAME}\`" echo "- Instance count: \`${INSTANCE_COUNT}\`" echo - echo "| Name | Instance ID | Elastic IP |" - echo "| --- | --- | --- |" + echo "| Name | Instance ID | Private IP | Current public IP |" + echo "| --- | --- | --- | --- |" for INDEX in $(seq -w 1 "${INSTANCE_COUNT}"); do printf -v SUFFIX '%03d' "$((10#${INDEX}))" INSTANCE_ID=$(jq -r --arg key "InstanceId${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") - ELASTIC_IP=$(jq -r --arg key "ElasticIp${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") - echo "| ${STACK_NAME}-${SUFFIX} | \`${INSTANCE_ID}\` | \`${ELASTIC_IP}\` |" + PRIVATE_IP=$(jq -r --arg key "PrivateIp${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") + PUBLIC_IP=$(jq -r --arg key "PublicIp${SUFFIX}" '.[] | select(.OutputKey == $key).OutputValue' <<< "${OUTPUTS}") + echo "| ${STACK_NAME}-${SUFFIX} | \`${INSTANCE_ID}\` | \`${PRIVATE_IP}\` | \`${PUBLIC_IP}\` |" done } >> "$GITHUB_STEP_SUMMARY" diff --git a/cloudformation/windows-a11y-instance-template.yml b/cloudformation/windows-a11y-instance-template.yml index c1f8ecd..4c3aeb2 100644 --- a/cloudformation/windows-a11y-instance-template.yml +++ b/cloudformation/windows-a11y-instance-template.yml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: "2010-09-09" -Description: Atomic batch of 1-20 Windows A11y EC2 instances with managed Elastic IPs +Description: Atomic batch of 1-20 Windows A11y EC2 instances with dynamic public IPs Parameters: AmiId: @@ -29,7 +29,7 @@ Parameters: - "18" - "19" - "20" - Description: Number of VM and Elastic IP pairs to create + Description: Number of VMs to create InstanceType: Type: String Default: m5.xlarge @@ -51,11 +51,6 @@ Parameters: KeyName: Type: AWS::EC2::KeyPair::KeyName Description: EC2 KeyPair for emergency access - BatchCreatedAt: - Type: String - AllowedPattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" - Description: UTC creation timestamp used for Elastic IP cost estimates - Mappings: InstanceCountToSlots: "1": @@ -235,7 +230,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -250,23 +245,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "001" - ElasticIp001: - Type: AWS::EC2::EIP - Condition: CreateSlot001 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance001 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-001" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "001" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance002: Type: AWS::EC2::Instance Condition: CreateSlot002 @@ -280,7 +258,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -295,23 +273,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "002" - ElasticIp002: - Type: AWS::EC2::EIP - Condition: CreateSlot002 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance002 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-002" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "002" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance003: Type: AWS::EC2::Instance Condition: CreateSlot003 @@ -325,7 +286,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -340,23 +301,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "003" - ElasticIp003: - Type: AWS::EC2::EIP - Condition: CreateSlot003 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance003 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-003" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "003" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance004: Type: AWS::EC2::Instance Condition: CreateSlot004 @@ -370,7 +314,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -385,23 +329,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "004" - ElasticIp004: - Type: AWS::EC2::EIP - Condition: CreateSlot004 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance004 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-004" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "004" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance005: Type: AWS::EC2::Instance Condition: CreateSlot005 @@ -415,7 +342,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -430,23 +357,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "005" - ElasticIp005: - Type: AWS::EC2::EIP - Condition: CreateSlot005 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance005 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-005" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "005" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance006: Type: AWS::EC2::Instance Condition: CreateSlot006 @@ -460,7 +370,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -475,23 +385,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "006" - ElasticIp006: - Type: AWS::EC2::EIP - Condition: CreateSlot006 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance006 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-006" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "006" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance007: Type: AWS::EC2::Instance Condition: CreateSlot007 @@ -505,7 +398,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -520,23 +413,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "007" - ElasticIp007: - Type: AWS::EC2::EIP - Condition: CreateSlot007 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance007 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-007" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "007" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance008: Type: AWS::EC2::Instance Condition: CreateSlot008 @@ -550,7 +426,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -565,23 +441,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "008" - ElasticIp008: - Type: AWS::EC2::EIP - Condition: CreateSlot008 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance008 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-008" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "008" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance009: Type: AWS::EC2::Instance Condition: CreateSlot009 @@ -595,7 +454,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -610,23 +469,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "009" - ElasticIp009: - Type: AWS::EC2::EIP - Condition: CreateSlot009 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance009 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-009" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "009" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance010: Type: AWS::EC2::Instance Condition: CreateSlot010 @@ -640,7 +482,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -655,23 +497,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "010" - ElasticIp010: - Type: AWS::EC2::EIP - Condition: CreateSlot010 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance010 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-010" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "010" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance011: Type: AWS::EC2::Instance Condition: CreateSlot011 @@ -685,7 +510,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -700,23 +525,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "011" - ElasticIp011: - Type: AWS::EC2::EIP - Condition: CreateSlot011 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance011 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-011" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "011" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance012: Type: AWS::EC2::Instance Condition: CreateSlot012 @@ -730,7 +538,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -745,23 +553,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "012" - ElasticIp012: - Type: AWS::EC2::EIP - Condition: CreateSlot012 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance012 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-012" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "012" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance013: Type: AWS::EC2::Instance Condition: CreateSlot013 @@ -775,7 +566,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -790,23 +581,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "013" - ElasticIp013: - Type: AWS::EC2::EIP - Condition: CreateSlot013 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance013 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-013" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "013" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance014: Type: AWS::EC2::Instance Condition: CreateSlot014 @@ -820,7 +594,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -835,23 +609,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "014" - ElasticIp014: - Type: AWS::EC2::EIP - Condition: CreateSlot014 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance014 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-014" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "014" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance015: Type: AWS::EC2::Instance Condition: CreateSlot015 @@ -865,7 +622,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -880,23 +637,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "015" - ElasticIp015: - Type: AWS::EC2::EIP - Condition: CreateSlot015 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance015 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-015" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "015" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance016: Type: AWS::EC2::Instance Condition: CreateSlot016 @@ -910,7 +650,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -925,23 +665,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "016" - ElasticIp016: - Type: AWS::EC2::EIP - Condition: CreateSlot016 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance016 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-016" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "016" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance017: Type: AWS::EC2::Instance Condition: CreateSlot017 @@ -955,7 +678,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -970,23 +693,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "017" - ElasticIp017: - Type: AWS::EC2::EIP - Condition: CreateSlot017 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance017 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-017" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "017" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance018: Type: AWS::EC2::Instance Condition: CreateSlot018 @@ -1000,7 +706,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -1015,23 +721,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "018" - ElasticIp018: - Type: AWS::EC2::EIP - Condition: CreateSlot018 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance018 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-018" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "018" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance019: Type: AWS::EC2::Instance Condition: CreateSlot019 @@ -1045,7 +734,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -1060,23 +749,6 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "019" - ElasticIp019: - Type: AWS::EC2::EIP - Condition: CreateSlot019 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance019 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-019" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "019" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt WindowsInstance020: Type: AWS::EC2::Instance Condition: CreateSlot020 @@ -1090,7 +762,7 @@ Resources: SubnetId: !Ref SubnetId GroupSet: - !Ref SecurityGroupId - AssociatePublicIpAddress: false + AssociatePublicIpAddress: true BlockDeviceMappings: - DeviceName: /dev/sda1 Ebs: @@ -1105,142 +777,185 @@ Resources: Value: !Ref "AWS::StackName" - Key: VmPortalInstanceIndex Value: "020" - ElasticIp020: - Type: AWS::EC2::EIP - Condition: CreateSlot020 - Properties: - Domain: vpc - InstanceId: !Ref WindowsInstance020 - Tags: - - Key: Name - Value: !Sub "${AWS::StackName}-020" - - Key: VmPortalManaged - Value: "true" - - Key: VmPortalStack - Value: !Ref "AWS::StackName" - - Key: VmPortalInstanceIndex - Value: "020" - - Key: VmPortalCreatedAt - Value: !Ref BatchCreatedAt Outputs: InstanceId001: Condition: CreateSlot001 Value: !Ref WindowsInstance001 - ElasticIp001: + PrivateIp001: Condition: CreateSlot001 - Value: !Ref ElasticIp001 + Value: !GetAtt WindowsInstance001.PrivateIp + PublicIp001: + Condition: CreateSlot001 + Value: !GetAtt WindowsInstance001.PublicIp InstanceId002: Condition: CreateSlot002 Value: !Ref WindowsInstance002 - ElasticIp002: + PrivateIp002: + Condition: CreateSlot002 + Value: !GetAtt WindowsInstance002.PrivateIp + PublicIp002: Condition: CreateSlot002 - Value: !Ref ElasticIp002 + Value: !GetAtt WindowsInstance002.PublicIp InstanceId003: Condition: CreateSlot003 Value: !Ref WindowsInstance003 - ElasticIp003: + PrivateIp003: Condition: CreateSlot003 - Value: !Ref ElasticIp003 + Value: !GetAtt WindowsInstance003.PrivateIp + PublicIp003: + Condition: CreateSlot003 + Value: !GetAtt WindowsInstance003.PublicIp InstanceId004: Condition: CreateSlot004 Value: !Ref WindowsInstance004 - ElasticIp004: + PrivateIp004: + Condition: CreateSlot004 + Value: !GetAtt WindowsInstance004.PrivateIp + PublicIp004: Condition: CreateSlot004 - Value: !Ref ElasticIp004 + Value: !GetAtt WindowsInstance004.PublicIp InstanceId005: Condition: CreateSlot005 Value: !Ref WindowsInstance005 - ElasticIp005: + PrivateIp005: + Condition: CreateSlot005 + Value: !GetAtt WindowsInstance005.PrivateIp + PublicIp005: Condition: CreateSlot005 - Value: !Ref ElasticIp005 + Value: !GetAtt WindowsInstance005.PublicIp InstanceId006: Condition: CreateSlot006 Value: !Ref WindowsInstance006 - ElasticIp006: + PrivateIp006: Condition: CreateSlot006 - Value: !Ref ElasticIp006 + Value: !GetAtt WindowsInstance006.PrivateIp + PublicIp006: + Condition: CreateSlot006 + Value: !GetAtt WindowsInstance006.PublicIp InstanceId007: Condition: CreateSlot007 Value: !Ref WindowsInstance007 - ElasticIp007: + PrivateIp007: + Condition: CreateSlot007 + Value: !GetAtt WindowsInstance007.PrivateIp + PublicIp007: Condition: CreateSlot007 - Value: !Ref ElasticIp007 + Value: !GetAtt WindowsInstance007.PublicIp InstanceId008: Condition: CreateSlot008 Value: !Ref WindowsInstance008 - ElasticIp008: + PrivateIp008: Condition: CreateSlot008 - Value: !Ref ElasticIp008 + Value: !GetAtt WindowsInstance008.PrivateIp + PublicIp008: + Condition: CreateSlot008 + Value: !GetAtt WindowsInstance008.PublicIp InstanceId009: Condition: CreateSlot009 Value: !Ref WindowsInstance009 - ElasticIp009: + PrivateIp009: + Condition: CreateSlot009 + Value: !GetAtt WindowsInstance009.PrivateIp + PublicIp009: Condition: CreateSlot009 - Value: !Ref ElasticIp009 + Value: !GetAtt WindowsInstance009.PublicIp InstanceId010: Condition: CreateSlot010 Value: !Ref WindowsInstance010 - ElasticIp010: + PrivateIp010: Condition: CreateSlot010 - Value: !Ref ElasticIp010 + Value: !GetAtt WindowsInstance010.PrivateIp + PublicIp010: + Condition: CreateSlot010 + Value: !GetAtt WindowsInstance010.PublicIp InstanceId011: Condition: CreateSlot011 Value: !Ref WindowsInstance011 - ElasticIp011: + PrivateIp011: + Condition: CreateSlot011 + Value: !GetAtt WindowsInstance011.PrivateIp + PublicIp011: Condition: CreateSlot011 - Value: !Ref ElasticIp011 + Value: !GetAtt WindowsInstance011.PublicIp InstanceId012: Condition: CreateSlot012 Value: !Ref WindowsInstance012 - ElasticIp012: + PrivateIp012: Condition: CreateSlot012 - Value: !Ref ElasticIp012 + Value: !GetAtt WindowsInstance012.PrivateIp + PublicIp012: + Condition: CreateSlot012 + Value: !GetAtt WindowsInstance012.PublicIp InstanceId013: Condition: CreateSlot013 Value: !Ref WindowsInstance013 - ElasticIp013: + PrivateIp013: + Condition: CreateSlot013 + Value: !GetAtt WindowsInstance013.PrivateIp + PublicIp013: Condition: CreateSlot013 - Value: !Ref ElasticIp013 + Value: !GetAtt WindowsInstance013.PublicIp InstanceId014: Condition: CreateSlot014 Value: !Ref WindowsInstance014 - ElasticIp014: + PrivateIp014: Condition: CreateSlot014 - Value: !Ref ElasticIp014 + Value: !GetAtt WindowsInstance014.PrivateIp + PublicIp014: + Condition: CreateSlot014 + Value: !GetAtt WindowsInstance014.PublicIp InstanceId015: Condition: CreateSlot015 Value: !Ref WindowsInstance015 - ElasticIp015: + PrivateIp015: + Condition: CreateSlot015 + Value: !GetAtt WindowsInstance015.PrivateIp + PublicIp015: Condition: CreateSlot015 - Value: !Ref ElasticIp015 + Value: !GetAtt WindowsInstance015.PublicIp InstanceId016: Condition: CreateSlot016 Value: !Ref WindowsInstance016 - ElasticIp016: + PrivateIp016: Condition: CreateSlot016 - Value: !Ref ElasticIp016 + Value: !GetAtt WindowsInstance016.PrivateIp + PublicIp016: + Condition: CreateSlot016 + Value: !GetAtt WindowsInstance016.PublicIp InstanceId017: Condition: CreateSlot017 Value: !Ref WindowsInstance017 - ElasticIp017: + PrivateIp017: + Condition: CreateSlot017 + Value: !GetAtt WindowsInstance017.PrivateIp + PublicIp017: Condition: CreateSlot017 - Value: !Ref ElasticIp017 + Value: !GetAtt WindowsInstance017.PublicIp InstanceId018: Condition: CreateSlot018 Value: !Ref WindowsInstance018 - ElasticIp018: + PrivateIp018: Condition: CreateSlot018 - Value: !Ref ElasticIp018 + Value: !GetAtt WindowsInstance018.PrivateIp + PublicIp018: + Condition: CreateSlot018 + Value: !GetAtt WindowsInstance018.PublicIp InstanceId019: Condition: CreateSlot019 Value: !Ref WindowsInstance019 - ElasticIp019: + PrivateIp019: + Condition: CreateSlot019 + Value: !GetAtt WindowsInstance019.PrivateIp + PublicIp019: Condition: CreateSlot019 - Value: !Ref ElasticIp019 + Value: !GetAtt WindowsInstance019.PublicIp InstanceId020: Condition: CreateSlot020 Value: !Ref WindowsInstance020 - ElasticIp020: + PrivateIp020: + Condition: CreateSlot020 + Value: !GetAtt WindowsInstance020.PrivateIp + PublicIp020: Condition: CreateSlot020 - Value: !Ref ElasticIp020 + Value: !GetAtt WindowsInstance020.PublicIp diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index 173bf8e..8b01a3f 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -202,18 +202,22 @@ Run **Manage Windows A11y EC2** from GitHub Actions after at least one `windows-a11y-*` AMI. The stack creates VM names `windows-a11y-anson-test-001` through the selected -count. Every VM receives one CloudFormation-managed Elastic IP and no temporary -public IPv4. The successful Job Summary contains a table of all VM names, -Instance IDs, and Elastic IPs. +count. Every VM receives its stable private IPv4 address and a dynamic public +IPv4 address from the selected public subnet. The successful Job Summary lists +all VM names, Instance IDs, private IPs, and current public IPs. -Before launching, make sure the Elastic IP quota in `ap-northeast-1` covers the -selected count plus addresses already allocated in the account. The GitHub OIDC -role used by the `windows-a11y` environment must allow the existing EC2 and -CloudFormation launch operations plus allocating, associating, disassociating, -and releasing EIPs. If any VM or EIP fails to provision, CloudFormation deletes -the new batch instead of preserving a partial result. +The public IPv4 address can change after a stop/start cycle; use the Portal or +the latest EC2 details instead of treating it as a permanent connection value. +The private IP remains attached to the primary network interface. The GitHub +OIDC role only needs the existing EC2 and CloudFormation launch operations—EIP +allocation permissions are not required. If any VM fails to provision, +CloudFormation deletes the new batch instead of preserving a partial result. + +The pre-existing VM `i-021a0b068258c64d5` was created without a public IP. This +template change does not rebuild it automatically; recreate that VM through the +workflow when direct IPv4 Internet access is required. Deletion always targets the whole batch. Select `delete`, enter the same suffix, and type the complete generated stack name in `confirm_stack_name`. Deleting the -stack terminates every VM in that batch and releases all of its Elastic IPs; an -individual VM cannot be deleted through this workflow. +stack terminates every VM in that batch; an individual VM cannot be deleted +through this workflow. diff --git a/vms_portal/src/vms_portal/ec2.py b/vms_portal/src/vms_portal/ec2.py index 0852684..9916d62 100644 --- a/vms_portal/src/vms_portal/ec2.py +++ b/vms_portal/src/vms_portal/ec2.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, replace -from datetime import UTC, datetime +from datetime import datetime from ipaddress import IPv4Address from typing import Any @@ -30,12 +30,11 @@ class InvalidStateTransition(VmError): class VmInstance: instance_id: str name: str + private_ip: IPv4Address public_ip: IPv4Address | None instance_type: str state: str launch_time: datetime - eip_allocation_id: str | None = None - eip_created_at: datetime | None = None _ACTIVE_STATES = ["pending", "running", "stopping", "stopped"] @@ -57,21 +56,20 @@ def list_managed(self) -> list[VmInstance]: for page in paginator.paginate(Filters=filters) for vm in _normalize_page(page) ] - instances = self._with_eip_metadata(instances) return sorted(instances, key=lambda vm: (vm.name.casefold(), vm.instance_id)) - def find_managed_by_public_ip(self, ip: IPv4Address) -> VmInstance | None: - filters = self._managed_filters() - filters.insert(1, {"Name": "ip-address", "Values": [str(ip)]}) - paginator = self._client.get_paginator("describe_instances") - instances = [ - vm - for page in paginator.paginate(Filters=filters) - for vm in _normalize_page(page) - ] - if len(instances) > 1: - raise VmError("multiple instances returned for one public IP") - return self._with_eip_metadata(instances)[0] if instances else None + def find_managed_by_instance_id(self, instance_id: str) -> VmInstance | None: + response = self._client.describe_instances(InstanceIds=[instance_id]) + instances = _normalize_page(response) + if not instances: + return None + raw = response["Reservations"][0]["Instances"][0] + tags = {tag["Key"]: tag["Value"] for tag in raw.get("Tags", [])} + if tags.get(self._tag_key) != self._tag_value: + return None + if instances[0].state not in _ACTIVE_STATES: + return None + return instances[0] def start( self, instance_id: str, expected_public_ip: IPv4Address | None = None @@ -101,32 +99,6 @@ def _managed_filters(self) -> list[dict[str, object]]: {"Name": "instance-state-name", "Values": _ACTIVE_STATES}, ] - def _with_eip_metadata(self, instances: list[VmInstance]) -> list[VmInstance]: - response = self._client.describe_addresses( - Filters=[ - {"Name": f"tag:{self._tag_key}", "Values": [self._tag_value]} - ] - ) - metadata: dict[str, tuple[str, datetime | None]] = {} - for address in response.get("Addresses", []): - instance_id = address.get("InstanceId") - allocation_id = address.get("AllocationId") - if not instance_id or not allocation_id: - continue - tags = {tag["Key"]: tag["Value"] for tag in address.get("Tags", [])} - metadata[instance_id] = ( - allocation_id, - _parse_utc_timestamp(tags.get("VmPortalCreatedAt")), - ) - return [ - replace( - vm, - eip_allocation_id=metadata.get(vm.instance_id, (None, None))[0], - eip_created_at=metadata.get(vm.instance_id, (None, None))[1], - ) - for vm in instances - ] - def _revalidate( self, instance_id: str, expected_public_ip: IPv4Address | None ) -> VmInstance: @@ -154,6 +126,7 @@ def _normalize_page(page: dict[str, Any]) -> list[VmInstance]: VmInstance( instance_id=raw["InstanceId"], name=tags.get("Name", raw["InstanceId"]), + private_ip=IPv4Address(raw["PrivateIpAddress"]), public_ip=IPv4Address(public_ip) if public_ip else None, instance_type=raw["InstanceType"], state=raw["State"]["Name"], @@ -161,15 +134,3 @@ def _normalize_page(page: dict[str, Any]) -> list[VmInstance]: ) ) return result - - -def _parse_utc_timestamp(value: str | None) -> datetime | None: - if not value: - return None - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return None - return parsed.astimezone(UTC) diff --git a/vms_portal/tests/test_ec2.py b/vms_portal/tests/test_ec2.py index fff6178..16fea8b 100644 --- a/vms_portal/tests/test_ec2.py +++ b/vms_portal/tests/test_ec2.py @@ -1,8 +1,6 @@ from __future__ import annotations from datetime import UTC, datetime -from ipaddress import IPv4Address - import boto3 import pytest from botocore.stub import Stubber @@ -46,23 +44,6 @@ def instance( } -def managed_address(*, created_at: str = "2026-08-20T00:00:00Z"): - return { - "AllocationId": "eipalloc-1234567890abcdef0", - "AssociationId": "eipassoc-1234567890abcdef0", - "Domain": "vpc", - "InstanceId": "i-1234567890abcdef0", - "NetworkInterfaceId": "eni-1234567890abcdef0", - "NetworkInterfaceOwnerId": "123456789012", - "PrivateIpAddress": "10.0.0.4", - "PublicIp": "198.51.100.9", - "Tags": [ - {"Key": "VmPortalManaged", "Value": "true"}, - {"Key": "VmPortalCreatedAt", "Value": created_at}, - ], - } - - def test_list_managed_uses_tag_and_active_state_filters() -> None: ec2 = client() stubber = Stubber(ec2) @@ -78,12 +59,6 @@ def test_list_managed_uses_tag_and_active_state_filters() -> None: stubber.add_response( "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected ) - stubber.add_response( - "describe_addresses", - {"Addresses": [managed_address()]}, - {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, - ) - with stubber: result = Ec2Service(ec2).list_managed() @@ -91,55 +66,40 @@ def test_list_managed_uses_tag_and_active_state_filters() -> None: ( vm.instance_id, vm.name, + str(vm.private_ip), str(vm.public_ip), vm.state, - vm.eip_allocation_id, - vm.eip_created_at, ) for vm in result ] == [ ( "i-1234567890abcdef0", "windows-a11y-demo", + "10.0.0.4", "198.51.100.9", "running", - "eipalloc-1234567890abcdef0", - datetime(2026, 8, 20, tzinfo=UTC), ) ] -def test_user_lookup_requires_exact_public_ip_and_tag() -> None: +def test_user_lookup_requires_exact_instance_id_and_tag() -> None: ec2 = client() stubber = Stubber(ec2) - expected = { - "Filters": [ - {"Name": "tag:VmPortalManaged", "Values": ["true"]}, - {"Name": "ip-address", "Values": ["198.51.100.9"]}, - { - "Name": "instance-state-name", - "Values": ["pending", "running", "stopping", "stopped"], - }, - ] - } - stubber.add_response( - "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected - ) stubber.add_response( - "describe_addresses", - {"Addresses": [managed_address()]}, - {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, + "describe_instances", + {"Reservations": [{"Instances": [instance()]}]}, + {"InstanceIds": ["i-1234567890abcdef0"]}, ) with stubber: - found = Ec2Service(ec2).find_managed_by_public_ip(IPv4Address("198.51.100.9")) + found = Ec2Service(ec2).find_managed_by_instance_id("i-1234567890abcdef0") assert found is not None assert found.instance_id == "i-1234567890abcdef0" - assert found.eip_allocation_id == "eipalloc-1234567890abcdef0" + assert str(found.private_ip) == "10.0.0.4" -def test_eip_metadata_is_optional_and_malformed_timestamp_is_ignored() -> None: +def test_normalization_allows_missing_dynamic_public_ip() -> None: ec2 = client() stubber = Stubber(ec2) expected = { @@ -151,20 +111,15 @@ def test_eip_metadata_is_optional_and_malformed_timestamp_is_ignored() -> None: }, ] } - stubber.add_response( - "describe_instances", {"Reservations": [{"Instances": [instance()]}]}, expected - ) - stubber.add_response( - "describe_addresses", - {"Addresses": [managed_address(created_at="not-a-timestamp")]}, - {"Filters": [{"Name": "tag:VmPortalManaged", "Values": ["true"]}]}, - ) + raw = instance() + raw.pop("PublicIpAddress") + stubber.add_response("describe_instances", {"Reservations": [{"Instances": [raw]}]}, expected) with stubber: result = Ec2Service(ec2).list_managed() - assert result[0].eip_allocation_id == "eipalloc-1234567890abcdef0" - assert result[0].eip_created_at is None + assert str(result[0].private_ip) == "10.0.0.4" + assert result[0].public_ip is None def test_stop_revalidates_tag_and_uses_normal_stop() -> None: @@ -190,9 +145,7 @@ def test_stop_revalidates_tag_and_uses_normal_stop() -> None: ) with stubber: - result = Ec2Service(ec2).stop( - "i-1234567890abcdef0", IPv4Address("198.51.100.9") - ) + result = Ec2Service(ec2).stop("i-1234567890abcdef0") assert result.state == "stopping" diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 3d149e1..eb571a5 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -20,7 +20,7 @@ def load_cfn(path: Path): return yaml.load(path.read_text(), Loader=CfnLoader) -def test_windows_template_defines_twenty_conditional_vm_eip_slots() -> None: +def test_windows_template_defines_twenty_conditional_vm_slots_with_dynamic_ips() -> None: template = load_cfn(ROOT / "cloudformation/windows-a11y-instance-template.yml") assert template["Parameters"]["InstanceCount"]["AllowedValues"] == [ str(value) for value in range(1, 21) @@ -30,23 +30,26 @@ def test_windows_template_defines_twenty_conditional_vm_eip_slots() -> None: suffix = f"{index:03d}" condition = f"CreateSlot{suffix}" instance = template["Resources"][f"WindowsInstance{suffix}"] - eip = template["Resources"][f"ElasticIp{suffix}"] - assert condition in template["Conditions"] assert instance["Condition"] == condition - assert eip["Condition"] == condition assert ( instance["Properties"]["NetworkInterfaces"][0][ "AssociatePublicIpAddress" ] - is False + is True ) - assert eip["Properties"]["InstanceId"] == f"WindowsInstance{suffix}" assert template["Outputs"][f"InstanceId{suffix}"]["Condition"] == condition - assert template["Outputs"][f"ElasticIp{suffix}"]["Condition"] == condition + assert template["Outputs"][f"PrivateIp{suffix}"]["Condition"] == condition + assert template["Outputs"][f"PublicIp{suffix}"]["Condition"] == condition + + assert all( + resource["Type"] != "AWS::EC2::EIP" + for resource in template["Resources"].values() + ) + assert not any(name.startswith("ElasticIp") for name in template["Outputs"]) -def test_windows_batch_resources_have_portal_and_cost_tags() -> None: +def test_windows_batch_resources_have_portal_tags() -> None: template = load_cfn(ROOT / "cloudformation/windows-a11y-instance-template.yml") for index in range(1, 21): @@ -57,13 +60,6 @@ def test_windows_batch_resources_have_portal_and_cost_tags() -> None: "Properties" ]["Tags"] } - eip_tags = { - tag["Key"]: tag["Value"] - for tag in template["Resources"][f"ElasticIp{suffix}"]["Properties"][ - "Tags" - ] - } - expected_common = { "Name": f"${{AWS::StackName}}-{suffix}", "VmPortalManaged": "true", @@ -71,7 +67,6 @@ def test_windows_batch_resources_have_portal_and_cost_tags() -> None: "VmPortalInstanceIndex": suffix, } assert instance_tags == expected_common - assert eip_tags == {**expected_common, "VmPortalCreatedAt": "BatchCreatedAt"} def test_windows_launch_workflow_uses_batch_count_and_latest_managed_ami() -> None: @@ -97,7 +92,7 @@ def test_windows_launch_workflow_creates_atomic_batch_and_lists_all_ips() -> Non assert "aws cloudformation create-stack" in create["run"] assert "--on-failure DELETE" in create["run"] assert 'ParameterKey=InstanceCount,ParameterValue="${INSTANCE_COUNT}"' in create["run"] - assert 'ParameterKey=BatchCreatedAt,ParameterValue="${BATCH_CREATED_AT}"' in create["run"] + assert "BatchCreatedAt" not in create["run"] assert "aws cloudformation deploy" not in create["run"] publish = next( @@ -105,8 +100,9 @@ def test_windows_launch_workflow_creates_atomic_batch_and_lists_all_ips() -> Non ) assert 'seq -w 1 "${INSTANCE_COUNT}"' in publish["run"] assert "InstanceId${SUFFIX}" in publish["run"] - assert "ElasticIp${SUFFIX}" in publish["run"] - assert "| Name | Instance ID | Elastic IP |" in publish["run"] + assert "PrivateIp${SUFFIX}" in publish["run"] + assert "PublicIp${SUFFIX}" in publish["run"] + assert "| Name | Instance ID | Private IP | Current public IP |" in publish["run"] def test_access_policy_restricts_mutation_by_tag() -> None: From 1f547db72690b0d35043a5f046a985457a691ff3 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 22:53:10 +0800 Subject: [PATCH 06/12] feat: persist VM assignment metadata Co-authored-by: Codex --- ansible_yaml/vms-portal-playbook.yml | 12 ++- vms_portal/Dockerfile | 2 +- vms_portal/src/vms_portal/assignments.py | 102 +++++++++++++++++++++++ vms_portal/src/vms_portal/config.py | 8 ++ vms_portal/tests/test_assignments.py | 50 +++++++++++ vms_portal/tests/test_config.py | 19 ++--- vms_portal/tests/test_infrastructure.py | 7 +- 7 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 vms_portal/src/vms_portal/assignments.py create mode 100644 vms_portal/tests/test_assignments.py diff --git a/ansible_yaml/vms-portal-playbook.yml b/ansible_yaml/vms-portal-playbook.yml index 784d51f..e3f2e2a 100644 --- a/ansible_yaml/vms-portal-playbook.yml +++ b/ansible_yaml/vms-portal-playbook.yml @@ -16,6 +16,14 @@ - name: Include common preparation include_tasks: common/pre-common.yml + - name: Create persistent portal data directory + file: + path: /data/vms-portal/data + state: directory + owner: 10001 + group: 10001 + mode: "0750" + - name: Write portal environment copy: dest: "{{ docker_compose_dir }}/.env" @@ -23,7 +31,7 @@ content: | AWS_REGION=ap-northeast-1 AUTH_SECRET_ID={{ auth_secret_id }} - PUBLIC_IPV4_HOURLY_USD=0.005 + ASSIGNMENTS_DB_PATH=/data/vms-portal/data/portal.db TRUSTED_PROXY_IPS=127.0.0.1 - name: Write portal Compose file @@ -39,6 +47,8 @@ read_only: true tmpfs: - /tmp:size=16m,mode=1777 + volumes: + - /data/vms-portal/data:/data/vms-portal/data security_opt: - no-new-privileges:true networks: diff --git a/vms_portal/Dockerfile b/vms_portal/Dockerfile index 1cfb93b..56700ab 100644 --- a/vms_portal/Dockerfile +++ b/vms_portal/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -RUN groupadd --system app && useradd --system --gid app --home /app app +RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid 10001 --home /app app WORKDIR /app COPY pyproject.toml uv.lock ./ COPY src ./src diff --git a/vms_portal/src/vms_portal/assignments.py b/vms_portal/src/vms_portal/assignments.py new file mode 100644 index 0000000..8367b1f --- /dev/null +++ b/vms_portal/src/vms_portal/assignments.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class Assignment: + instance_id: str + assignee: str + updated_at: datetime + updated_by: str + + +class AssignmentRepository: + def __init__(self, database_path: str | Path) -> None: + self._database_path = Path(database_path) + self._database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def upsert( + self, + instance_id: str, + assignee: str, + *, + updated_by: str, + updated_at: datetime, + ) -> Assignment: + timestamp = updated_at.astimezone(UTC) + with self._connect() as connection: + connection.execute( + """ + INSERT INTO vm_assignments (instance_id, assignee, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + assignee = excluded.assignee, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (instance_id, assignee, timestamp.isoformat(), updated_by), + ) + return Assignment(instance_id, assignee, timestamp, updated_by) + + def get(self, instance_id: str) -> Assignment | None: + with self._connect() as connection: + row = connection.execute( + """ + SELECT instance_id, assignee, updated_at, updated_by + FROM vm_assignments + WHERE instance_id = ? + """, + (instance_id,), + ).fetchone() + return _assignment_from_row(row) if row else None + + def get_many(self, instance_ids: Iterable[str]) -> Mapping[str, Assignment]: + requested = tuple(dict.fromkeys(instance_ids)) + if not requested: + return {} + placeholders = ",".join("?" for _ in requested) + with self._connect() as connection: + rows = connection.execute( + f""" + SELECT instance_id, assignee, updated_at, updated_by + FROM vm_assignments + WHERE instance_id IN ({placeholders}) + """, # nosec B608: placeholders are generated, values remain parameterized + requested, + ).fetchall() + return { + assignment.instance_id: assignment + for row in rows + if (assignment := _assignment_from_row(row)) + } + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self._database_path) + + def _initialize(self) -> None: + with self._connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS vm_assignments ( + instance_id TEXT PRIMARY KEY, + assignee TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL + ) + """ + ) + + +def _assignment_from_row(row: tuple[str, str, str, str]) -> Assignment: + return Assignment( + instance_id=row[0], + assignee=row[1], + updated_at=datetime.fromisoformat(row[2]).astimezone(UTC), + updated_by=row[3], + ) diff --git a/vms_portal/src/vms_portal/config.py b/vms_portal/src/vms_portal/config.py index 8d1b8ea..255c60c 100644 --- a/vms_portal/src/vms_portal/config.py +++ b/vms_portal/src/vms_portal/config.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from dataclasses import dataclass from decimal import Decimal, InvalidOperation +from pathlib import Path class ConfigurationError(ValueError): @@ -42,6 +43,7 @@ class Settings: trusted_proxy_ips: tuple[str, ...] = ("127.0.0.1", "::1") cost_cache_seconds: int = 21_600 public_ipv4_hourly_usd: Decimal = Decimal("0.005") + assignments_db_path: Path = Path("/data/vms-portal/data/portal.db") @classmethod def from_env(cls, env: Mapping[str, str]) -> Settings: @@ -55,6 +57,11 @@ def from_env(cls, env: Mapping[str, str]) -> Settings: ) if not proxy_ips: raise ConfigurationError("TRUSTED_PROXY_IPS must contain at least one IP") + assignments_db_path = Path( + env.get("ASSIGNMENTS_DB_PATH", "/data/vms-portal/data/portal.db") + ) + if not assignments_db_path.is_absolute(): + raise ConfigurationError("ASSIGNMENTS_DB_PATH must be an absolute path") return cls( auth_secret_id=secret_id, trusted_proxy_ips=proxy_ips, @@ -62,4 +69,5 @@ def from_env(cls, env: Mapping[str, str]) -> Settings: public_ipv4_hourly_usd=_non_negative_decimal( env, "PUBLIC_IPV4_HOURLY_USD", "0.005" ), + assignments_db_path=assignments_db_path, ) diff --git a/vms_portal/tests/test_assignments.py b/vms_portal/tests/test_assignments.py new file mode 100644 index 0000000..833a5d7 --- /dev/null +++ b/vms_portal/tests/test_assignments.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from vms_portal.assignments import AssignmentRepository + + +def test_upsert_persists_assignment_across_repository_instances(tmp_path) -> None: + database = tmp_path / "portal.db" + updated_at = datetime(2026, 8, 29, 3, 4, 5, tzinfo=UTC) + + AssignmentRepository(database).upsert( + "i-1234567890abcdef0", + "Anson", + updated_by="admin", + updated_at=updated_at, + ) + + assignment = AssignmentRepository(database).get("i-1234567890abcdef0") + assert assignment is not None + assert assignment.instance_id == "i-1234567890abcdef0" + assert assignment.assignee == "Anson" + assert assignment.updated_by == "admin" + assert assignment.updated_at == updated_at + + +def test_upsert_keeps_empty_assignee_as_explicit_metadata(tmp_path) -> None: + repository = AssignmentRepository(tmp_path / "portal.db") + repository.upsert( + "i-1234567890abcdef0", + "", + updated_by="admin", + updated_at=datetime(2026, 8, 29, tzinfo=UTC), + ) + + assignment = repository.get("i-1234567890abcdef0") + assert assignment is not None + assert assignment.assignee == "" + + +def test_get_many_returns_only_requested_existing_assignments(tmp_path) -> None: + repository = AssignmentRepository(tmp_path / "portal.db") + now = datetime(2026, 8, 29, tzinfo=UTC) + repository.upsert("i-one", "One", updated_by="admin", updated_at=now) + repository.upsert("i-two", "Two", updated_by="admin", updated_at=now) + + result = repository.get_many(["i-two", "i-missing"]) + + assert list(result) == ["i-two"] + assert result["i-two"].assignee == "Two" diff --git a/vms_portal/tests/test_config.py b/vms_portal/tests/test_config.py index 83c0814..9f7f35c 100644 --- a/vms_portal/tests/test_config.py +++ b/vms_portal/tests/test_config.py @@ -1,7 +1,5 @@ from __future__ import annotations -from decimal import Decimal - import pytest from vms_portal.config import ConfigurationError, Settings @@ -19,7 +17,7 @@ def test_defaults_are_safe_and_region_is_fixed() -> None: assert settings.managed_tag_value == "true" assert settings.session_cookie_name == "vms_portal_session" assert settings.cost_cache_seconds == 21_600 - assert settings.public_ipv4_hourly_usd == Decimal("0.005") + assert str(settings.assignments_db_path) == "/data/vms-portal/data/portal.db" assert settings.trusted_proxy_ips == ("127.0.0.1", "::1") @@ -44,25 +42,22 @@ def test_proxy_ips_are_trimmed_and_empty_entries_removed() -> None: assert settings.trusted_proxy_ips == ("127.0.0.1", "172.18.0.2", "::1") -def test_public_ipv4_hourly_rate_can_be_overridden() -> None: +def test_assignments_database_path_can_be_overridden() -> None: settings = Settings.from_env( { "AUTH_SECRET_ID": "prod/vms-portal/auth", - "PUBLIC_IPV4_HOURLY_USD": "0.00625", + "ASSIGNMENTS_DB_PATH": "/tmp/portal.db", } ) - assert settings.public_ipv4_hourly_usd == Decimal("0.00625") + assert str(settings.assignments_db_path) == "/tmp/portal.db" -@pytest.mark.parametrize("value", ["-0.001", "NaN", "Infinity", "not-a-price"]) -def test_public_ipv4_hourly_rate_must_be_a_non_negative_finite_decimal( - value: str, -) -> None: - with pytest.raises(ConfigurationError, match="PUBLIC_IPV4_HOURLY_USD"): +def test_assignments_database_path_must_be_absolute() -> None: + with pytest.raises(ConfigurationError, match="ASSIGNMENTS_DB_PATH"): Settings.from_env( { "AUTH_SECRET_ID": "prod/vms-portal/auth", - "PUBLIC_IPV4_HOURLY_USD": value, + "ASSIGNMENTS_DB_PATH": "relative/portal.db", } ) diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index eb571a5..1d56940 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -170,10 +170,15 @@ def test_deployment_is_non_root_read_only_and_uses_exact_domain() -> None: traefik = (ROOT / "ansible_yaml/extra/vms-portal.yml").read_text() assert "USER app" in dockerfile + assert "--uid 10001" in dockerfile + assert "--gid 10001" in dockerfile assert "read_only: true" in playbook assert "no-new-privileges:true" in playbook assert "AUTH_SECRET_ID" in playbook and "secret_data" not in playbook - assert "PUBLIC_IPV4_HOURLY_USD=0.005" in playbook + assert "ASSIGNMENTS_DB_PATH=/data/vms-portal/data/portal.db" in playbook + assert "path: /data/vms-portal/data" in playbook + assert "owner: 10001" in playbook + assert "- /data/vms-portal/data:/data/vms-portal/data" in playbook assert "vms.coseeing.org" in traefik From f0fa4d8fcfb426b9f593316168f81a800de2a7f9 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 22:56:43 +0800 Subject: [PATCH 07/12] feat: manage VM assignments in the portal Co-authored-by: Codex --- vms_portal/src/vms_portal/audit.py | 2 +- .../src/vms_portal/templates/admin.html | 7 +- vms_portal/src/vms_portal/templates/user.html | 8 +- vms_portal/src/vms_portal/web.py | 71 ++++++++-- vms_portal/tests/test_audit.py | 8 +- vms_portal/tests/test_web.py | 129 ++++++++++++++---- 6 files changed, 176 insertions(+), 49 deletions(-) diff --git a/vms_portal/src/vms_portal/audit.py b/vms_portal/src/vms_portal/audit.py index de3a6f6..b14a815 100644 --- a/vms_portal/src/vms_portal/audit.py +++ b/vms_portal/src/vms_portal/audit.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime -_DETAIL_KEYS = frozenset({"category", "error_code", "aws_request_id"}) +_DETAIL_KEYS = frozenset({"assignee", "category", "error_code", "aws_request_id"}) @dataclass(frozen=True, slots=True) diff --git a/vms_portal/src/vms_portal/templates/admin.html b/vms_portal/src/vms_portal/templates/admin.html index e32b72a..ac37330 100644 --- a/vms_portal/src/vms_portal/templates/admin.html +++ b/vms_portal/src/vms_portal/templates/admin.html @@ -3,18 +3,19 @@

所有受管 VM

- + {% for vm in vms %} {% set cost = costs.get(vm.instance_id) %} + {% set assignment = assignments.get(vm.instance_id) %} + + - - {% endfor %} diff --git a/vms_portal/src/vms_portal/templates/user.html b/vms_portal/src/vms_portal/templates/user.html index 2b97f4c..a2616cc 100644 --- a/vms_portal/src/vms_portal/templates/user.html +++ b/vms_portal/src/vms_portal/templates/user.html @@ -2,7 +2,7 @@ {% block content %}

查詢 VM

- + {% if error %}

{{ error }}

{% endif %} {% if vm %} {% set cost = costs.get(vm.instance_id) %} @@ -10,13 +10,11 @@

查詢 VM

{{ vm.name }}

Instance ID
{{ vm.instance_id }}
-
Public IPv4
{{ vm.public_ip }}
+
Private IPv4
{{ vm.private_ip }}
狀態
{{ vm.state }}
最近 14 天 EC2 實際成本
{% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}
-
最近 14 天 EIP 估算成本
{% if not vm.eip_allocation_id %}未綁定受管 EIP{% elif cost and cost.eip_amount is not none %}{{ cost.eip_amount }} {{ cost.currency }}(估算){% else %}估算資料不足{% endif %}
-
最近 14 天合計
{% if cost and cost.total_amount is not none %}{{ cost.total_amount }} {{ cost.currency }}{% else %}合計資料尚未提供{% endif %}
- {% if vm.state == 'running' %}{% elif vm.state == 'stopped' %}{% else %}

狀態轉換中

{% endif %} + {% if vm.state == 'running' %}{% elif vm.state == 'stopped' %}{% else %}

狀態轉換中

{% endif %} {% endif %} {% endblock %} diff --git a/vms_portal/src/vms_portal/web.py b/vms_portal/src/vms_portal/web.py index fd2dc12..dcb909d 100644 --- a/vms_portal/src/vms_portal/web.py +++ b/vms_portal/src/vms_portal/web.py @@ -2,11 +2,11 @@ import logging import os +import re import time import uuid from collections.abc import Callable from datetime import UTC, datetime -from ipaddress import AddressValueError, IPv4Address from pathlib import Path from typing import Any @@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates from .audit import AuditEvent, AuditLogger +from .assignments import AssignmentRepository from .config import Settings from .costs import CostService from .ec2 import Ec2Service, VmError @@ -31,6 +32,7 @@ ) _ROOT = Path(__file__).parent +_INSTANCE_ID = re.compile(r"^i-[0-9a-f]{8,17}$") def create_app( @@ -39,6 +41,7 @@ def create_app( secret_cache: Any | None = None, ec2_service: Any | None = None, cost_service: Any | None = None, + assignment_repository: Any | None = None, audit_logger: AuditLogger | None = None, clock: Callable[[], float] = time.time, ) -> FastAPI: @@ -56,6 +59,9 @@ def create_app( settings.cost_cache_seconds, settings.public_ipv4_hourly_usd, ) + assignment_repository = assignment_repository or AssignmentRepository( + settings.assignments_db_path + ) audit_logger = audit_logger or AuditLogger( logging.getLogger("vms_portal.audit").warning ) @@ -213,8 +219,18 @@ def home(request: Request): ) vms = ec2_service.list_managed() costs = cost_service.get_costs(vms, datetime.now(UTC)) + assignments = assignment_repository.get_many( + vm.instance_id for vm in vms + ) return render( - request, "admin.html", {"identity": current, "vms": vms, "costs": costs} + request, + "admin.html", + { + "identity": current, + "vms": vms, + "costs": costs, + "assignments": assignments, + }, ) @app.post("/logout") @@ -245,12 +261,9 @@ async def lookup(request: Request): if not validate_csrf(current.csrf_token, str(form.get("csrf_token", ""))): return HTMLResponse("Forbidden", status_code=403) vm = None - try: - vm = ec2_service.find_managed_by_public_ip( - IPv4Address(str(form.get("public_ip", ""))) - ) - except (AddressValueError, ValueError): - pass + supplied_id = str(form.get("instance_id", "")).strip() + if _INSTANCE_ID.fullmatch(supplied_id): + vm = ec2_service.find_managed_by_instance_id(supplied_id) error = None if vm else "找不到符合條件的機器。" costs = {} if vm: @@ -261,6 +274,42 @@ async def lookup(request: Request): {"identity": current, "vm": vm, "error": error, "costs": costs}, ) + @app.post("/instances/{instance_id}/assignment") + async def update_assignment(instance_id: str, request: Request): + current = identity(request) + if current is None: + return RedirectResponse("/login", status_code=303) + if current.role != "admin": + return HTMLResponse("Forbidden", status_code=403) + form = await request.form() + if not validate_csrf(current.csrf_token, str(form.get("csrf_token", ""))): + return HTMLResponse("Forbidden", status_code=403) + vm = ec2_service.find_managed_by_instance_id(instance_id) + if vm is None: + return HTMLResponse("Not found", status_code=404) + assignee = str(form.get("assignee", "")).strip() + if len(assignee) > 200: + return HTMLResponse("Invalid assignment", status_code=422) + assignment_repository.upsert( + instance_id, + assignee, + updated_by=current.username, + updated_at=datetime.fromtimestamp(clock(), UTC), + ) + audit_logger.emit( + AuditEvent( + "vm.assignment.updated", + "succeeded", + str(uuid.uuid4()), + current.username, + current.role, + request.client.host if request.client else "unknown", + instance_id, + details={"assignee": assignee}, + ) + ) + return RedirectResponse("/", status_code=303) + @app.post("/instances/{instance_id}/{action}") async def power(instance_id: str, action: str, request: Request): current = identity(request) @@ -269,9 +318,6 @@ async def power(instance_id: str, action: str, request: Request): form = await request.form() if not validate_csrf(current.csrf_token, str(form.get("csrf_token", ""))): return HTMLResponse("Forbidden", status_code=403) - expected_ip = ( - IPv4Address(str(form["public_ip"])) if current.role == "user" else None - ) if action not in {"start", "stop"}: return HTMLResponse("Not found", status_code=404) request_id = str(uuid.uuid4()) @@ -284,11 +330,10 @@ async def power(instance_id: str, action: str, request: Request): current.role, source_ip, instance_id, - str(expected_ip) if expected_ip else None, ) audit_logger.emit(accepted) try: - getattr(ec2_service, action)(instance_id, expected_ip) + getattr(ec2_service, action)(instance_id) except VmError: audit_logger.emit( AuditEvent( diff --git a/vms_portal/tests/test_audit.py b/vms_portal/tests/test_audit.py index d3cd1da..1868d08 100644 --- a/vms_portal/tests/test_audit.py +++ b/vms_portal/tests/test_audit.py @@ -20,11 +20,15 @@ def test_audit_serializes_only_allowlisted_fields() -> None: instance_id="i-123", public_ip="198.51.100.9", previous_state="running", - details={"password": "must-not-log", "category": "accepted"}, + details={ + "password": "must-not-log", + "category": "accepted", + "assignee": "Anson", + }, ) ) parsed = json.loads(emitted[0]) assert parsed["event"] == "vm.stop.accepted" - assert parsed["details"] == {"category": "accepted"} + assert parsed["details"] == {"assignee": "Anson", "category": "accepted"} assert "must-not-log" not in emitted[0] diff --git a/vms_portal/tests/test_web.py b/vms_portal/tests/test_web.py index 318a905..de2a694 100644 --- a/vms_portal/tests/test_web.py +++ b/vms_portal/tests/test_web.py @@ -8,6 +8,7 @@ from argon2.exceptions import VerificationError from fastapi.testclient import TestClient from vms_portal.audit import AuditLogger +from vms_portal.assignments import Assignment from vms_portal.config import Settings from vms_portal.costs import InstanceCost from vms_portal.ec2 import VmInstance @@ -50,24 +51,25 @@ def verify_password(self, username, password): class FakeEc2: def __init__(self) -> None: self.vm = VmInstance( - "i-123", + "i-1234567890abcdef0", "windows-demo", + IPv4Address("10.0.0.4"), IPv4Address("198.51.100.9"), "m5.xlarge", "running", datetime(2026, 8, 20, tzinfo=UTC), - "eipalloc-123", - datetime(2026, 8, 20, tzinfo=UTC), ) self.list_calls = 0 self.stop_calls = 0 + self.lookup_calls = [] def list_managed(self): self.list_calls += 1 return [self.vm] - def find_managed_by_public_ip(self, ip): - return self.vm if ip == self.vm.public_ip else None + def find_managed_by_instance_id(self, instance_id): + self.lookup_calls.append(instance_id) + return self.vm if instance_id == self.vm.instance_id else None def stop(self, instance_id, expected_public_ip=None): self.stop_calls += 1 @@ -107,7 +109,29 @@ def get_costs(self, vms, now): } -def make_client(cost_service=None): +class FakeAssignments: + def __init__(self) -> None: + self.values = { + "i-1234567890abcdef0": Assignment( + "i-1234567890abcdef0", + "Original owner", + datetime(2026, 8, 20, tzinfo=UTC), + "admin", + ) + } + self.upsert_calls = [] + + def get_many(self, instance_ids): + return {key: self.values[key] for key in instance_ids if key in self.values} + + def upsert(self, instance_id, assignee, *, updated_by, updated_at): + self.upsert_calls.append((instance_id, assignee, updated_by)) + assignment = Assignment(instance_id, assignee, updated_at, updated_by) + self.values[instance_id] = assignment + return assignment + + +def make_client(cost_service=None, assignment_repository=None): ec2 = FakeEc2() audit_events = [] app = create_app( @@ -115,6 +139,7 @@ def make_client(cost_service=None): secret_cache=FakeSecretCache(), ec2_service=ec2, cost_service=cost_service or FakeCosts(), + assignment_repository=assignment_repository or FakeAssignments(), audit_logger=AuditLogger(audit_events.append), clock=lambda: 1_000.0, ) @@ -164,24 +189,24 @@ def test_admin_home_lists_managed_instances() -> None: assert response.status_code == 200 assert "vms_portal_session=" in response.headers["set-cookie"] assert "windows-demo" in response.text + assert "i-1234567890abcdef0" in response.text + assert "Original owner" in response.text + assert "10.0.0.4" in response.text assert "198.51.100.9" in response.text - assert 'action="/instances/i-123/stop"' in response.text + assert 'action="/instances/i-1234567890abcdef0/stop"' in response.text assert 'data-confirm="停止 windows-demo?"' in response.text assert "最近 14 天 EC2 實際成本" in response.text assert "1.25 USD" in response.text - assert "最近 14 天 EIP 估算成本" in response.text - assert "0.18 USD(估算)" in response.text - assert "最近 14 天合計" in response.text - assert "1.43 USD" in response.text + assert "EIP" not in response.text assert ec2.list_calls == 1 -def test_user_home_never_lists_and_exact_ip_lookup_returns_one_vm() -> None: +def test_user_home_never_lists_and_exact_instance_id_lookup_returns_one_vm() -> None: client, ec2, _ = make_client() login(client, "user", "user-pass") home = client.get("/") - assert "輸入 Public IPv4" in home.text + assert "輸入 Instance ID" in home.text assert "windows-demo" not in home.text assert ec2.list_calls == 0 @@ -190,29 +215,32 @@ def test_user_home_never_lists_and_exact_ip_lookup_returns_one_vm() -> None: or client.cookies["vms_portal_session_csrf"] ) result = client.post( - "/lookup", data={"public_ip": "198.51.100.9", "csrf_token": csrf} + "/lookup", + data={"instance_id": "i-1234567890abcdef0", "csrf_token": csrf}, ) assert result.status_code == 200 assert "windows-demo" in result.text - assert 'action="/instances/i-123/stop"' in result.text - assert 'name="public_ip" value="198.51.100.9"' in result.text + assert "i-1234567890abcdef0" in result.text + assert "10.0.0.4" in result.text + assert "198.51.100.9" not in result.text + assert "Original owner" not in result.text + assert 'action="/instances/i-1234567890abcdef0/stop"' in result.text + assert 'name="public_ip"' not in result.text assert "1.25 USD" in result.text - assert "0.18 USD(估算)" in result.text - assert "1.43 USD" in result.text + assert "EIP" not in result.text -def test_cost_explorer_failure_still_shows_eip_estimate_without_total() -> None: +def test_cost_explorer_failure_shows_unavailable_without_eip_values() -> None: client, _, _ = make_client(FakeCostsWithoutEc2()) login(client, "admin", "admin-pass") response = client.get("/") assert "成本資料尚未提供" in response.text - assert "0.18 USD(估算)" in response.text - assert "合計資料尚未提供" in response.text + assert "EIP" not in response.text -def test_user_invalid_or_unknown_ip_gets_generic_message() -> None: +def test_user_invalid_or_unknown_instance_id_gets_generic_message() -> None: client, _, _ = make_client() login(client, "user", "user-pass") home = client.get("/") @@ -222,10 +250,10 @@ def test_user_invalid_or_unknown_ip_gets_generic_message() -> None: ) invalid = client.post( - "/lookup", data={"public_ip": "not-an-ip", "csrf_token": csrf} + "/lookup", data={"instance_id": "not-an-id", "csrf_token": csrf} ) missing = client.post( - "/lookup", data={"public_ip": "203.0.113.8", "csrf_token": csrf} + "/lookup", data={"instance_id": "i-00000000000000000", "csrf_token": csrf} ) assert "找不到符合條件的機器" in invalid.text @@ -255,10 +283,61 @@ def test_stop_writes_audit_before_and_after_mutation() -> None: csrf = client.cookies["vms_portal_session_csrf"] response = client.post( - "/instances/i-123/stop", data={"csrf_token": csrf}, follow_redirects=False + "/instances/i-1234567890abcdef0/stop", + data={"csrf_token": csrf}, + follow_redirects=False, ) assert response.status_code == 303 assert ec2.stop_calls == 1 assert '"event":"vm.stop.accepted"' in events[-2] assert '"event":"vm.stop.succeeded"' in events[-1] + + +def test_admin_can_update_assignment_with_csrf_and_audit() -> None: + assignments = FakeAssignments() + client, ec2, events = make_client(assignment_repository=assignments) + login(client, "admin", "admin-pass") + client.get("/") + csrf = client.cookies["vms_portal_session_csrf"] + + response = client.post( + "/instances/i-1234567890abcdef0/assignment", + data={"csrf_token": csrf, "assignee": "Anson"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert ec2.lookup_calls == ["i-1234567890abcdef0"] + assert assignments.upsert_calls == [ + ("i-1234567890abcdef0", "Anson", "admin") + ] + assert '"event":"vm.assignment.updated"' in events[-1] + assert '"assignee":"Anson"' in events[-1] + + +def test_assignment_update_is_admin_only_and_requires_csrf() -> None: + assignments = FakeAssignments() + client, _, _ = make_client(assignment_repository=assignments) + login(client, "admin", "admin-pass") + assert ( + client.post( + "/instances/i-1234567890abcdef0/assignment", + data={"csrf_token": "bad", "assignee": "Anson"}, + ).status_code + == 403 + ) + + client, _, _ = make_client(assignment_repository=assignments) + login(client, "user", "user-pass") + home = client.get("/") + csrf = home.cookies.get("vms_portal_session_csrf") or client.cookies[ + "vms_portal_session_csrf" + ] + response = client.post( + "/instances/i-1234567890abcdef0/assignment", + data={"csrf_token": csrf, "assignee": "Anson"}, + ) + + assert response.status_code == 403 + assert assignments.upsert_calls == [] From 0ce6d11705bb9aa707ede689140b9aa24dc1f19e Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 22:59:03 +0800 Subject: [PATCH 08/12] feat: stop managed Windows VMs every night Co-authored-by: Codex --- .github/workflows/deploy-vms-portal.yml | 23 ++++ cloudformation/vms-portal-access-template.yml | 101 ++++++++++++++++++ .../vms-portal-foundation-template.yml | 28 +++++ lambda/windows_vm_shutdown/lambda_function.py | 45 ++++++++ .../tests/test_lambda_function.py | 85 +++++++++++++++ vms_portal/tests/test_infrastructure.py | 37 +++++++ 6 files changed, 319 insertions(+) create mode 100644 cloudformation/vms-portal-foundation-template.yml create mode 100644 lambda/windows_vm_shutdown/lambda_function.py create mode 100644 lambda/windows_vm_shutdown/tests/test_lambda_function.py diff --git a/.github/workflows/deploy-vms-portal.yml b/.github/workflows/deploy-vms-portal.yml index fcf9956..ed2d946 100644 --- a/.github/workflows/deploy-vms-portal.yml +++ b/.github/workflows/deploy-vms-portal.yml @@ -49,6 +49,7 @@ jobs: ECR_REPOSITORY: vms-portal IMAGE_TAG: ${{ github.sha }} ACCESS_STACK_NAME: vms-portal-access + FOUNDATION_STACK_NAME: vms-portal-foundation RUNTIME_ROLE_NAME: coseeing-ec2-common AUTH_SECRET_ID: ${{ inputs.auth_secret_id }} steps: @@ -70,6 +71,25 @@ jobs: --query ARN \ --output text) + aws cloudformation deploy \ + --stack-name "$FOUNDATION_STACK_NAME" \ + --template-file cloudformation/vms-portal-foundation-template.yml \ + --no-fail-on-empty-changeset + + ARTIFACTS_BUCKET=$(aws cloudformation describe-stacks \ + --stack-name "$FOUNDATION_STACK_NAME" \ + --query 'Stacks[0].Outputs[?OutputKey==`DeploymentArtifactsBucketName`].OutputValue' \ + --output text) + SHUTDOWN_CODE_KEY="lambda/windows-vm-shutdown/${GITHUB_SHA}.zip" + zip -X -j /tmp/windows-vm-shutdown.zip \ + lambda/windows_vm_shutdown/lambda_function.py + SHUTDOWN_CODE_VERSION=$(aws s3api put-object \ + --bucket "$ARTIFACTS_BUCKET" \ + --key "$SHUTDOWN_CODE_KEY" \ + --body /tmp/windows-vm-shutdown.zip \ + --query VersionId \ + --output text) + if ! aws ecr describe-repositories \ --repository-names "$ECR_REPOSITORY" >/dev/null 2>&1; then aws ecr create-repository \ @@ -86,6 +106,9 @@ jobs: ExistingRoleName="$RUNTIME_ROLE_NAME" \ AuthSecretArn="$SECRET_ARN" \ LogRetentionDays=90 \ + ShutdownCodeS3Bucket="$ARTIFACTS_BUCKET" \ + ShutdownCodeS3Key="$SHUTDOWN_CODE_KEY" \ + ShutdownCodeS3Version="$SHUTDOWN_CODE_VERSION" \ --no-fail-on-empty-changeset STACK_OUTPUTS=$(aws cloudformation describe-stacks \ diff --git a/cloudformation/vms-portal-access-template.yml b/cloudformation/vms-portal-access-template.yml index 7b550d2..beae3ad 100644 --- a/cloudformation/vms-portal-access-template.yml +++ b/cloudformation/vms-portal-access-template.yml @@ -11,6 +11,12 @@ Parameters: LogRetentionDays: Type: Number Default: 90 + ShutdownCodeS3Bucket: + Type: String + ShutdownCodeS3Key: + Type: String + ShutdownCodeS3Version: + Type: String Resources: AuditLogGroup: @@ -19,6 +25,101 @@ Resources: LogGroupName: /coseeing/vms-portal RetentionInDays: !Ref LogRetentionDays + ShutdownDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + KmsMasterKeyId: alias/aws/sqs + MessageRetentionPeriod: 1209600 + + ShutdownLambdaRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + ShutdownLambdaPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: vms-portal-nightly-shutdown + Roles: + - !Ref ShutdownLambdaRole + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: DescribeManagedInstances + Effect: Allow + Action: + - ec2:DescribeInstances + Resource: "*" + - Sid: StopManagedInstances + Effect: Allow + Action: + - ec2:StopInstances + Resource: !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/* + Condition: + StringEquals: + aws:ResourceTag/VmPortalManaged: "true" + + ShutdownLambda: + Type: AWS::Lambda::Function + Properties: + Runtime: python3.13 + Handler: lambda_function.lambda_handler + Role: !GetAtt ShutdownLambdaRole.Arn + Timeout: 60 + MemorySize: 128 + Code: + S3Bucket: !Ref ShutdownCodeS3Bucket + S3Key: !Ref ShutdownCodeS3Key + S3ObjectVersion: !Ref ShutdownCodeS3Version + + ShutdownSchedulerRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: scheduler.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: invoke-nightly-shutdown + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: lambda:InvokeFunction + Resource: !GetAtt ShutdownLambda.Arn + - Effect: Allow + Action: sqs:SendMessage + Resource: !GetAtt ShutdownDeadLetterQueue.Arn + + NightlyShutdownSchedule: + Type: AWS::Scheduler::Schedule + Properties: + Description: Stop all running VmPortalManaged Windows instances every night + ScheduleExpression: cron(0 1 * * ? *) + ScheduleExpressionTimezone: Asia/Taipei + FlexibleTimeWindow: + Mode: "OFF" + State: ENABLED + Target: + Arn: !GetAtt ShutdownLambda.Arn + RoleArn: !GetAtt ShutdownSchedulerRole.Arn + RetryPolicy: + MaximumEventAgeInSeconds: 3600 + MaximumRetryAttempts: 3 + DeadLetterConfig: + Arn: !GetAtt ShutdownDeadLetterQueue.Arn + PortalPolicy: Type: AWS::IAM::Policy Properties: diff --git a/cloudformation/vms-portal-foundation-template.yml b/cloudformation/vms-portal-foundation-template.yml new file mode 100644 index 0000000..370c7b8 --- /dev/null +++ b/cloudformation/vms-portal-foundation-template.yml @@ -0,0 +1,28 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Durable storage and cost reporting foundation for the Windows VM portal + +Resources: + DeploymentArtifactsBucket: + Type: AWS::S3::Bucket + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: ExpireOldLambdaArtifacts + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 30 + +Outputs: + DeploymentArtifactsBucketName: + Value: !Ref DeploymentArtifactsBucket diff --git a/lambda/windows_vm_shutdown/lambda_function.py b/lambda/windows_vm_shutdown/lambda_function.py new file mode 100644 index 0000000..0d622da --- /dev/null +++ b/lambda/windows_vm_shutdown/lambda_function.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +import logging + +import boto3 + +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.INFO) +_BATCH_SIZE = 1000 + + +def stop_managed_instances(ec2_client): + paginator = ec2_client.get_paginator("describe_instances") + instance_ids = [ + instance["InstanceId"] + for page in paginator.paginate( + Filters=[ + {"Name": "tag:VmPortalManaged", "Values": ["true"]}, + {"Name": "instance-state-name", "Values": ["running"]}, + ] + ) + for reservation in page.get("Reservations", []) + for instance in reservation.get("Instances", []) + ] + stopped = 0 + for offset in range(0, len(instance_ids), _BATCH_SIZE): + batch = instance_ids[offset : offset + _BATCH_SIZE] + try: + ec2_client.stop_instances(InstanceIds=batch) + except Exception: + LOGGER.exception( + "managed VM shutdown failed", + extra={"matched": len(instance_ids), "stopped": stopped}, + ) + raise + stopped += len(batch) + result = {"matched": len(instance_ids), "stopped": stopped} + LOGGER.info("managed VM shutdown complete %s", json.dumps(result, sort_keys=True)) + return result + + +def lambda_handler(event, context): + del event, context + return stop_managed_instances(boto3.client("ec2")) diff --git a/lambda/windows_vm_shutdown/tests/test_lambda_function.py b/lambda/windows_vm_shutdown/tests/test_lambda_function.py new file mode 100644 index 0000000..002c158 --- /dev/null +++ b/lambda/windows_vm_shutdown/tests/test_lambda_function.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +MODULE_PATH = Path(__file__).parents[1] / "lambda_function.py" +SPEC = importlib.util.spec_from_file_location("windows_vm_shutdown", MODULE_PATH) +module = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(module) + + +class FakePaginator: + def __init__(self, pages): + self.pages = pages + self.calls = [] + + def paginate(self, **kwargs): + self.calls.append(kwargs) + return self.pages + + +class FakeEc2: + def __init__(self, pages): + self.paginator = FakePaginator(pages) + self.stop_calls = [] + self.error = None + + def get_paginator(self, name): + assert name == "describe_instances" + return self.paginator + + def stop_instances(self, **kwargs): + self.stop_calls.append(kwargs) + if self.error: + raise self.error + + +def page(*instance_ids): + return { + "Reservations": [ + { + "Instances": [ + {"InstanceId": instance_id} for instance_id in instance_ids + ] + } + ] + } + + +def test_no_running_managed_instances_is_success() -> None: + ec2 = FakeEc2([]) + + result = module.stop_managed_instances(ec2) + + assert result == {"matched": 0, "stopped": 0} + assert ec2.stop_calls == [] + assert ec2.paginator.calls == [ + { + "Filters": [ + {"Name": "tag:VmPortalManaged", "Values": ["true"]}, + {"Name": "instance-state-name", "Values": ["running"]}, + ] + } + ] + + +def test_stops_all_instances_in_bounded_batches() -> None: + instance_ids = [f"i-{index:017x}" for index in range(1001)] + ec2 = FakeEc2([page(*instance_ids)]) + + result = module.stop_managed_instances(ec2) + + assert result == {"matched": 1001, "stopped": 1001} + assert [len(call["InstanceIds"]) for call in ec2.stop_calls] == [1000, 1] + + +def test_stop_failure_is_propagated() -> None: + ec2 = FakeEc2([page("i-1234567890abcdef0")]) + ec2.error = RuntimeError("stop failed") + + with pytest.raises(RuntimeError, match="stop failed"): + module.stop_managed_instances(ec2) diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 1d56940..c127d67 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -124,6 +124,43 @@ def test_access_policy_restricts_mutation_by_tag() -> None: assert describe["Resource"] == "*" +def test_scheduler_stops_managed_windows_vms_at_one_am_taipei() -> None: + template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") + schedule = template["Resources"]["NightlyShutdownSchedule"] + assert schedule["Type"] == "AWS::Scheduler::Schedule" + assert schedule["Properties"]["ScheduleExpression"] == "cron(0 1 * * ? *)" + assert schedule["Properties"]["ScheduleExpressionTimezone"] == "Asia/Taipei" + assert schedule["Properties"]["FlexibleTimeWindow"] == {"Mode": "OFF"} + assert schedule["Properties"]["Target"]["RetryPolicy"]["MaximumRetryAttempts"] > 0 + assert "DeadLetterConfig" in schedule["Properties"]["Target"] + + policy = template["Resources"]["ShutdownLambdaPolicy"]["Properties"][ + "PolicyDocument" + ]["Statement"] + stop = next(item for item in policy if "ec2:StopInstances" in item["Action"]) + assert stop["Condition"]["StringEquals"][ + "aws:ResourceTag/VmPortalManaged" + ] == "true" + describe = next(item for item in policy if item["Action"] == ["ec2:DescribeInstances"]) + assert describe["Resource"] == "*" + + +def test_deploy_workflow_uploads_versioned_lambda_before_access_stack() -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/deploy-vms-portal.yml").read_text() + ) + steps = workflow["jobs"]["deploy"]["steps"] + prepare = next(step for step in steps if step.get("name") == "Prepare AWS infrastructure") + run = prepare["run"] + assert "vms-portal-foundation-template.yml" in run + assert "lambda/windows_vm_shutdown/lambda_function.py" in run + assert "aws s3api put-object" in run + assert "ShutdownCodeS3Version=" in run + assert run.index("vms-portal-foundation-template.yml") < run.index( + "vms-portal-access-template.yml" + ) + + def test_portal_policy_can_describe_eips_but_cannot_provision_resources() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ From dd53414d60d054c09e33cd27443adf092a134ed1 Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 23:02:39 +0800 Subject: [PATCH 09/12] feat: provision CUR cost reporting for the portal Co-authored-by: Codex --- .github/workflows/deploy-vms-portal.yml | 23 ++- cloudformation/vms-portal-access-template.yml | 57 ++++++- .../vms-portal-foundation-template.yml | 150 ++++++++++++++++++ docs/vms-portal-deployment-sop.md | 22 +-- vms_portal/tests/test_infrastructure.py | 71 ++++++++- 5 files changed, 296 insertions(+), 27 deletions(-) diff --git a/.github/workflows/deploy-vms-portal.yml b/.github/workflows/deploy-vms-portal.yml index ed2d946..5d38ac0 100644 --- a/.github/workflows/deploy-vms-portal.yml +++ b/.github/workflows/deploy-vms-portal.yml @@ -76,10 +76,17 @@ jobs: --template-file cloudformation/vms-portal-foundation-template.yml \ --no-fail-on-empty-changeset - ARTIFACTS_BUCKET=$(aws cloudformation describe-stacks \ + FOUNDATION_OUTPUTS=$(aws cloudformation describe-stacks \ --stack-name "$FOUNDATION_STACK_NAME" \ - --query 'Stacks[0].Outputs[?OutputKey==`DeploymentArtifactsBucketName`].OutputValue' \ - --output text) + --query 'Stacks[0].Outputs' \ + --output json) + ARTIFACTS_BUCKET=$(jq -r '.[] | select(.OutputKey == "DeploymentArtifactsBucketName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_DATA_BUCKET=$(jq -r '.[] | select(.OutputKey == "CostDataBucketName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_DATA_PREFIX=$(jq -r '.[] | select(.OutputKey == "CostDataPrefix") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_QUERY_RESULTS_PREFIX=$(jq -r '.[] | select(.OutputKey == "CostQueryResultsPrefix") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_DATABASE=$(jq -r '.[] | select(.OutputKey == "CostDatabaseName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_TABLE=$(jq -r '.[] | select(.OutputKey == "CostTableName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + COST_WORKGROUP=$(jq -r '.[] | select(.OutputKey == "CostWorkGroupName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") SHUTDOWN_CODE_KEY="lambda/windows-vm-shutdown/${GITHUB_SHA}.zip" zip -X -j /tmp/windows-vm-shutdown.zip \ lambda/windows_vm_shutdown/lambda_function.py @@ -109,6 +116,12 @@ jobs: ShutdownCodeS3Bucket="$ARTIFACTS_BUCKET" \ ShutdownCodeS3Key="$SHUTDOWN_CODE_KEY" \ ShutdownCodeS3Version="$SHUTDOWN_CODE_VERSION" \ + CostDataBucketName="$COST_DATA_BUCKET" \ + CostDataPrefix="$COST_DATA_PREFIX" \ + CostQueryResultsPrefix="$COST_QUERY_RESULTS_PREFIX" \ + CostDatabaseName="$COST_DATABASE" \ + CostTableName="$COST_TABLE" \ + CostWorkGroupName="$COST_WORKGROUP" \ --no-fail-on-empty-changeset STACK_OUTPUTS=$(aws cloudformation describe-stacks \ @@ -135,6 +148,10 @@ jobs: echo "host_ip=$HOST_IP" >> "$GITHUB_OUTPUT" echo "instance_id=$INSTANCE_ID" >> "$GITHUB_OUTPUT" + echo "cost_data_bucket=$COST_DATA_BUCKET" >> "$GITHUB_OUTPUT" + echo "cost_database=$COST_DATABASE" >> "$GITHUB_OUTPUT" + echo "cost_table=$COST_TABLE" >> "$GITHUB_OUTPUT" + echo "cost_workgroup=$COST_WORKGROUP" >> "$GITHUB_OUTPUT" - name: Build and push image run: | set -euo pipefail diff --git a/cloudformation/vms-portal-access-template.yml b/cloudformation/vms-portal-access-template.yml index beae3ad..83eeefd 100644 --- a/cloudformation/vms-portal-access-template.yml +++ b/cloudformation/vms-portal-access-template.yml @@ -17,6 +17,18 @@ Parameters: Type: String ShutdownCodeS3Version: Type: String + CostDataBucketName: + Type: String + CostDataPrefix: + Type: String + CostQueryResultsPrefix: + Type: String + CostDatabaseName: + Type: String + CostTableName: + Type: String + CostWorkGroupName: + Type: String Resources: AuditLogGroup: @@ -134,11 +146,6 @@ Resources: Action: - ec2:DescribeInstances Resource: "*" - - Sid: DescribeManagedElasticIps - Effect: Allow - Action: - - ec2:DescribeAddresses - Resource: "*" - Sid: StartStopManagedInstances Effect: Allow Action: @@ -154,11 +161,45 @@ Resources: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue Resource: !Ref AuthSecretArn - - Sid: ReadResourceCosts + - Sid: RunPortalCostQueries Effect: Allow Action: - - ce:GetCostAndUsageWithResources - Resource: "*" + - athena:StartQueryExecution + - athena:GetQueryExecution + - athena:GetQueryResults + - athena:StopQueryExecution + Resource: !Sub arn:${AWS::Partition}:athena:${AWS::Region}:${AWS::AccountId}:workgroup/${CostWorkGroupName} + - Sid: ReadPortalCostSchema + Effect: Allow + Action: + - glue:GetDatabase + - glue:GetTable + Resource: + - !Sub arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:catalog + - !Sub arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:database/${CostDatabaseName} + - !Sub arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:table/${CostDatabaseName}/${CostTableName} + - Sid: ListPortalCostBucket + Effect: Allow + Action: + - s3:ListBucket + - s3:GetBucketLocation + Resource: !Sub arn:${AWS::Partition}:s3:::${CostDataBucketName} + Condition: + StringLike: + s3:prefix: + - !Sub ${CostDataPrefix}/* + - !Sub ${CostQueryResultsPrefix}/* + - Sid: ReadPortalCostData + Effect: Allow + Action: + - s3:GetObject + Resource: !Sub arn:${AWS::Partition}:s3:::${CostDataBucketName}/${CostDataPrefix}/* + - Sid: ManagePortalQueryResults + Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + Resource: !Sub arn:${AWS::Partition}:s3:::${CostDataBucketName}/${CostQueryResultsPrefix}/* - Sid: WritePortalLogs Effect: Allow Action: diff --git a/cloudformation/vms-portal-foundation-template.yml b/cloudformation/vms-portal-foundation-template.yml index 370c7b8..077368a 100644 --- a/cloudformation/vms-portal-foundation-template.yml +++ b/cloudformation/vms-portal-foundation-template.yml @@ -23,6 +23,156 @@ Resources: NoncurrentVersionExpiration: NoncurrentDays: 30 + CostDataBucket: + Type: AWS::S3::Bucket + Properties: + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerEnforced + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + LifecycleConfiguration: + Rules: + - Id: ExpireAthenaResults + Status: Enabled + Prefix: athena-results/ + ExpirationInDays: 30 + + CostDataBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref CostDataBucket + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: EnableAWSDataExportsToWriteToS3 + Effect: Allow + Principal: + Service: bcm-data-exports.amazonaws.com + Action: s3:PutObject + Resource: !Sub ${CostDataBucket.Arn}/vms-portal-cur/* + Condition: + ArnLike: + aws:SourceArn: !Sub arn:${AWS::Partition}:bcm-data-exports:us-east-1:${AWS::AccountId}:export/* + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + + PortalCurExport: + Type: AWS::BCMDataExports::Export + DependsOn: CostDataBucketPolicy + Properties: + Export: + Name: vms_portal_cur + Description: Minimal resource-level EC2 cost data for the Windows VM portal + DataQuery: + QueryStatement: >- + SELECT bill_billing_period_start_date, + line_item_resource_id, + line_item_usage_start_date, + line_item_unblended_cost, + reservation_effective_cost, + savings_plan_savings_plan_effective_cost + FROM COST_AND_USAGE_REPORT + TableConfigurations: + COST_AND_USAGE_REPORT: + INCLUDE_RESOURCES: "TRUE" + INCLUDE_SPLIT_COST_ALLOCATION_DATA: "FALSE" + TIME_GRANULARITY: "DAILY" + DestinationConfigurations: + S3Destination: + S3Bucket: !Ref CostDataBucket + S3BucketOwner: !Ref AWS::AccountId + S3Prefix: vms-portal-cur + S3Region: !Ref AWS::Region + S3OutputConfigurations: + Compression: PARQUET + Format: PARQUET + OutputType: CUSTOM + Overwrite: OVERWRITE_REPORT + RefreshCadence: + Frequency: SYNCHRONOUS + + CostDatabase: + Type: AWS::Glue::Database + Properties: + CatalogId: !Ref AWS::AccountId + DatabaseInput: + Name: vms_portal_costs + Description: CUR 2.0 database for the Windows VM portal + + CostTable: + Type: AWS::Glue::Table + Properties: + CatalogId: !Ref AWS::AccountId + DatabaseName: !Ref CostDatabase + TableInput: + Name: cur2 + TableType: EXTERNAL_TABLE + Parameters: + classification: parquet + projection.enabled: "true" + projection.billing_period.type: date + projection.billing_period.range: 2025-01,NOW + projection.billing_period.format: yyyy-MM + projection.billing_period.interval: "1" + projection.billing_period.interval.unit: MONTHS + storage.location.template: !Sub s3://${CostDataBucket}/vms-portal-cur/vms_portal_cur/data/BILLING_PERIOD=${!billing_period}/ + PartitionKeys: + - Name: billing_period + Type: string + StorageDescriptor: + Location: !Sub s3://${CostDataBucket}/vms-portal-cur/vms_portal_cur/data/ + InputFormat: org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat + OutputFormat: org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat + SerdeInfo: + SerializationLibrary: org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe + Columns: + - Name: bill_billing_period_start_date + Type: timestamp + - Name: line_item_resource_id + Type: string + - Name: line_item_usage_start_date + Type: timestamp + - Name: line_item_unblended_cost + Type: decimal(38,18) + - Name: reservation_effective_cost + Type: decimal(38,18) + - Name: savings_plan_savings_plan_effective_cost + Type: decimal(38,18) + + CostWorkGroup: + Type: AWS::Athena::WorkGroup + Properties: + Name: vms-portal-costs + State: ENABLED + WorkGroupConfiguration: + EnforceWorkGroupConfiguration: true + PublishCloudWatchMetricsEnabled: true + BytesScannedCutoffPerQuery: 1073741824 + ResultConfiguration: + EncryptionConfiguration: + EncryptionOption: SSE_S3 + OutputLocation: !Sub s3://${CostDataBucket}/athena-results/ + Outputs: DeploymentArtifactsBucketName: Value: !Ref DeploymentArtifactsBucket + CostDataBucketName: + Value: !Ref CostDataBucket + CostDataPrefix: + Value: vms-portal-cur/vms_portal_cur/data + CostQueryResultsPrefix: + Value: athena-results + CostDatabaseName: + Value: !Ref CostDatabase + CostTableName: + Value: !Ref CostTable + CostWorkGroupName: + Value: !Ref CostWorkGroup diff --git a/docs/vms-portal-deployment-sop.md b/docs/vms-portal-deployment-sop.md index ed3eec2..3ea6015 100644 --- a/docs/vms-portal-deployment-sop.md +++ b/docs/vms-portal-deployment-sop.md @@ -1,6 +1,6 @@ # Windows VM Portal 部署 SOP -本 SOP 部署 `https://vms.coseeing.org`,AWS Region 固定為 `ap-northeast-1`。日常部署由 GitHub Actions 完成;只有帳密、GitHub/AWS 信任關係、DNS 與 Cost Explorer 需要第一次手動設定。 +本 SOP 部署 `https://vms.coseeing.org`,AWS Region 固定為 `ap-northeast-1`。日常部署由 GitHub Actions 完成;只有帳密、GitHub/AWS 信任關係與 DNS 需要第一次手動設定。 ## A. 第一次部署前:一次性設定 @@ -34,7 +34,8 @@ OIDC role 必須允許 workflow 執行下列範圍: - 查詢 `prod/vms-portal/auth` 的 ARN。 - 建立/查詢 ECR repository `vms-portal` 並 push image。 -- 建立或更新 CloudFormation stack `vms-portal-access`。 +- 建立或更新 CloudFormation stack `vms-portal-foundation` 與 `vms-portal-access`。 +- 管理受限範圍的 BCM Data Exports、S3、Glue、Athena、Lambda、SQS 與 EventBridge Scheduler 資源,並將 Lambda code artifact 上傳至 foundation stack 建立的 versioned bucket。 - 對 role `coseeing-ec2-common` 管理 inline policy `vms-portal-runtime`。 - 建立 `/coseeing/vms-portal` log group。 - 查詢既有 Linux EC2 stack,並設定該 instance 的 IMDSv2 metadata options。 @@ -49,11 +50,12 @@ OIDC role 必須允許 workflow 執行下列範圍: | AWS 權限 | Resource 限制 | 用途 | | --- | --- | --- | -| `ec2:DescribeInstances` | `*` | admin 列出 VM、user 依 Public IPv4 查詢,以及每次開關機前重新驗證 tag/IP/狀態。此 API 不支援限制到單一 instance ARN。 | -| `ec2:DescribeAddresses` | `*` | 讀取帶有 `VmPortalManaged=true` 的 EIP,依 Instance ID 對應 VM 並取得估算所需的建立時間 tag。此 API 不支援限制到單一 EIP ARN。 | +| `ec2:DescribeInstances` | `*` | admin 列出 VM、user 依 Instance ID 查詢,以及每次開關機前重新驗證 tag/狀態。此 API 不支援限制到單一 instance ARN。 | | `ec2:StartInstances`、`ec2:StopInstances` | 本帳號、本 Region 的 EC2 instance ARN,另要求 `VmPortalManaged=true` | 只允許控制明確交由 Portal 管理的 Windows VM。 | | `secretsmanager:DescribeSecret`、`secretsmanager:GetSecretValue` | 建立部署時指定的單一 Secret ARN | 啟動與定期更新共用登入帳密;Secret 只保留於記憶體 cache。 | -| `ce:GetCostAndUsageWithResources` | `*` | 查詢每台 EC2 最近 14 天的 Cost Explorer resource-level cost;此 API 不支援 resource ARN 限制。 | +| Athena query/read | 單一 `vms-portal-costs` workgroup | 執行 Portal 的批次 60 天成本查詢並讀取結果。 | +| Glue `GetDatabase`、`GetTable` | 單一 cost database/table 及 catalog | 解析固定 CUR 2.0 Parquet schema;不使用 crawler。 | +| S3 list/read/write | CUR data prefix 只讀、Athena result prefix 讀寫 | 讀取 Data Export 並保存短期查詢結果;不能管理 bucket 或 export。 | | `logs:CreateLogStream`、`logs:PutLogEvents` | `/coseeing/vms-portal` log group 內的 stream | Docker `awslogs` driver 寫入登入及開關機 audit log。 | 同一台 EC2 上的既有服務已經從 private ECR 拉取 image,因此 `coseeing-ec2-common` 的共用基礎 policy 應已具備 `ecr:GetAuthorizationToken`、`ecr:BatchCheckLayerAvailability`、`ecr:GetDownloadUrlForLayer` 與 `ecr:BatchGetImage`。這些權限不是 Portal 特有權限,不由 `vms-portal-access` stack 重複管理。可登入該 EC2 驗證目前 instance profile 是否仍能取得 ECR token: @@ -69,13 +71,13 @@ Linux EC2 不需要 `ec2:*`、`iam:*`、`secretsmanager:*` 或 ECR push 權限 部署後可在 AWS Console 的 **IAM** → **Roles** → `coseeing-ec2-common` → **Permissions** 確認存在 `vms-portal-runtime`。若 role 名稱不同,需先調整 workflow 的 `RUNTIME_ROLE_NAME`,不要額外建立一份過度寬鬆的 policy。 -### 4. DNS、Windows tag 與 Cost Explorer +### 4. DNS、Windows tag 與 CUR 2.0 - DNS:將 `vms.coseeing.org` 的 A/AAAA 記錄指向既有 Traefik Linux EC2。 -- Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。由 batch workflow 建立的 EIP 也會帶管理 tag、Instance index 與 `VmPortalCreatedAt`;既有 EIP 缺少建立時間時 Portal 會顯示「估算資料不足」。 -- Cost Explorer:用 payer/management account 開啟 **Billing and Cost Management** → **Cost Management preferences** → **Granular data**,啟用 EC2 resource-level data。資料只涵蓋最近 14 天,可能需等待 48 小時,且 granular data/API request 可能產生費用。 +- Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。 +- CUR 2.0:deploy workflow 先建立加密、封鎖公開存取的 cost bucket、resource-ID Data Export、固定 Glue schema 與受 scan limit 保護的 Athena workgroup,再更新 Portal runtime IAM。首次報表通常需等待最多 24 小時;若帳號沒有可用的舊月份檔案,近 60 天畫面會明確顯示實際可用期間。需要補舊資料時由 payer/management account 向 AWS Support 申請 backfill。 -Portal 的 EC2 欄位是 Cost Explorer `UnblendedCost` 實際資料;EIP 欄位不是 Cost Explorer line item,而是依 `VmPortalCreatedAt`、最近 14 天持有小時及 `PUBLIC_IPV4_HOURLY_USD` 計算的估算。部署預設單價為 `0.005` USD/IP/hour。若 AWS 調價,先更新 `ansible_yaml/vms-portal-playbook.yml` 內的環境值再重新部署。VM 停止後 EIP 仍由帳號持有,因此估算會繼續累計。 +Portal 不再使用 Cost Explorer 的 14 天 resource API,也不估算 EIP 成本。CUR query 對每個 instance 使用 Savings Plan effective cost、RI effective cost或 unblended cost 的適用值,並區分數字 `0`、報表尚未準備及查詢失敗。 以上三項無法由本 repository 的 deploy workflow 安全代辦。 @@ -92,7 +94,7 @@ Portal 的 EC2 欄位是 Cost Explorer `UnblendedCost` 實際資料;EIP 欄位 Deploy job 會自動完成: - 建立 ECR repository(若不存在)。 -- 部署 runtime IAM policy 與 CloudWatch log group。 +- 依序部署 foundation、上傳 versioned shutdown Lambda、再部署 runtime IAM/Scheduler;任一步失敗都不會更新 Portal container。 - 將 Linux EC2 設為 `HttpTokens=required`、hop limit `2`。 - 以 Git commit SHA 作為 immutable image tag,build/push Docker image。 - 透過 Ansible 更新 Portal/Traefik。 diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index c127d67..2acd6c8 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -161,21 +161,27 @@ def test_deploy_workflow_uploads_versioned_lambda_before_access_stack() -> None: ) -def test_portal_policy_can_describe_eips_but_cannot_provision_resources() -> None: +def test_portal_policy_reads_cur_through_athena_but_cannot_provision() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ "Statement" ] - describe_addresses = next( - item for item in statements if item["Action"] == ["ec2:DescribeAddresses"] - ) - assert describe_addresses["Resource"] == "*" - actions = { action for statement in statements for action in statement.get("Action", []) } + assert { + "athena:StartQueryExecution", + "athena:GetQueryExecution", + "athena:GetQueryResults", + "glue:GetDatabase", + "glue:GetTable", + "s3:GetObject", + "s3:PutObject", + }.issubset(actions) assert actions.isdisjoint( { + "ce:GetCostAndUsageWithResources", + "ec2:DescribeAddresses", "ec2:RunInstances", "ec2:AllocateAddress", "ec2:AssociateAddress", @@ -187,6 +193,59 @@ def test_portal_policy_can_describe_eips_but_cannot_provision_resources() -> Non ) +def test_foundation_defines_cur2_parquet_glue_projection_and_athena_limits() -> None: + template = load_cfn(ROOT / "cloudformation/vms-portal-foundation-template.yml") + resources = template["Resources"] + + bucket = resources["CostDataBucket"]["Properties"] + assert bucket["BucketEncryption"] + assert all(bucket["PublicAccessBlockConfiguration"].values()) + + export = resources["PortalCurExport"]["Properties"]["Export"] + query = export["DataQuery"] + assert "line_item_resource_id" in query["QueryStatement"] + assert query["TableConfigurations"]["COST_AND_USAGE_REPORT"][ + "INCLUDE_RESOURCES" + ] == "TRUE" + destination = export["DestinationConfigurations"]["S3Destination"] + assert destination["S3OutputConfigurations"] == { + "Compression": "PARQUET", + "Format": "PARQUET", + "OutputType": "CUSTOM", + "Overwrite": "OVERWRITE_REPORT", + } + assert export["RefreshCadence"] == {"Frequency": "SYNCHRONOUS"} + + table = resources["CostTable"]["Properties"]["TableInput"] + assert table["Parameters"]["projection.enabled"] == "true" + assert table["Parameters"]["projection.billing_period.type"] == "date" + assert "${!billing_period}" in table["Parameters"]["storage.location.template"] + assert resources["CostWorkGroup"]["Properties"]["WorkGroupConfiguration"][ + "BytesScannedCutoffPerQuery" + ] == 1073741824 + assert "Crawler" not in " ".join(resources) + + +def test_deploy_workflow_passes_foundation_cost_outputs_to_access_stack() -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/deploy-vms-portal.yml").read_text() + ) + prepare = next( + step + for step in workflow["jobs"]["deploy"]["steps"] + if step.get("name") == "Prepare AWS infrastructure" + )["run"] + for value in ( + "CostDatabaseName", + "CostTableName", + "CostWorkGroupName", + "CostDataBucketName", + "CostQueryResultsPrefix", + ): + assert value in prepare + assert "CostDatabaseName=" in prepare + + def test_portal_runtime_policy_does_not_duplicate_shared_ecr_access() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ From 228315ffac4d8abc30182b1b5ded9f4996b8dc1d Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 23:10:28 +0800 Subject: [PATCH 10/12] feat: show 60-day CUR costs per VM Co-authored-by: Codex --- .github/workflows/deploy-vms-portal.yml | 16 +- ansible_yaml/vms-portal-playbook.yml | 3 + .../vms-portal-cur-export-template.yml | 50 +++ .../vms-portal-foundation-template.yml | 39 +- docs/vms-portal-deployment-sop.md | 4 +- .../tests/test_lambda_function.py | 6 +- vms_portal/src/vms_portal/config.py | 27 +- vms_portal/src/vms_portal/costs.py | 287 ++++++++++----- .../src/vms_portal/templates/admin.html | 4 +- vms_portal/src/vms_portal/templates/user.html | 2 +- vms_portal/src/vms_portal/web.py | 14 +- vms_portal/tests/test_config.py | 22 ++ vms_portal/tests/test_costs.py | 340 +++++++++--------- vms_portal/tests/test_ec2.py | 5 +- vms_portal/tests/test_infrastructure.py | 67 ++-- vms_portal/tests/test_web.py | 64 +++- 16 files changed, 584 insertions(+), 366 deletions(-) create mode 100644 cloudformation/vms-portal-cur-export-template.yml diff --git a/.github/workflows/deploy-vms-portal.yml b/.github/workflows/deploy-vms-portal.yml index 5d38ac0..9f1dbfe 100644 --- a/.github/workflows/deploy-vms-portal.yml +++ b/.github/workflows/deploy-vms-portal.yml @@ -50,6 +50,7 @@ jobs: IMAGE_TAG: ${{ github.sha }} ACCESS_STACK_NAME: vms-portal-access FOUNDATION_STACK_NAME: vms-portal-foundation + CUR_EXPORT_STACK_NAME: vms-portal-cur-export RUNTIME_ROLE_NAME: coseeing-ec2-common AUTH_SECRET_ID: ${{ inputs.auth_secret_id }} steps: @@ -87,6 +88,16 @@ jobs: COST_DATABASE=$(jq -r '.[] | select(.OutputKey == "CostDatabaseName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") COST_TABLE=$(jq -r '.[] | select(.OutputKey == "CostTableName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") COST_WORKGROUP=$(jq -r '.[] | select(.OutputKey == "CostWorkGroupName") | .OutputValue' <<<"$FOUNDATION_OUTPUTS") + + aws cloudformation deploy \ + --region us-east-1 \ + --stack-name "$CUR_EXPORT_STACK_NAME" \ + --template-file cloudformation/vms-portal-cur-export-template.yml \ + --parameter-overrides \ + CostDataBucketName="$COST_DATA_BUCKET" \ + CostDataBucketRegion="$AWS_REGION" \ + --no-fail-on-empty-changeset + SHUTDOWN_CODE_KEY="lambda/windows-vm-shutdown/${GITHUB_SHA}.zip" zip -X -j /tmp/windows-vm-shutdown.zip \ lambda/windows_vm_shutdown/lambda_function.py @@ -172,11 +183,14 @@ jobs: - name: Deploy with Ansible env: HOST_IP: ${{ steps.prepare.outputs.host_ip }} + COST_DATABASE: ${{ steps.prepare.outputs.cost_database }} + COST_TABLE: ${{ steps.prepare.outputs.cost_table }} + COST_WORKGROUP: ${{ steps.prepare.outputs.cost_workgroup }} ANSIBLE_HOST_KEY_CHECKING: "False" run: | set -euo pipefail printf '[portal]\n%s ansible_user=ubuntu\n' "$HOST_IP" > inventory - ansible-playbook -i inventory -e "deploy_tag=$IMAGE_TAG" -e "auth_secret_id=$AUTH_SECRET_ID" -e "deploy_domain=vms.coseeing.org" ansible_yaml/vms-portal-playbook.yml + ansible-playbook -i inventory -e "deploy_tag=$IMAGE_TAG" -e "auth_secret_id=$AUTH_SECRET_ID" -e "deploy_domain=vms.coseeing.org" -e "cost_database=$COST_DATABASE" -e "cost_table=$COST_TABLE" -e "cost_workgroup=$COST_WORKGROUP" ansible_yaml/vms-portal-playbook.yml - name: Verify portal health run: | set -euo pipefail diff --git a/ansible_yaml/vms-portal-playbook.yml b/ansible_yaml/vms-portal-playbook.yml index e3f2e2a..352d6d0 100644 --- a/ansible_yaml/vms-portal-playbook.yml +++ b/ansible_yaml/vms-portal-playbook.yml @@ -32,6 +32,9 @@ AWS_REGION=ap-northeast-1 AUTH_SECRET_ID={{ auth_secret_id }} ASSIGNMENTS_DB_PATH=/data/vms-portal/data/portal.db + COST_DATABASE={{ cost_database }} + COST_TABLE={{ cost_table }} + COST_WORKGROUP={{ cost_workgroup }} TRUSTED_PROXY_IPS=127.0.0.1 - name: Write portal Compose file diff --git a/cloudformation/vms-portal-cur-export-template.yml b/cloudformation/vms-portal-cur-export-template.yml new file mode 100644 index 0000000..1bad1ec --- /dev/null +++ b/cloudformation/vms-portal-cur-export-template.yml @@ -0,0 +1,50 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: us-east-1 BCM Data Export for the Windows VM portal CUR 2.0 data + +Parameters: + CostDataBucketName: + Type: String + CostDataBucketRegion: + Type: String + Default: ap-northeast-1 + +Resources: + PortalCurExport: + Type: AWS::BCMDataExports::Export + Properties: + Export: + Name: vms_portal_cur + Description: Minimal resource-level EC2 cost data for the Windows VM portal + DataQuery: + QueryStatement: >- + SELECT bill_billing_period_start_date, + line_item_resource_id, + line_item_usage_start_date, + line_item_line_item_type, + line_item_currency_code, + line_item_unblended_cost, + reservation_effective_cost, + savings_plan_savings_plan_effective_cost + FROM COST_AND_USAGE_REPORT + TableConfigurations: + COST_AND_USAGE_REPORT: + INCLUDE_RESOURCES: "TRUE" + INCLUDE_SPLIT_COST_ALLOCATION_DATA: "FALSE" + TIME_GRANULARITY: "DAILY" + DestinationConfigurations: + S3Destination: + S3Bucket: !Ref CostDataBucketName + S3BucketOwner: !Ref AWS::AccountId + S3Prefix: vms-portal-cur + S3Region: !Ref CostDataBucketRegion + S3OutputConfigurations: + Compression: PARQUET + Format: PARQUET + OutputType: CUSTOM + Overwrite: OVERWRITE_REPORT + RefreshCadence: + Frequency: SYNCHRONOUS + +Outputs: + ExportArn: + Value: !GetAtt PortalCurExport.ExportArn diff --git a/cloudformation/vms-portal-foundation-template.yml b/cloudformation/vms-portal-foundation-template.yml index 077368a..9499e7e 100644 --- a/cloudformation/vms-portal-foundation-template.yml +++ b/cloudformation/vms-portal-foundation-template.yml @@ -64,41 +64,6 @@ Resources: StringEquals: aws:SourceAccount: !Ref AWS::AccountId - PortalCurExport: - Type: AWS::BCMDataExports::Export - DependsOn: CostDataBucketPolicy - Properties: - Export: - Name: vms_portal_cur - Description: Minimal resource-level EC2 cost data for the Windows VM portal - DataQuery: - QueryStatement: >- - SELECT bill_billing_period_start_date, - line_item_resource_id, - line_item_usage_start_date, - line_item_unblended_cost, - reservation_effective_cost, - savings_plan_savings_plan_effective_cost - FROM COST_AND_USAGE_REPORT - TableConfigurations: - COST_AND_USAGE_REPORT: - INCLUDE_RESOURCES: "TRUE" - INCLUDE_SPLIT_COST_ALLOCATION_DATA: "FALSE" - TIME_GRANULARITY: "DAILY" - DestinationConfigurations: - S3Destination: - S3Bucket: !Ref CostDataBucket - S3BucketOwner: !Ref AWS::AccountId - S3Prefix: vms-portal-cur - S3Region: !Ref AWS::Region - S3OutputConfigurations: - Compression: PARQUET - Format: PARQUET - OutputType: CUSTOM - Overwrite: OVERWRITE_REPORT - RefreshCadence: - Frequency: SYNCHRONOUS - CostDatabase: Type: AWS::Glue::Database Properties: @@ -140,6 +105,10 @@ Resources: Type: string - Name: line_item_usage_start_date Type: timestamp + - Name: line_item_line_item_type + Type: string + - Name: line_item_currency_code + Type: string - Name: line_item_unblended_cost Type: decimal(38,18) - Name: reservation_effective_cost diff --git a/docs/vms-portal-deployment-sop.md b/docs/vms-portal-deployment-sop.md index 3ea6015..6801cb1 100644 --- a/docs/vms-portal-deployment-sop.md +++ b/docs/vms-portal-deployment-sop.md @@ -34,7 +34,7 @@ OIDC role 必須允許 workflow 執行下列範圍: - 查詢 `prod/vms-portal/auth` 的 ARN。 - 建立/查詢 ECR repository `vms-portal` 並 push image。 -- 建立或更新 CloudFormation stack `vms-portal-foundation` 與 `vms-portal-access`。 +- 在 `ap-northeast-1` 建立或更新 CloudFormation stack `vms-portal-foundation` 與 `vms-portal-access`,並在 BCM Data Exports 唯一支援的 `us-east-1` 建立 `vms-portal-cur-export`。 - 管理受限範圍的 BCM Data Exports、S3、Glue、Athena、Lambda、SQS 與 EventBridge Scheduler 資源,並將 Lambda code artifact 上傳至 foundation stack 建立的 versioned bucket。 - 對 role `coseeing-ec2-common` 管理 inline policy `vms-portal-runtime`。 - 建立 `/coseeing/vms-portal` log group。 @@ -75,7 +75,7 @@ Linux EC2 不需要 `ec2:*`、`iam:*`、`secretsmanager:*` 或 ECR push 權限 - DNS:將 `vms.coseeing.org` 的 A/AAAA 記錄指向既有 Traefik Linux EC2。 - Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。 -- CUR 2.0:deploy workflow 先建立加密、封鎖公開存取的 cost bucket、resource-ID Data Export、固定 Glue schema 與受 scan limit 保護的 Athena workgroup,再更新 Portal runtime IAM。首次報表通常需等待最多 24 小時;若帳號沒有可用的舊月份檔案,近 60 天畫面會明確顯示實際可用期間。需要補舊資料時由 payer/management account 向 AWS Support 申請 backfill。 +- CUR 2.0:deploy workflow 先在 Tokyo 建立加密、封鎖公開存取的 cost bucket、固定 Glue schema 與受 scan limit 保護的 Athena workgroup,再從 `us-east-1` stack 建立 resource-ID Data Export,最後更新 Portal runtime IAM。首次報表通常需等待最多 24 小時;若帳號沒有可用的舊月份檔案,近 60 天畫面會明確顯示實際可用期間。需要補舊資料時由 payer/management account 向 AWS Support 申請 backfill。 Portal 不再使用 Cost Explorer 的 14 天 resource API,也不估算 EIP 成本。CUR query 對每個 instance 使用 Savings Plan effective cost、RI effective cost或 unblended cost 的適用值,並區分數字 `0`、報表尚未準備及查詢失敗。 diff --git a/lambda/windows_vm_shutdown/tests/test_lambda_function.py b/lambda/windows_vm_shutdown/tests/test_lambda_function.py index 002c158..98bd2db 100644 --- a/lambda/windows_vm_shutdown/tests/test_lambda_function.py +++ b/lambda/windows_vm_shutdown/tests/test_lambda_function.py @@ -41,11 +41,7 @@ def stop_instances(self, **kwargs): def page(*instance_ids): return { "Reservations": [ - { - "Instances": [ - {"InstanceId": instance_id} for instance_id in instance_ids - ] - } + {"Instances": [{"InstanceId": instance_id} for instance_id in instance_ids]} ] } diff --git a/vms_portal/src/vms_portal/config.py b/vms_portal/src/vms_portal/config.py index 255c60c..292f2ad 100644 --- a/vms_portal/src/vms_portal/config.py +++ b/vms_portal/src/vms_portal/config.py @@ -1,8 +1,8 @@ from __future__ import annotations +import re from collections.abc import Mapping from dataclasses import dataclass -from decimal import Decimal, InvalidOperation from pathlib import Path @@ -21,15 +21,10 @@ def _positive_int(env: Mapping[str, str], name: str, default: int) -> int: return value -def _non_negative_decimal( - env: Mapping[str, str], name: str, default: str -) -> Decimal: - try: - value = Decimal(env.get(name, default)) - except InvalidOperation as exc: - raise ConfigurationError(f"{name} must be a non-negative decimal") from exc - if not value.is_finite() or value < 0: - raise ConfigurationError(f"{name} must be a non-negative decimal") +def _identifier(env: Mapping[str, str], name: str, default: str, pattern: str) -> str: + value = env.get(name, default) + if not re.fullmatch(pattern, value): + raise ConfigurationError(f"{name} contains unsupported characters") return value @@ -42,7 +37,9 @@ class Settings: session_cookie_name: str = "vms_portal_session" trusted_proxy_ips: tuple[str, ...] = ("127.0.0.1", "::1") cost_cache_seconds: int = 21_600 - public_ipv4_hourly_usd: Decimal = Decimal("0.005") + cost_database: str = "vms_portal_costs" + cost_table: str = "cur2" + cost_workgroup: str = "vms-portal-costs" assignments_db_path: Path = Path("/data/vms-portal/data/portal.db") @classmethod @@ -66,8 +63,12 @@ def from_env(cls, env: Mapping[str, str]) -> Settings: auth_secret_id=secret_id, trusted_proxy_ips=proxy_ips, cost_cache_seconds=_positive_int(env, "COST_CACHE_SECONDS", 21_600), - public_ipv4_hourly_usd=_non_negative_decimal( - env, "PUBLIC_IPV4_HOURLY_USD", "0.005" + cost_database=_identifier( + env, "COST_DATABASE", "vms_portal_costs", r"[a-z0-9_]+" + ), + cost_table=_identifier(env, "COST_TABLE", "cur2", r"[a-z0-9_]+"), + cost_workgroup=_identifier( + env, "COST_WORKGROUP", "vms-portal-costs", r"[A-Za-z0-9._-]+" ), assignments_db_path=assignments_db_path, ) diff --git a/vms_portal/src/vms_portal/costs.py b/vms_portal/src/vms_portal/costs.py index f63aa89..4d9c141 100644 --- a/vms_portal/src/vms_portal/costs.py +++ b/vms_portal/src/vms_portal/costs.py @@ -1,45 +1,63 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +import logging +import re +import time +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from decimal import Decimal +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal, InvalidOperation from typing import Any from botocore.exceptions import ClientError from .ec2 import VmInstance +_INSTANCE_ID = re.compile(r"^i-[0-9a-f]+$") +_SQL_IDENTIFIER = re.compile(r"^[a-z0-9_]+$") +_NOT_READY_REASONS = ("TABLE_NOT_FOUND", "does not exist", "not found") + @dataclass(frozen=True, slots=True) class InstanceCost: instance_id: str - ec2_amount: Decimal | None - eip_amount: Decimal | None + status: str + amount: Decimal | None currency: str - estimated: bool + period_start: date | None + period_end: date | None retrieved_at: datetime - - @property - def total_amount(self) -> Decimal | None: - if self.ec2_amount is None or self.eip_amount is None: - return None - return self.ec2_amount + self.eip_amount + query_execution_id: str | None = None class CostService: def __init__( self, client: Any, + *, + database: str, + table: str, + workgroup: str, cache_seconds: int = 21_600, - public_ipv4_hourly_usd: Decimal = Decimal("0.005"), + sleeper: Callable[[float], None] = time.sleep, + poll_interval_seconds: float = 0.25, + max_poll_attempts: int = 120, + logger: logging.Logger | None = None, ) -> None: + if not _SQL_IDENTIFIER.fullmatch(database): + raise ValueError("invalid Athena database") + if not _SQL_IDENTIFIER.fullmatch(table): + raise ValueError("invalid Athena table") self._client = client + self._database = database + self._table = table + self._workgroup = workgroup self._cache_seconds = cache_seconds - self._public_ipv4_hourly_usd = public_ipv4_hourly_usd - self._cache_key: frozenset[tuple[str, str | None, datetime | None]] = ( - frozenset() - ) + self._sleeper = sleeper + self._poll_interval_seconds = poll_interval_seconds + self._max_poll_attempts = max_poll_attempts + self._logger = logger or logging.getLogger(__name__) + self._cache_key: frozenset[str] = frozenset() self._cache_at: datetime | None = None self._cache: dict[str, InstanceCost] = {} @@ -47,96 +65,171 @@ def get_costs( self, instances: Sequence[VmInstance], now: datetime ) -> Mapping[str, InstanceCost]: now = now.astimezone(UTC) - key = frozenset( - (vm.instance_id, vm.eip_allocation_id, vm.eip_created_at) - for vm in instances - ) - if not key: + instance_ids = frozenset(vm.instance_id for vm in instances) + if not instance_ids: return {} if ( self._cache_at is not None - and key == self._cache_key + and instance_ids == self._cache_key and (now - self._cache_at).total_seconds() < self._cache_seconds ): return dict(self._cache) - instance_ids = {vm.instance_id for vm in instances} - end = now.date() - start = end - timedelta(days=14) - request: dict[str, Any] = { - "TimePeriod": {"Start": start.isoformat(), "End": end.isoformat()}, - "Granularity": "DAILY", - "Filter": { - "And": [ - { - "Dimensions": { - "Key": "SERVICE", - "Values": ["Amazon Elastic Compute Cloud - Compute"], - } - }, - { - "Dimensions": { - "Key": "RESOURCE_ID", - "Values": sorted(instance_ids), - } - }, - ] - }, - "GroupBy": [{"Type": "DIMENSION", "Key": "RESOURCE_ID"}], - "Metrics": ["UnblendedCost"], - } - amounts = {instance_id: Decimal(0) for instance_id in instance_ids} - currencies = {instance_id: "USD" for instance_id in instance_ids} - estimated = {instance_id: False for instance_id in instance_ids} - ec2_available = True + + query_id: str | None = None try: - while True: - response = self._client.get_cost_and_usage_with_resources(**request) - for period in response.get("ResultsByTime", []): - for group in period.get("Groups", []): - instance_id = group["Keys"][0] - if instance_id not in amounts: - continue - metric = group["Metrics"]["UnblendedCost"] - amounts[instance_id] += Decimal(metric["Amount"]) - currencies[instance_id] = metric["Unit"] - estimated[instance_id] = estimated[instance_id] or bool( - period.get("Estimated") - ) - token = response.get("NextPageToken") - if not token: - break - request["NextPageToken"] = token - except ClientError: - ec2_available = False - result = { - vm.instance_id: InstanceCost( - vm.instance_id, - amounts[vm.instance_id] if ec2_available else None, - _estimate_eip_cost(vm, now, self._public_ipv4_hourly_usd), - currencies[vm.instance_id], - estimated[vm.instance_id], - now, + response = self._client.start_query_execution( + QueryString=self._build_query(instance_ids, now), + QueryExecutionContext={"Database": self._database}, + WorkGroup=self._workgroup, ) - for vm in instances - } - self._cache_key = key + query_id = response["QueryExecutionId"] + status, reason = self._wait(query_id) + if status != "SUCCEEDED": + result_status = ( + "not_ready" + if any( + marker.casefold() in reason.casefold() + for marker in _NOT_READY_REASONS + ) + else "failed" + ) + self._logger.error( + "Athena cost query ended in %s: %s", + status, + reason, + extra={"query_execution_id": query_id}, + ) + result = self._empty(instance_ids, result_status, now, query_id) + else: + result = self._read_results(instance_ids, now, query_id) + except ClientError as exc: + metadata = exc.response.get("ResponseMetadata", {}) + self._logger.exception( + "Athena cost API failure: %s", + exc.response.get("Error", {}).get("Code", "unknown"), + extra={ + "aws_request_id": metadata.get("RequestId"), + "query_execution_id": query_id, + }, + ) + result = self._empty(instance_ids, "failed", now, query_id) + except (InvalidOperation, ValueError, KeyError, IndexError): + self._logger.exception( + "Athena cost result could not be parsed", + extra={"query_execution_id": query_id}, + ) + result = self._empty(instance_ids, "failed", now, query_id) + + self._cache_key = instance_ids self._cache_at = now self._cache = result return dict(result) + def _build_query(self, instance_ids: frozenset[str], now: datetime) -> str: + if any(not _INSTANCE_ID.fullmatch(instance_id) for instance_id in instance_ids): + raise ValueError("invalid EC2 instance ID") + end = now.date() + timedelta(days=1) + start = end - timedelta(days=60) + ids = ", ".join(f"'{instance_id}'" for instance_id in sorted(instance_ids)) + periods = ", ".join(f"'{value}'" for value in _billing_periods(start, end)) + return f""" +SELECT + line_item_resource_id AS instance_id, + SUM( + CASE + WHEN line_item_line_item_type = 'SavingsPlanCoveredUsage' + THEN savings_plan_savings_plan_effective_cost + WHEN line_item_line_item_type = 'DiscountedUsage' + THEN reservation_effective_cost + ELSE line_item_unblended_cost + END + ) AS amount, + MAX(line_item_currency_code) AS currency, + MIN(CAST(line_item_usage_start_date AS DATE)) AS period_start, + MAX(CAST(line_item_usage_start_date AS DATE)) AS period_end +FROM "{self._database}"."{self._table}" +WHERE billing_period IN ({periods}) + AND line_item_usage_start_date >= TIMESTAMP '{start.isoformat()} 00:00:00' + AND line_item_usage_start_date < TIMESTAMP '{end.isoformat()} 00:00:00' + AND line_item_resource_id IN ({ids}) +GROUP BY line_item_resource_id +""".strip() + + def _wait(self, query_id: str) -> tuple[str, str]: + for _ in range(self._max_poll_attempts): + response = self._client.get_query_execution(QueryExecutionId=query_id) + status = response["QueryExecution"]["Status"] + state = status["State"] + if state in {"SUCCEEDED", "FAILED", "CANCELLED"}: + return state, status.get("StateChangeReason", "") + self._sleeper(self._poll_interval_seconds) + return "FAILED", "query polling timed out" -def _estimate_eip_cost( - vm: VmInstance, now: datetime, hourly_rate: Decimal -) -> Decimal | None: - if vm.eip_allocation_id is None: - return Decimal(0) - if vm.eip_created_at is None: - return None - start = max(vm.eip_created_at.astimezone(UTC), now - timedelta(days=14)) - elapsed = now - start - if elapsed.total_seconds() <= 0: - return Decimal(0) - seconds = Decimal(elapsed.days * 86_400 + elapsed.seconds) + ( - Decimal(elapsed.microseconds) / Decimal(1_000_000) - ) - return (seconds / Decimal(3_600)) * hourly_rate + def _read_results( + self, + instance_ids: frozenset[str], + now: datetime, + query_id: str, + ) -> dict[str, InstanceCost]: + result = self._empty(instance_ids, "not_ready", now, query_id) + request: dict[str, str] = {"QueryExecutionId": query_id} + first_row = True + while True: + response = self._client.get_query_results(**request) + for row in response.get("ResultSet", {}).get("Rows", []): + if first_row: + first_row = False + continue + values = [item.get("VarCharValue", "") for item in row.get("Data", [])] + if len(values) < 5 or values[0] not in instance_ids: + continue + result[values[0]] = InstanceCost( + instance_id=values[0], + status="ready", + amount=Decimal(values[1]), + currency=values[2] or "USD", + period_start=date.fromisoformat(values[3]), + period_end=date.fromisoformat(values[4]), + retrieved_at=now, + query_execution_id=query_id, + ) + token = response.get("NextToken") + if not token: + break + request["NextToken"] = token + return result + + @staticmethod + def _empty( + instance_ids: frozenset[str], + status: str, + now: datetime, + query_id: str | None, + ) -> dict[str, InstanceCost]: + return { + instance_id: InstanceCost( + instance_id=instance_id, + status=status, + amount=None, + currency="USD", + period_start=None, + period_end=None, + retrieved_at=now, + query_execution_id=query_id, + ) + for instance_id in instance_ids + } + + +def _billing_periods(start: date, end: date) -> list[str]: + current = start.replace(day=1) + last = (end - timedelta(days=1)).replace(day=1) + result: list[str] = [] + while current <= last: + result.append(current.strftime("%Y-%m")) + current = ( + current.replace(year=current.year + 1, month=1) + if current.month == 12 + else current.replace(month=current.month + 1) + ) + return result diff --git a/vms_portal/src/vms_portal/templates/admin.html b/vms_portal/src/vms_portal/templates/admin.html index ac37330..9de6e99 100644 --- a/vms_portal/src/vms_portal/templates/admin.html +++ b/vms_portal/src/vms_portal/templates/admin.html @@ -3,7 +3,7 @@

所有受管 VM

名稱Instance IDPublic IPv4狀態最近 14 天 EC2 實際成本最近 14 天 EIP 估算成本最近 14 天合計操作
名稱Instance ID指派給Private IPv4目前 Public IPv4狀態最近 14 天 EC2 實際成本操作
{{ vm.name }} {{ vm.instance_id }}
{{ vm.private_ip }} {{ vm.public_ip or '—' }} {{ vm.state }} {% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}{% if not vm.eip_allocation_id %}未綁定受管 EIP{% elif cost and cost.eip_amount is not none %}{{ cost.eip_amount }} {{ cost.currency }}(估算){% else %}估算資料不足{% endif %}{% if cost and cost.total_amount is not none %}{{ cost.total_amount }} {{ cost.currency }}{% else %}合計資料尚未提供{% endif %} {% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}狀態轉換中{% endif %}
- + {% for vm in vms %} {% set cost = costs.get(vm.instance_id) %} @@ -15,7 +15,7 @@

所有受管 VM

- + {% endfor %} diff --git a/vms_portal/src/vms_portal/templates/user.html b/vms_portal/src/vms_portal/templates/user.html index a2616cc..846ce65 100644 --- a/vms_portal/src/vms_portal/templates/user.html +++ b/vms_portal/src/vms_portal/templates/user.html @@ -12,7 +12,7 @@

{{ vm.name }}

Instance ID
{{ vm.instance_id }}
Private IPv4
{{ vm.private_ip }}
狀態
{{ vm.state }}
-
最近 14 天 EC2 實際成本
{% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}
+
最近 60 天 EC2 成本
{% if cost and cost.status == 'ready' %}{{ cost.amount }} {{ cost.currency }}
可用期間:{{ cost.period_start }}~{{ cost.period_end }}{% elif cost and cost.status == 'not_ready' %}成本報表尚未準備完成{% else %}成本查詢失敗{% endif %}
{% if vm.state == 'running' %}{% elif vm.state == 'stopped' %}{% else %}

狀態轉換中

{% endif %} diff --git a/vms_portal/src/vms_portal/web.py b/vms_portal/src/vms_portal/web.py index dcb909d..80fd346 100644 --- a/vms_portal/src/vms_portal/web.py +++ b/vms_portal/src/vms_portal/web.py @@ -16,8 +16,8 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from .audit import AuditEvent, AuditLogger from .assignments import AssignmentRepository +from .audit import AuditEvent, AuditLogger from .config import Settings from .costs import CostService from .ec2 import Ec2Service, VmError @@ -55,9 +55,11 @@ def create_app( boto3.client("ec2", region_name=settings.aws_region) ) cost_service = cost_service or CostService( - boto3.client("ce", region_name="us-east-1"), - settings.cost_cache_seconds, - settings.public_ipv4_hourly_usd, + boto3.client("athena", region_name=settings.aws_region), + database=settings.cost_database, + table=settings.cost_table, + workgroup=settings.cost_workgroup, + cache_seconds=settings.cost_cache_seconds, ) assignment_repository = assignment_repository or AssignmentRepository( settings.assignments_db_path @@ -219,9 +221,7 @@ def home(request: Request): ) vms = ec2_service.list_managed() costs = cost_service.get_costs(vms, datetime.now(UTC)) - assignments = assignment_repository.get_many( - vm.instance_id for vm in vms - ) + assignments = assignment_repository.get_many(vm.instance_id for vm in vms) return render( request, "admin.html", diff --git a/vms_portal/tests/test_config.py b/vms_portal/tests/test_config.py index 9f7f35c..ef79ffb 100644 --- a/vms_portal/tests/test_config.py +++ b/vms_portal/tests/test_config.py @@ -17,6 +17,9 @@ def test_defaults_are_safe_and_region_is_fixed() -> None: assert settings.managed_tag_value == "true" assert settings.session_cookie_name == "vms_portal_session" assert settings.cost_cache_seconds == 21_600 + assert settings.cost_database == "vms_portal_costs" + assert settings.cost_table == "cur2" + assert settings.cost_workgroup == "vms-portal-costs" assert str(settings.assignments_db_path) == "/data/vms-portal/data/portal.db" assert settings.trusted_proxy_ips == ("127.0.0.1", "::1") @@ -61,3 +64,22 @@ def test_assignments_database_path_must_be_absolute() -> None: "ASSIGNMENTS_DB_PATH": "relative/portal.db", } ) + + +def test_cost_identifiers_can_be_overridden_and_must_be_safe() -> None: + settings = Settings.from_env( + { + "AUTH_SECRET_ID": "prod/vms-portal/auth", + "COST_DATABASE": "portal_costs_2", + "COST_TABLE": "cur_daily", + "COST_WORKGROUP": "portal-costs-2", + } + ) + assert settings.cost_database == "portal_costs_2" + assert settings.cost_table == "cur_daily" + assert settings.cost_workgroup == "portal-costs-2" + + with pytest.raises(ConfigurationError, match="COST_DATABASE"): + Settings.from_env( + {"AUTH_SECRET_ID": "prod/vms-portal/auth", "COST_DATABASE": "bad;drop"} + ) diff --git a/vms_portal/tests/test_costs.py b/vms_portal/tests/test_costs.py index f19da7a..bceee77 100644 --- a/vms_portal/tests/test_costs.py +++ b/vms_portal/tests/test_costs.py @@ -1,195 +1,213 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from decimal import Decimal from ipaddress import IPv4Address -import boto3 -from botocore.stub import Stubber +from botocore.exceptions import ClientError from vms_portal.costs import CostService from vms_portal.ec2 import VmInstance -def client(): - return boto3.client( - "ce", - region_name="us-east-1", - aws_access_key_id="test", - aws_secret_access_key="test", - aws_session_token="test", - ) - - -def vm( - instance_id: str, - *, - state: str = "running", - allocation_id: str | None = None, - eip_created_at: datetime | None = None, -) -> VmInstance: +def vm(instance_id: str) -> VmInstance: return VmInstance( instance_id, - f"windows-{instance_id}", - IPv4Address("198.51.100.9"), + instance_id, + IPv4Address("10.0.0.4"), + None, "m5.xlarge", - state, - datetime(2026, 8, 1, tzinfo=UTC), - allocation_id, - eip_created_at, + "stopped", + datetime(2026, 8, 20, tzinfo=UTC), ) -def expected_request(instance_ids: list[str]) -> dict[str, object]: - return { - "TimePeriod": {"Start": "2026-08-06", "End": "2026-08-20"}, - "Granularity": "DAILY", - "Filter": { - "And": [ - { - "Dimensions": { - "Key": "SERVICE", - "Values": ["Amazon Elastic Compute Cloud - Compute"], - } - }, - { - "Dimensions": { - "Key": "RESOURCE_ID", - "Values": sorted(instance_ids), - } - }, - ] - }, - "GroupBy": [{"Type": "DIMENSION", "Key": "RESOURCE_ID"}], - "Metrics": ["UnblendedCost"], - } - - -def test_costs_combine_ec2_actual_and_stopped_vm_eip_estimate() -> None: - ce = client() - stubber = Stubber(ce) - stubber.add_response( - "get_cost_and_usage_with_resources", - { - "ResultsByTime": [ - { - "TimePeriod": {"Start": "2026-08-06", "End": "2026-08-07"}, - "Estimated": False, - "Groups": [ - { - "Keys": ["i-a"], - "Metrics": { - "UnblendedCost": {"Amount": "0.30", "Unit": "USD"} - }, - } - ], +def row(*values: str): + return {"Data": [{"VarCharValue": value} for value in values]} + + +class FakeAthena: + def __init__(self, *, state="SUCCEEDED", reason="", pages=None): + self.state = state + self.reason = reason + self.pages = pages or [{"ResultSet": {"Rows": []}}] + self.start_calls = [] + self.execution_calls = [] + self.result_calls = [] + self.start_error = None + + def start_query_execution(self, **kwargs): + self.start_calls.append(kwargs) + if self.start_error: + raise self.start_error + return {"QueryExecutionId": "query-123"} + + def get_query_execution(self, **kwargs): + self.execution_calls.append(kwargs) + return { + "QueryExecution": { + "Status": { + "State": self.state, + "StateChangeReason": self.reason, } - ] - }, - expected_request(["i-a"]), - ) - instance = vm( - "i-a", - state="stopped", - allocation_id="eipalloc-a", - eip_created_at=datetime(2026, 8, 19, tzinfo=UTC), + } + } + + def get_query_results(self, **kwargs): + self.result_calls.append(kwargs) + index = 0 if "NextToken" not in kwargs else int(kwargs["NextToken"]) + result = dict(self.pages[index]) + if index + 1 < len(self.pages): + result["NextToken"] = str(index + 1) + return result + + +def service(client, **kwargs): + return CostService( + client, + database="vms_portal_costs", + table="cur2", + workgroup="vms-portal-costs", + sleeper=lambda _: None, + **kwargs, ) - with stubber: - cost = CostService(ce, public_ipv4_hourly_usd=Decimal("0.005")).get_costs( - [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) - )["i-a"] - assert cost.ec2_amount == Decimal("0.30") - assert cost.eip_amount == Decimal("0.180") - assert cost.total_amount == Decimal("0.480") +def test_batches_visible_instances_into_one_sixty_day_effective_cost_query() -> None: + client = FakeAthena( + pages=[ + { + "ResultSet": { + "Rows": [ + row( + "instance_id", + "amount", + "currency", + "period_start", + "period_end", + ), + row( + "i-11111111111111111", + "12.50", + "USD", + "2026-07-01", + "2026-08-28", + ), + row( + "i-22222222222222222", + "0", + "USD", + "2026-08-01", + "2026-08-28", + ), + ] + } + } + ] + ) + result = service(client).get_costs( + [vm("i-11111111111111111"), vm("i-22222222222222222")], + datetime(2026, 8, 29, tzinfo=UTC), + ) -def test_eip_estimate_is_capped_at_fourteen_days() -> None: - ce = client() - stubber = Stubber(ce) - stubber.add_response( - "get_cost_and_usage_with_resources", - {"ResultsByTime": []}, - expected_request(["i-a"]), + assert len(client.start_calls) == 1 + query = client.start_calls[0]["QueryString"] + assert 'FROM "vms_portal_costs"."cur2"' in query + assert "SavingsPlanCoveredUsage" in query + assert "savings_plan_savings_plan_effective_cost" in query + assert "DiscountedUsage" in query + assert "reservation_effective_cost" in query + assert "line_item_unblended_cost" in query + assert "2026-07-01" in query and "2026-08-30" in query + assert client.start_calls[0]["WorkGroup"] == "vms-portal-costs" + assert result["i-11111111111111111"].status == "ready" + assert result["i-11111111111111111"].amount == Decimal("12.50") + assert result["i-11111111111111111"].period_start == date(2026, 7, 1) + assert result["i-22222222222222222"].status == "ready" + assert result["i-22222222222222222"].amount == Decimal(0) + + +def test_pages_results_and_marks_instances_without_history_not_ready() -> None: + client = FakeAthena( + pages=[ + {"ResultSet": {"Rows": [row("headers")]}}, + { + "ResultSet": { + "Rows": [ + row( + "i-11111111111111111", + "1.00", + "USD", + "2026-08-01", + "2026-08-28", + ) + ] + } + }, + ] ) - instance = vm( - "i-a", - allocation_id="eipalloc-a", - eip_created_at=datetime(2026, 8, 1, tzinfo=UTC), + + result = service(client).get_costs( + [vm("i-11111111111111111"), vm("i-22222222222222222")], + datetime(2026, 8, 29, tzinfo=UTC), ) - with stubber: - cost = CostService(ce).get_costs( - [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) - )["i-a"] + assert len(client.result_calls) == 2 + assert result["i-11111111111111111"].status == "ready" + assert result["i-22222222222222222"].status == "not_ready" - assert cost.eip_amount == Decimal("1.680") +def test_missing_cur_table_is_not_ready_but_other_query_failure_is_failed() -> None: + now = datetime(2026, 8, 29, tzinfo=UTC) + missing = service(FakeAthena(state="FAILED", reason="TABLE_NOT_FOUND: cur2")) + failed = service(FakeAthena(state="FAILED", reason="GENERIC_INTERNAL_ERROR")) -def test_missing_eip_is_zero_but_missing_eip_timestamp_is_unavailable() -> None: - ce = client() - stubber = Stubber(ce) - stubber.add_response( - "get_cost_and_usage_with_resources", - {"ResultsByTime": []}, - expected_request(["i-no-eip", "i-no-time"]), + assert ( + missing.get_costs([vm("i-11111111111111111")], now)[ + "i-11111111111111111" + ].status + == "not_ready" ) - instances = [ - vm("i-no-eip"), - vm("i-no-time", allocation_id="eipalloc-no-time"), - ] - - with stubber: - costs = CostService(ce).get_costs( - instances, datetime(2026, 8, 20, 12, tzinfo=UTC) - ) - - assert costs["i-no-eip"].eip_amount == Decimal(0) - assert costs["i-no-eip"].total_amount == Decimal(0) - assert costs["i-no-time"].eip_amount is None - assert costs["i-no-time"].total_amount is None - - -def test_cost_explorer_failure_keeps_eip_estimate_available() -> None: - ce = client() - stubber = Stubber(ce) - stubber.add_client_error( - "get_cost_and_usage_with_resources", - service_error_code="AccessDeniedException", - service_message="denied", - expected_params=expected_request(["i-a"]), + assert ( + failed.get_costs([vm("i-11111111111111111")], now)["i-11111111111111111"].status + == "failed" ) - instance = vm( - "i-a", - allocation_id="eipalloc-a", - eip_created_at=datetime(2026, 8, 19, tzinfo=UTC), - ) - - with stubber: - cost = CostService(ce).get_costs( - [instance], datetime(2026, 8, 20, 12, tzinfo=UTC) - )["i-a"] - assert cost.ec2_amount is None - assert cost.eip_amount == Decimal("0.180") - assert cost.total_amount is None - -def test_cost_cache_avoids_second_api_request_for_six_hours() -> None: - ce = client() - stubber = Stubber(ce) - stubber.add_response( - "get_cost_and_usage_with_resources", - {"ResultsByTime": []}, - expected_request(["i-a"]), +def test_athena_api_failure_is_failed_and_cached_for_six_hours() -> None: + client = FakeAthena() + client.start_error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + "StartQueryExecution", + ) + costs = service(client) + instances = [vm("i-11111111111111111")] + now = datetime(2026, 8, 29, tzinfo=UTC) + + first = costs.get_costs(instances, now) + second = costs.get_costs(instances, now.replace(hour=5)) + + assert first[instances[0].instance_id].status == "failed" + assert second == first + assert len(client.start_calls) == 1 + + +def test_malformed_athena_result_is_reported_as_failed() -> None: + client = FakeAthena( + pages=[ + { + "ResultSet": { + "Rows": [ + row("headers"), + row("i-11111111111111111", "not-a-number", "USD", "", ""), + ] + } + } + ] ) - service = CostService(ce) - instance = vm("i-a") - with stubber: - first = service.get_costs([instance], datetime(2026, 8, 20, 1, tzinfo=UTC)) - second = service.get_costs( - [instance], datetime(2026, 8, 20, 6, 59, tzinfo=UTC) - ) + result = service(client).get_costs( + [vm("i-11111111111111111")], datetime(2026, 8, 29, tzinfo=UTC) + ) - assert first == second + assert result["i-11111111111111111"].status == "failed" diff --git a/vms_portal/tests/test_ec2.py b/vms_portal/tests/test_ec2.py index 16fea8b..184b571 100644 --- a/vms_portal/tests/test_ec2.py +++ b/vms_portal/tests/test_ec2.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime + import boto3 import pytest from botocore.stub import Stubber @@ -113,7 +114,9 @@ def test_normalization_allows_missing_dynamic_public_ip() -> None: } raw = instance() raw.pop("PublicIpAddress") - stubber.add_response("describe_instances", {"Reservations": [{"Instances": [raw]}]}, expected) + stubber.add_response( + "describe_instances", {"Reservations": [{"Instances": [raw]}]}, expected + ) with stubber: result = Ec2Service(ec2).list_managed() diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 2acd6c8..ffab760 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -20,7 +20,9 @@ def load_cfn(path: Path): return yaml.load(path.read_text(), Loader=CfnLoader) -def test_windows_template_defines_twenty_conditional_vm_slots_with_dynamic_ips() -> None: +def test_windows_template_defines_twenty_conditional_vm_slots_with_dynamic_ips() -> ( + None +): template = load_cfn(ROOT / "cloudformation/windows-a11y-instance-template.yml") assert template["Parameters"]["InstanceCount"]["AllowedValues"] == [ str(value) for value in range(1, 21) @@ -33,9 +35,7 @@ def test_windows_template_defines_twenty_conditional_vm_slots_with_dynamic_ips() assert condition in template["Conditions"] assert instance["Condition"] == condition assert ( - instance["Properties"]["NetworkInterfaces"][0][ - "AssociatePublicIpAddress" - ] + instance["Properties"]["NetworkInterfaces"][0]["AssociatePublicIpAddress"] is True ) assert template["Outputs"][f"InstanceId{suffix}"]["Condition"] == condition @@ -56,9 +56,9 @@ def test_windows_batch_resources_have_portal_tags() -> None: suffix = f"{index:03d}" instance_tags = { tag["Key"]: tag["Value"] - for tag in template["Resources"][f"WindowsInstance{suffix}"][ - "Properties" - ]["Tags"] + for tag in template["Resources"][f"WindowsInstance{suffix}"]["Properties"][ + "Tags" + ] } expected_common = { "Name": f"${{AWS::StackName}}-{suffix}", @@ -91,7 +91,9 @@ def test_windows_launch_workflow_creates_atomic_batch_and_lists_all_ips() -> Non create = next(step for step in steps if step.get("name") == "Create VM batch stack") assert "aws cloudformation create-stack" in create["run"] assert "--on-failure DELETE" in create["run"] - assert 'ParameterKey=InstanceCount,ParameterValue="${INSTANCE_COUNT}"' in create["run"] + assert ( + 'ParameterKey=InstanceCount,ParameterValue="${INSTANCE_COUNT}"' in create["run"] + ) assert "BatchCreatedAt" not in create["run"] assert "aws cloudformation deploy" not in create["run"] @@ -138,10 +140,12 @@ def test_scheduler_stops_managed_windows_vms_at_one_am_taipei() -> None: "PolicyDocument" ]["Statement"] stop = next(item for item in policy if "ec2:StopInstances" in item["Action"]) - assert stop["Condition"]["StringEquals"][ - "aws:ResourceTag/VmPortalManaged" - ] == "true" - describe = next(item for item in policy if item["Action"] == ["ec2:DescribeInstances"]) + assert ( + stop["Condition"]["StringEquals"]["aws:ResourceTag/VmPortalManaged"] == "true" + ) + describe = next( + item for item in policy if item["Action"] == ["ec2:DescribeInstances"] + ) assert describe["Resource"] == "*" @@ -150,7 +154,9 @@ def test_deploy_workflow_uploads_versioned_lambda_before_access_stack() -> None: (ROOT / ".github/workflows/deploy-vms-portal.yml").read_text() ) steps = workflow["jobs"]["deploy"]["steps"] - prepare = next(step for step in steps if step.get("name") == "Prepare AWS infrastructure") + prepare = next( + step for step in steps if step.get("name") == "Prepare AWS infrastructure" + ) run = prepare["run"] assert "vms-portal-foundation-template.yml" in run assert "lambda/windows_vm_shutdown/lambda_function.py" in run @@ -201,12 +207,17 @@ def test_foundation_defines_cur2_parquet_glue_projection_and_athena_limits() -> assert bucket["BucketEncryption"] assert all(bucket["PublicAccessBlockConfiguration"].values()) - export = resources["PortalCurExport"]["Properties"]["Export"] + assert "PortalCurExport" not in resources + export_path = ROOT / "cloudformation/vms-portal-cur-export-template.yml" + assert export_path.exists() + export_template = load_cfn(export_path) + export = export_template["Resources"]["PortalCurExport"]["Properties"]["Export"] query = export["DataQuery"] assert "line_item_resource_id" in query["QueryStatement"] - assert query["TableConfigurations"]["COST_AND_USAGE_REPORT"][ - "INCLUDE_RESOURCES" - ] == "TRUE" + assert ( + query["TableConfigurations"]["COST_AND_USAGE_REPORT"]["INCLUDE_RESOURCES"] + == "TRUE" + ) destination = export["DestinationConfigurations"]["S3Destination"] assert destination["S3OutputConfigurations"] == { "Compression": "PARQUET", @@ -220,9 +231,12 @@ def test_foundation_defines_cur2_parquet_glue_projection_and_athena_limits() -> assert table["Parameters"]["projection.enabled"] == "true" assert table["Parameters"]["projection.billing_period.type"] == "date" assert "${!billing_period}" in table["Parameters"]["storage.location.template"] - assert resources["CostWorkGroup"]["Properties"]["WorkGroupConfiguration"][ - "BytesScannedCutoffPerQuery" - ] == 1073741824 + assert ( + resources["CostWorkGroup"]["Properties"]["WorkGroupConfiguration"][ + "BytesScannedCutoffPerQuery" + ] + == 1073741824 + ) assert "Crawler" not in " ".join(resources) @@ -244,6 +258,13 @@ def test_deploy_workflow_passes_foundation_cost_outputs_to_access_stack() -> Non ): assert value in prepare assert "CostDatabaseName=" in prepare + assert "vms-portal-cur-export-template.yml" in prepare + assert "--region us-east-1" in prepare + assert ( + prepare.index("vms-portal-foundation-template.yml") + < prepare.index("vms-portal-cur-export-template.yml") + < prepare.index("vms-portal-access-template.yml") + ) def test_portal_runtime_policy_does_not_duplicate_shared_ecr_access() -> None: @@ -251,11 +272,7 @@ def test_portal_runtime_policy_does_not_duplicate_shared_ecr_access() -> None: statements = template["Resources"]["PortalPolicy"]["Properties"]["PolicyDocument"][ "Statement" ] - actions = { - action - for statement in statements - for action in statement["Action"] - } + actions = {action for statement in statements for action in statement["Action"]} assert not any(action.startswith("ecr:") for action in actions) diff --git a/vms_portal/tests/test_web.py b/vms_portal/tests/test_web.py index de2a694..b53c094 100644 --- a/vms_portal/tests/test_web.py +++ b/vms_portal/tests/test_web.py @@ -7,8 +7,8 @@ from argon2 import PasswordHasher from argon2.exceptions import VerificationError from fastapi.testclient import TestClient -from vms_portal.audit import AuditLogger from vms_portal.assignments import Assignment +from vms_portal.audit import AuditLogger from vms_portal.config import Settings from vms_portal.costs import InstanceCost from vms_portal.ec2 import VmInstance @@ -84,26 +84,47 @@ def get_costs(self, vms, now): return { vm.instance_id: InstanceCost( vm.instance_id, + "ready", Decimal("1.25"), - Decimal("0.18"), "USD", - False, + datetime(2026, 7, 1, tzinfo=UTC).date(), + datetime(2026, 8, 20, tzinfo=UTC).date(), + datetime(2026, 8, 20, tzinfo=UTC), + "query-123", + ) + for vm in vms + } + + +class FakeCostsNotReady: + def get_costs(self, vms, now): + return { + vm.instance_id: InstanceCost( + vm.instance_id, + "not_ready", + None, + "USD", + None, + None, datetime(2026, 8, 20, tzinfo=UTC), + "query-123", ) for vm in vms } -class FakeCostsWithoutEc2: +class FakeCostsFailed: def get_costs(self, vms, now): return { vm.instance_id: InstanceCost( vm.instance_id, + "failed", None, - Decimal("0.18"), "USD", - False, + None, + None, datetime(2026, 8, 20, tzinfo=UTC), + "query-123", ) for vm in vms } @@ -195,8 +216,9 @@ def test_admin_home_lists_managed_instances() -> None: assert "198.51.100.9" in response.text assert 'action="/instances/i-1234567890abcdef0/stop"' in response.text assert 'data-confirm="停止 windows-demo?"' in response.text - assert "最近 14 天 EC2 實際成本" in response.text + assert "最近 60 天 EC2 成本" in response.text assert "1.25 USD" in response.text + assert "2026-07-01~2026-08-20" in response.text assert "EIP" not in response.text assert ec2.list_calls == 1 @@ -227,19 +249,30 @@ def test_user_home_never_lists_and_exact_instance_id_lookup_returns_one_vm() -> assert 'action="/instances/i-1234567890abcdef0/stop"' in result.text assert 'name="public_ip"' not in result.text assert "1.25 USD" in result.text + assert "2026-07-01~2026-08-20" in result.text assert "EIP" not in result.text -def test_cost_explorer_failure_shows_unavailable_without_eip_values() -> None: - client, _, _ = make_client(FakeCostsWithoutEc2()) +def test_cur_not_ready_is_distinct_from_zero_cost() -> None: + client, _, _ = make_client(FakeCostsNotReady()) login(client, "admin", "admin-pass") response = client.get("/") - assert "成本資料尚未提供" in response.text + assert "成本報表尚未準備完成" in response.text assert "EIP" not in response.text +def test_cur_query_failure_is_shown_separately() -> None: + client, _, _ = make_client(FakeCostsFailed()) + login(client, "admin", "admin-pass") + + response = client.get("/") + + assert "成本查詢失敗" in response.text + assert "成本報表尚未準備完成" not in response.text + + def test_user_invalid_or_unknown_instance_id_gets_generic_message() -> None: client, _, _ = make_client() login(client, "user", "user-pass") @@ -309,9 +342,7 @@ def test_admin_can_update_assignment_with_csrf_and_audit() -> None: assert response.status_code == 303 assert ec2.lookup_calls == ["i-1234567890abcdef0"] - assert assignments.upsert_calls == [ - ("i-1234567890abcdef0", "Anson", "admin") - ] + assert assignments.upsert_calls == [("i-1234567890abcdef0", "Anson", "admin")] assert '"event":"vm.assignment.updated"' in events[-1] assert '"assignee":"Anson"' in events[-1] @@ -331,9 +362,10 @@ def test_assignment_update_is_admin_only_and_requires_csrf() -> None: client, _, _ = make_client(assignment_repository=assignments) login(client, "user", "user-pass") home = client.get("/") - csrf = home.cookies.get("vms_portal_session_csrf") or client.cookies[ - "vms_portal_session_csrf" - ] + csrf = ( + home.cookies.get("vms_portal_session_csrf") + or client.cookies["vms_portal_session_csrf"] + ) response = client.post( "/instances/i-1234567890abcdef0/assignment", data={"csrf_token": csrf, "assignee": "Anson"}, From 58cf89bcfb7e0c5f4bb0db8a717113c1601454bb Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Sat, 29 Aug 2026 23:20:50 +0800 Subject: [PATCH 11/12] fix: harden VM lookup and cost reporting Co-authored-by: Codex --- .github/workflows/deploy-vms-portal.yml | 9 +++++++ cloudformation/vms-portal-access-template.yml | 1 + docs/vms-portal-deployment-sop.md | 25 +++++++++++++------ lambda/windows_vm_shutdown/lambda_function.py | 4 ++- .../tests/test_lambda_function.py | 4 ++- vms_portal/src/vms_portal/costs.py | 14 ++++++++--- vms_portal/src/vms_portal/ec2.py | 12 ++++++++- vms_portal/tests/test_costs.py | 16 +++++++++--- vms_portal/tests/test_ec2.py | 16 ++++++++++++ vms_portal/tests/test_infrastructure.py | 18 +++++++++++++ 10 files changed, 102 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy-vms-portal.yml b/.github/workflows/deploy-vms-portal.yml index 9f1dbfe..5ec676a 100644 --- a/.github/workflows/deploy-vms-portal.yml +++ b/.github/workflows/deploy-vms-portal.yml @@ -33,6 +33,15 @@ jobs: working-directory: vms_portal - run: uv run pytest working-directory: vms_portal + - run: uv run pytest ../lambda/windows_vm_shutdown/tests + working-directory: vms_portal + - name: Validate CloudFormation templates + run: >- + uvx cfn-lint + cloudformation/vms-portal-foundation-template.yml + cloudformation/vms-portal-cur-export-template.yml + cloudformation/vms-portal-access-template.yml + cloudformation/windows-a11y-instance-template.yml - run: docker build -t vms-portal:validate vms_portal deploy: diff --git a/cloudformation/vms-portal-access-template.yml b/cloudformation/vms-portal-access-template.yml index 83eeefd..527bff7 100644 --- a/cloudformation/vms-portal-access-template.yml +++ b/cloudformation/vms-portal-access-template.yml @@ -117,6 +117,7 @@ Resources: NightlyShutdownSchedule: Type: AWS::Scheduler::Schedule Properties: + Name: vms-portal-nightly-shutdown Description: Stop all running VmPortalManaged Windows instances every night ScheduleExpression: cron(0 1 * * ? *) ScheduleExpressionTimezone: Asia/Taipei diff --git a/docs/vms-portal-deployment-sop.md b/docs/vms-portal-deployment-sop.md index 6801cb1..62ca88a 100644 --- a/docs/vms-portal-deployment-sop.md +++ b/docs/vms-portal-deployment-sop.md @@ -36,6 +36,7 @@ OIDC role 必須允許 workflow 執行下列範圍: - 建立/查詢 ECR repository `vms-portal` 並 push image。 - 在 `ap-northeast-1` 建立或更新 CloudFormation stack `vms-portal-foundation` 與 `vms-portal-access`,並在 BCM Data Exports 唯一支援的 `us-east-1` 建立 `vms-portal-cur-export`。 - 管理受限範圍的 BCM Data Exports、S3、Glue、Athena、Lambda、SQS 與 EventBridge Scheduler 資源,並將 Lambda code artifact 上傳至 foundation stack 建立的 versioned bucket。 +- BCM export bootstrap 需包含 `bcm-data-exports:*` 對 CUR table/export ARN、`cur:PutReportDefinition`,以及只對本 workflow 建立之 Lambda/Scheduler roles 的 `iam:PassRole`;不要給 Portal runtime 這些管理權限。 - 對 role `coseeing-ec2-common` 管理 inline policy `vms-portal-runtime`。 - 建立 `/coseeing/vms-portal` log group。 - 查詢既有 Linux EC2 stack,並設定該 instance 的 IMDSv2 metadata options。 @@ -77,7 +78,7 @@ Linux EC2 不需要 `ec2:*`、`iam:*`、`secretsmanager:*` 或 ECR push 權限 - Windows VM:新版 `windows-a11y-instance-template.yml` 已自動加入 `VmPortalManaged=true`。既有 VM 必須補上相同 tag,否則 Portal 不會顯示或控制它。 - CUR 2.0:deploy workflow 先在 Tokyo 建立加密、封鎖公開存取的 cost bucket、固定 Glue schema 與受 scan limit 保護的 Athena workgroup,再從 `us-east-1` stack 建立 resource-ID Data Export,最後更新 Portal runtime IAM。首次報表通常需等待最多 24 小時;若帳號沒有可用的舊月份檔案,近 60 天畫面會明確顯示實際可用期間。需要補舊資料時由 payer/management account 向 AWS Support 申請 backfill。 -Portal 不再使用 Cost Explorer 的 14 天 resource API,也不估算 EIP 成本。CUR query 對每個 instance 使用 Savings Plan effective cost、RI effective cost或 unblended cost 的適用值,並區分數字 `0`、報表尚未準備及查詢失敗。 +Portal 不再使用 Cost Explorer 的 14 天 resource API,也不估算 EIP 成本。CUR query 對每個 instance 使用 Savings Plan effective cost、RI effective cost 或 unblended cost 的適用值,並區分數字 `0`、報表尚未準備及查詢失敗。 以上三項無法由本 repository 的 deploy workflow 安全代辦。 @@ -95,6 +96,7 @@ Deploy job 會自動完成: - 建立 ECR repository(若不存在)。 - 依序部署 foundation、上傳 versioned shutdown Lambda、再部署 runtime IAM/Scheduler;任一步失敗都不會更新 Portal container。 +- 在 `us-east-1` 部署 CUR 2.0 export;S3、Glue、Athena、Lambda、Scheduler 與 Portal 仍位於 `ap-northeast-1`。 - 將 Linux EC2 設為 `HttpTokens=required`、hop limit `2`。 - 以 Git commit SHA 作為 immutable image tag,build/push Docker image。 - 透過 Ansible 更新 Portal/Traefik。 @@ -105,10 +107,16 @@ Deploy job 會自動完成: ## C. 部署後人工驗收 1. 開啟 `https://vms.coseeing.org`。 -2. 使用 admin 登入:只應列出有 `VmPortalManaged=true` 的 VM。 -3. 使用 user 登入:不應直接出現清單;輸入已知 Public IPv4 後才能看到該 VM。 -4. 第一次開關機前,人工確認完整 instance ID、Public IPv4、目前狀態與預定動作。 -5. 確認每台 VM 分開顯示 EC2 實際、EIP 估算及合計。Cost Explorer 尚未準備完成時,EC2 顯示「成本資料尚未提供」屬正常情況;有完整 EIP tag 時仍應顯示 EIP 估算,但不顯示合計。 +2. 使用 admin 登入:只應列出有 `VmPortalManaged=true` 的 VM,並看到 Name、Instance ID、assignment、private IP、目前 public IP、狀態、60 天成本與開關機控制。 +3. 更新一筆 assignment、重新部署 Portal,再確認資料仍存在。assignment 只是紀錄「這台給誰」,不會改變登入或操作權限。 +4. 使用 user 登入:不應直接出現清單;輸入完整 Instance ID 後,只能看到該 VM 的 Name、Instance ID、private IP、狀態、60 天成本與開關機控制,不應看到 assignment 或 public IP。 +5. 第一次開關機前,人工確認完整 Instance ID、private IP、目前狀態與預定動作。public IP 是動態值,stop/start 後可能改變。 +6. 成本有三種明確狀態:數值(包括 `0`)、`成本報表尚未準備完成`、`成本查詢失敗`。有資料時同時顯示實際可用期間;首次 CUR delivery 最多可能等待 24 小時。 +7. 在 EventBridge Scheduler 確認 `vms-portal-nightly-shutdown` 類型的 schedule 為 enabled、timezone 是 `Asia/Taipei`,並在 01:00 後確認所有 running 且 `VmPortalManaged=true` 的 VM 進入 stopping/stopped。此機制只停機,不會自動開機。 + +Assignment SQLite 位於 host 的 `/data/vms-portal/data/portal.db`,只有這個目錄以 writable bind mount 掛入 read-only container。備份前先在 `/data/vms-portal` 執行 `docker compose stop vms-portal`,複製 `data/portal.db` 到受控備份位置,再執行 `docker compose start vms-portal`,避免複製進行中的 SQLite transaction。刪除 VM 時不會自動刪除 assignment record;Admin 清單只 join 目前仍存在的 VM。 + +成本機制本身(小量 S3、Athena query、Glue catalog、Scheduler/Lambda)預估約 `0.02–0.10 USD/月`,不含 Windows EC2、EBS、資料傳輸與 public IPv4。每台 VM 有 public IPv4 時另依 AWS public IPv4 單價計費;以 `0.005 USD/小時` 且整月持有估算約 `3.60 USD/台/月`,實際金額以當期 AWS 帳單與價格為準。每天 01:00 自動停機可停止 EC2 compute 累計;非 EIP 的動態 public IPv4 會在 stop 時釋放,但 EBS 仍會繼續計費。 ## D. Rollback 與故障排除 @@ -117,8 +125,11 @@ Rollback:checkout 上一個已知正常 commit,從該 commit 手動執行同 若需立即停用控制能力,先從 `coseeing-ec2-common` 移除 `vms-portal-runtime` inline policy,再查看 `/coseeing/vms-portal` CloudWatch log。 - readiness 503:檢查 Secret JSON schema、instance role 與 IMDSv2 hop limit。 -- VM 不出現:檢查 Region、Public IPv4 與 `VmPortalManaged=true`。 +- VM 不出現:檢查 Region、Instance ID 與 `VmPortalManaged=true`。 - AccessDenied:以 CloudTrail request ID 確認缺少的動作,不要擴大成 `ec2:*`。 -- EC2 成本空白:確認 granular data 已啟用並等待最多 48 小時。EIP 顯示「估算資料不足」時檢查 EIP 的 `VmPortalCreatedAt` tag 與 `PUBLIC_IPV4_HOURLY_USD` 部署值。 +- 成本顯示尚未準備:確認 `vms-portal-cur-export` 位於 `us-east-1`、Data Export execution 成功,並等待首次 S3 delivery(通常最多 24 小時)。需要近兩個月舊資料時向 AWS Support 詢問 backfill。 +- 成本查詢失敗:從 Portal log 取得 error class、AWS request ID 與 Athena query execution ID,再檢查 Tokyo Glue table、Athena workgroup、S3 prefixes 與 runtime IAM;不要把錯誤改顯示成 `0`。 +- 每日關機未執行:檢查 Scheduler execution role、Lambda log、SQS DLQ 與 VM 的 `VmPortalManaged=true` tag。 +- 既有 `i-021a0b068258c64d5` 沒有 public IP,且不會被 template 更新自動重建;需要直接 IPv4 外網時,請用 Windows batch workflow 重建。 - 帳密輪替未生效:確認 `auth_version` 已增加且 Secret stage 是 `AWSCURRENT`。 - Traefik 502:檢查 container health、`entry` network 與 `/data/entry/traefik.yml`。 diff --git a/lambda/windows_vm_shutdown/lambda_function.py b/lambda/windows_vm_shutdown/lambda_function.py index 0d622da..9cd8fed 100644 --- a/lambda/windows_vm_shutdown/lambda_function.py +++ b/lambda/windows_vm_shutdown/lambda_function.py @@ -30,7 +30,9 @@ def stop_managed_instances(ec2_client): ec2_client.stop_instances(InstanceIds=batch) except Exception: LOGGER.exception( - "managed VM shutdown failed", + "managed VM shutdown failed matched=%d stopped=%d", + len(instance_ids), + stopped, extra={"matched": len(instance_ids), "stopped": stopped}, ) raise diff --git a/lambda/windows_vm_shutdown/tests/test_lambda_function.py b/lambda/windows_vm_shutdown/tests/test_lambda_function.py index 98bd2db..9228f86 100644 --- a/lambda/windows_vm_shutdown/tests/test_lambda_function.py +++ b/lambda/windows_vm_shutdown/tests/test_lambda_function.py @@ -73,9 +73,11 @@ def test_stops_all_instances_in_bounded_batches() -> None: assert [len(call["InstanceIds"]) for call in ec2.stop_calls] == [1000, 1] -def test_stop_failure_is_propagated() -> None: +def test_stop_failure_is_logged_and_propagated(caplog) -> None: ec2 = FakeEc2([page("i-1234567890abcdef0")]) ec2.error = RuntimeError("stop failed") with pytest.raises(RuntimeError, match="stop failed"): module.stop_managed_instances(ec2) + + assert "matched=1 stopped=0" in caplog.text diff --git a/vms_portal/src/vms_portal/costs.py b/vms_portal/src/vms_portal/costs.py index 4d9c141..bc80c87 100644 --- a/vms_portal/src/vms_portal/costs.py +++ b/vms_portal/src/vms_portal/costs.py @@ -94,7 +94,8 @@ def get_costs( else "failed" ) self._logger.error( - "Athena cost query ended in %s: %s", + "Athena cost query %s ended in %s: %s", + query_id, status, reason, extra={"query_execution_id": query_id}, @@ -105,8 +106,10 @@ def get_costs( except ClientError as exc: metadata = exc.response.get("ResponseMetadata", {}) self._logger.exception( - "Athena cost API failure: %s", + "Athena cost API failure code=%s request_id=%s query_id=%s", exc.response.get("Error", {}).get("Code", "unknown"), + metadata.get("RequestId", "unknown"), + query_id or "not-started", extra={ "aws_request_id": metadata.get("RequestId"), "query_execution_id": query_id, @@ -115,7 +118,8 @@ def get_costs( result = self._empty(instance_ids, "failed", now, query_id) except (InvalidOperation, ValueError, KeyError, IndexError): self._logger.exception( - "Athena cost result could not be parsed", + "Athena cost result could not be parsed query_id=%s", + query_id or "not-started", extra={"query_execution_id": query_id}, ) result = self._empty(instance_ids, "failed", now, query_id) @@ -141,7 +145,9 @@ def _build_query(self, instance_ids: frozenset[str], now: datetime) -> str: THEN savings_plan_savings_plan_effective_cost WHEN line_item_line_item_type = 'DiscountedUsage' THEN reservation_effective_cost - ELSE line_item_unblended_cost + WHEN line_item_line_item_type = 'Usage' + THEN line_item_unblended_cost + ELSE CAST(0 AS DECIMAL(38,18)) END ) AS amount, MAX(line_item_currency_code) AS currency, diff --git a/vms_portal/src/vms_portal/ec2.py b/vms_portal/src/vms_portal/ec2.py index 9916d62..d0706aa 100644 --- a/vms_portal/src/vms_portal/ec2.py +++ b/vms_portal/src/vms_portal/ec2.py @@ -5,6 +5,8 @@ from ipaddress import IPv4Address from typing import Any +from botocore.exceptions import ClientError + class VmError(RuntimeError): pass @@ -59,7 +61,15 @@ def list_managed(self) -> list[VmInstance]: return sorted(instances, key=lambda vm: (vm.name.casefold(), vm.instance_id)) def find_managed_by_instance_id(self, instance_id: str) -> VmInstance | None: - response = self._client.describe_instances(InstanceIds=[instance_id]) + try: + response = self._client.describe_instances(InstanceIds=[instance_id]) + except ClientError as exc: + if ( + exc.response.get("Error", {}).get("Code") + == "InvalidInstanceID.NotFound" + ): + return None + raise instances = _normalize_page(response) if not instances: return None diff --git a/vms_portal/tests/test_costs.py b/vms_portal/tests/test_costs.py index bceee77..4f67bc8 100644 --- a/vms_portal/tests/test_costs.py +++ b/vms_portal/tests/test_costs.py @@ -118,6 +118,8 @@ def test_batches_visible_instances_into_one_sixty_day_effective_cost_query() -> assert "DiscountedUsage" in query assert "reservation_effective_cost" in query assert "line_item_unblended_cost" in query + assert "WHEN line_item_line_item_type = 'Usage'" in query + assert "ELSE CAST(0 AS DECIMAL(38,18))" in query assert "2026-07-01" in query and "2026-08-30" in query assert client.start_calls[0]["WorkGroup"] == "vms-portal-costs" assert result["i-11111111111111111"].status == "ready" @@ -157,7 +159,9 @@ def test_pages_results_and_marks_instances_without_history_not_ready() -> None: assert result["i-22222222222222222"].status == "not_ready" -def test_missing_cur_table_is_not_ready_but_other_query_failure_is_failed() -> None: +def test_missing_cur_table_is_not_ready_but_other_query_failure_is_failed( + caplog, +) -> None: now = datetime(2026, 8, 29, tzinfo=UTC) missing = service(FakeAthena(state="FAILED", reason="TABLE_NOT_FOUND: cur2")) failed = service(FakeAthena(state="FAILED", reason="GENERIC_INTERNAL_ERROR")) @@ -172,12 +176,16 @@ def test_missing_cur_table_is_not_ready_but_other_query_failure_is_failed() -> N failed.get_costs([vm("i-11111111111111111")], now)["i-11111111111111111"].status == "failed" ) + assert "query-123" in caplog.text -def test_athena_api_failure_is_failed_and_cached_for_six_hours() -> None: +def test_athena_api_failure_is_failed_and_cached_for_six_hours(caplog) -> None: client = FakeAthena() client.start_error = ClientError( - {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + { + "Error": {"Code": "AccessDeniedException", "Message": "denied"}, + "ResponseMetadata": {"RequestId": "request-123"}, + }, "StartQueryExecution", ) costs = service(client) @@ -190,6 +198,8 @@ def test_athena_api_failure_is_failed_and_cached_for_six_hours() -> None: assert first[instances[0].instance_id].status == "failed" assert second == first assert len(client.start_calls) == 1 + assert "AccessDeniedException" in caplog.text + assert "request-123" in caplog.text def test_malformed_athena_result_is_reported_as_failed() -> None: diff --git a/vms_portal/tests/test_ec2.py b/vms_portal/tests/test_ec2.py index 184b571..45d4ba4 100644 --- a/vms_portal/tests/test_ec2.py +++ b/vms_portal/tests/test_ec2.py @@ -100,6 +100,22 @@ def test_user_lookup_requires_exact_instance_id_and_tag() -> None: assert str(found.private_ip) == "10.0.0.4" +def test_user_lookup_returns_none_when_instance_id_does_not_exist() -> None: + ec2 = client() + stubber = Stubber(ec2) + stubber.add_client_error( + "describe_instances", + service_error_code="InvalidInstanceID.NotFound", + service_message="The instance ID does not exist", + expected_params={"InstanceIds": ["i-00000000000000000"]}, + ) + + with stubber: + found = Ec2Service(ec2).find_managed_by_instance_id("i-00000000000000000") + + assert found is None + + def test_normalization_allows_missing_dynamic_public_ip() -> None: ec2 = client() stubber = Stubber(ec2) diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index ffab760..3ac72e2 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -130,6 +130,7 @@ def test_scheduler_stops_managed_windows_vms_at_one_am_taipei() -> None: template = load_cfn(ROOT / "cloudformation/vms-portal-access-template.yml") schedule = template["Resources"]["NightlyShutdownSchedule"] assert schedule["Type"] == "AWS::Scheduler::Schedule" + assert schedule["Properties"]["Name"] == "vms-portal-nightly-shutdown" assert schedule["Properties"]["ScheduleExpression"] == "cron(0 1 * * ? *)" assert schedule["Properties"]["ScheduleExpressionTimezone"] == "Asia/Taipei" assert schedule["Properties"]["FlexibleTimeWindow"] == {"Mode": "OFF"} @@ -306,6 +307,23 @@ def test_deploy_workflow_requires_exact_domain_confirmation() -> None: ) +def test_deploy_workflow_validates_lambda_and_cloudformation() -> None: + workflow = yaml.safe_load( + (ROOT / ".github/workflows/deploy-vms-portal.yml").read_text() + ) + steps = workflow["jobs"]["validate"]["steps"] + commands = "\n".join(str(step.get("run", "")) for step in steps) + assert "../lambda/windows_vm_shutdown/tests" in commands + assert "cfn-lint" in commands + for template in ( + "vms-portal-foundation-template.yml", + "vms-portal-cur-export-template.yml", + "vms-portal-access-template.yml", + "windows-a11y-instance-template.yml", + ): + assert template in commands + + def test_deploy_workflow_derives_immutable_image_tag_and_defaults_secret() -> None: workflow = yaml.safe_load( (ROOT / ".github/workflows/deploy-vms-portal.yml").read_text() From f5adc48a583008efd0c90ce5d17809d31acbd1cb Mon Sep 17 00:00:00 2001 From: Anson Shie Date: Thu, 3 Sep 2026 20:22:21 +0800 Subject: [PATCH 12/12] fix: allow Data Exports bucket validation Co-authored-by: Codex --- cloudformation/vms-portal-foundation-template.yml | 2 +- vms_portal/tests/test_infrastructure.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cloudformation/vms-portal-foundation-template.yml b/cloudformation/vms-portal-foundation-template.yml index 9499e7e..8382bd2 100644 --- a/cloudformation/vms-portal-foundation-template.yml +++ b/cloudformation/vms-portal-foundation-template.yml @@ -57,7 +57,7 @@ Resources: Principal: Service: bcm-data-exports.amazonaws.com Action: s3:PutObject - Resource: !Sub ${CostDataBucket.Arn}/vms-portal-cur/* + Resource: !Sub ${CostDataBucket.Arn}/* Condition: ArnLike: aws:SourceArn: !Sub arn:${AWS::Partition}:bcm-data-exports:us-east-1:${AWS::AccountId}:export/* diff --git a/vms_portal/tests/test_infrastructure.py b/vms_portal/tests/test_infrastructure.py index 3ac72e2..f5d5fde 100644 --- a/vms_portal/tests/test_infrastructure.py +++ b/vms_portal/tests/test_infrastructure.py @@ -208,6 +208,16 @@ def test_foundation_defines_cur2_parquet_glue_projection_and_athena_limits() -> assert bucket["BucketEncryption"] assert all(bucket["PublicAccessBlockConfiguration"].values()) + bucket_policy = resources["CostDataBucketPolicy"]["Properties"]["PolicyDocument"][ + "Statement" + ] + data_exports_delivery = next( + statement + for statement in bucket_policy + if statement["Sid"] == "EnableAWSDataExportsToWriteToS3" + ) + assert data_exports_delivery["Resource"] == "${CostDataBucket.Arn}/*" + assert "PortalCurExport" not in resources export_path = ROOT / "cloudformation/vms-portal-cur-export-template.yml" assert export_path.exists()
名稱Instance ID指派給Private IPv4目前 Public IPv4狀態最近 14 天 EC2 實際成本操作
名稱Instance ID指派給Private IPv4目前 Public IPv4狀態最近 60 天 EC2 成本操作
{{ vm.private_ip }} {{ vm.public_ip or '—' }} {{ vm.state }}{% if cost and cost.ec2_amount is not none %}{{ cost.ec2_amount }} {{ cost.currency }}{% else %}成本資料尚未提供{% endif %}{% if cost and cost.status == 'ready' %}{{ cost.amount }} {{ cost.currency }}
可用期間:{{ cost.period_start }}~{{ cost.period_end }}{% elif cost and cost.status == 'not_ready' %}成本報表尚未準備完成{% else %}成本查詢失敗{% endif %}
{% if vm.state == 'running' %}
{% elif vm.state == 'stopped' %}
{% else %}狀態轉換中{% endif %}