diff --git a/.github/workflows/deploy-vms-portal.yml b/.github/workflows/deploy-vms-portal.yml index fcf9956..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: @@ -49,6 +58,8 @@ jobs: ECR_REPOSITORY: vms-portal 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: @@ -70,6 +81,42 @@ 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 + + FOUNDATION_OUTPUTS=$(aws cloudformation describe-stacks \ + --stack-name "$FOUNDATION_STACK_NAME" \ + --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") + + 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 + 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 +133,15 @@ jobs: ExistingRoleName="$RUNTIME_ROLE_NAME" \ AuthSecretArn="$SECRET_ARN" \ LogRetentionDays=90 \ + 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 \ @@ -112,6 +168,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 @@ -132,11 +192,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/.github/workflows/launch-windows-a11y-ec2.yml b/.github/workflows/launch-windows-a11y-ec2.yml index 7a2fb30..bbf497c 100644 --- a/.github/workflows/launch-windows-a11y-ec2.yml +++ b/.github/workflows/launch-windows-a11y-ec2.yml @@ -19,14 +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 - type: string - instance_name: - description: "Launch only: EC2 Name tag" + instance_count: + description: "Launch only: number of VMs to create (1-20)" required: true - default: "windows-a11y" + default: "1" type: string instance_type: description: "Launch only: EC2 instance type" @@ -59,15 +55,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 +76,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 +103,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 \ @@ -116,49 +119,74 @@ 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 + + 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 }}" \ + --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 | 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}") + 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" delete: @@ -185,6 +213,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}" @@ -203,4 +235,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/ansible_yaml/vms-portal-playbook.yml b/ansible_yaml/vms-portal-playbook.yml index 2acec58..352d6d0 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,6 +31,10 @@ content: | 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 @@ -38,6 +50,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/cloudformation/vms-portal-access-template.yml b/cloudformation/vms-portal-access-template.yml index 47362d9..527bff7 100644 --- a/cloudformation/vms-portal-access-template.yml +++ b/cloudformation/vms-portal-access-template.yml @@ -11,6 +11,24 @@ Parameters: LogRetentionDays: Type: Number Default: 90 + ShutdownCodeS3Bucket: + Type: String + ShutdownCodeS3Key: + 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: @@ -19,6 +37,102 @@ 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: + Name: vms-portal-nightly-shutdown + 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: @@ -48,11 +162,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-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 new file mode 100644 index 0000000..8382bd2 --- /dev/null +++ b/cloudformation/vms-portal-foundation-template.yml @@ -0,0 +1,147 @@ +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 + + 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}/* + Condition: + ArnLike: + aws:SourceArn: !Sub arn:${AWS::Partition}:bcm-data-exports:us-east-1:${AWS::AccountId}:export/* + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + + 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_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 + 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/cloudformation/windows-a11y-instance-template.yml b/cloudformation/windows-a11y-instance-template.yml index cb5e91e..4c3aeb2 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 dynamic public 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 VMs 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,315 @@ Parameters: KeyName: Type: AWS::EC2::KeyPair::KeyName Description: EC2 KeyPair for emergency access - InstanceName: - Type: String - Description: Name tag for the instance +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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + WindowsInstance006: Type: AWS::EC2::Instance + Condition: CreateSlot006 Properties: InstanceType: !Ref InstanceType ImageId: !Ref AmiId @@ -50,15 +378,584 @@ Resources: VolumeType: gp3 Tags: - Key: Name - Value: !Ref InstanceName + Value: !Sub "${AWS::StackName}-006" - Key: VmPortalManaged Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "006" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" + WindowsInstance016: + Type: AWS::EC2::Instance + Condition: CreateSlot016 + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref AmiId + KeyName: !Ref KeyName + IamInstanceProfile: !Ref InstanceProfileName + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref SubnetId + GroupSet: + - !Ref SecurityGroupId + AssociatePublicIpAddress: true + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref DiskSize + VolumeType: gp3 + Tags: + - Key: Name + Value: !Sub "${AWS::StackName}-016" + - Key: VmPortalManaged + Value: "true" + - Key: VmPortalStack + Value: !Ref "AWS::StackName" + - Key: VmPortalInstanceIndex + Value: "016" + 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: true + 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" + 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: true + 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" + 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: true + 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" + 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: true + 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" 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 + PrivateIp001: + Condition: CreateSlot001 + Value: !GetAtt WindowsInstance001.PrivateIp + PublicIp001: + Condition: CreateSlot001 + Value: !GetAtt WindowsInstance001.PublicIp + InstanceId002: + Condition: CreateSlot002 + Value: !Ref WindowsInstance002 + PrivateIp002: + Condition: CreateSlot002 + Value: !GetAtt WindowsInstance002.PrivateIp + PublicIp002: + Condition: CreateSlot002 + Value: !GetAtt WindowsInstance002.PublicIp + InstanceId003: + Condition: CreateSlot003 + Value: !Ref WindowsInstance003 + PrivateIp003: + Condition: CreateSlot003 + Value: !GetAtt WindowsInstance003.PrivateIp + PublicIp003: + Condition: CreateSlot003 + Value: !GetAtt WindowsInstance003.PublicIp + InstanceId004: + Condition: CreateSlot004 + Value: !Ref WindowsInstance004 + PrivateIp004: + Condition: CreateSlot004 + Value: !GetAtt WindowsInstance004.PrivateIp + PublicIp004: + Condition: CreateSlot004 + Value: !GetAtt WindowsInstance004.PublicIp + InstanceId005: + Condition: CreateSlot005 + Value: !Ref WindowsInstance005 + PrivateIp005: + Condition: CreateSlot005 + Value: !GetAtt WindowsInstance005.PrivateIp + PublicIp005: + Condition: CreateSlot005 + Value: !GetAtt WindowsInstance005.PublicIp + InstanceId006: + Condition: CreateSlot006 + Value: !Ref WindowsInstance006 + PrivateIp006: + Condition: CreateSlot006 + Value: !GetAtt WindowsInstance006.PrivateIp + PublicIp006: + Condition: CreateSlot006 + Value: !GetAtt WindowsInstance006.PublicIp + InstanceId007: + Condition: CreateSlot007 + Value: !Ref WindowsInstance007 + PrivateIp007: + Condition: CreateSlot007 + Value: !GetAtt WindowsInstance007.PrivateIp + PublicIp007: + Condition: CreateSlot007 + Value: !GetAtt WindowsInstance007.PublicIp + InstanceId008: + Condition: CreateSlot008 + Value: !Ref WindowsInstance008 + PrivateIp008: + Condition: CreateSlot008 + Value: !GetAtt WindowsInstance008.PrivateIp + PublicIp008: + Condition: CreateSlot008 + Value: !GetAtt WindowsInstance008.PublicIp + InstanceId009: + Condition: CreateSlot009 + Value: !Ref WindowsInstance009 + PrivateIp009: + Condition: CreateSlot009 + Value: !GetAtt WindowsInstance009.PrivateIp + PublicIp009: + Condition: CreateSlot009 + Value: !GetAtt WindowsInstance009.PublicIp + InstanceId010: + Condition: CreateSlot010 + Value: !Ref WindowsInstance010 + PrivateIp010: + Condition: CreateSlot010 + Value: !GetAtt WindowsInstance010.PrivateIp + PublicIp010: + Condition: CreateSlot010 + Value: !GetAtt WindowsInstance010.PublicIp + InstanceId011: + Condition: CreateSlot011 + Value: !Ref WindowsInstance011 + PrivateIp011: + Condition: CreateSlot011 + Value: !GetAtt WindowsInstance011.PrivateIp + PublicIp011: + Condition: CreateSlot011 + Value: !GetAtt WindowsInstance011.PublicIp + InstanceId012: + Condition: CreateSlot012 + Value: !Ref WindowsInstance012 + PrivateIp012: + Condition: CreateSlot012 + Value: !GetAtt WindowsInstance012.PrivateIp + PublicIp012: + Condition: CreateSlot012 + Value: !GetAtt WindowsInstance012.PublicIp + InstanceId013: + Condition: CreateSlot013 + Value: !Ref WindowsInstance013 + PrivateIp013: + Condition: CreateSlot013 + Value: !GetAtt WindowsInstance013.PrivateIp + PublicIp013: + Condition: CreateSlot013 + Value: !GetAtt WindowsInstance013.PublicIp + InstanceId014: + Condition: CreateSlot014 + Value: !Ref WindowsInstance014 + PrivateIp014: + Condition: CreateSlot014 + Value: !GetAtt WindowsInstance014.PrivateIp + PublicIp014: + Condition: CreateSlot014 + Value: !GetAtt WindowsInstance014.PublicIp + InstanceId015: + Condition: CreateSlot015 + Value: !Ref WindowsInstance015 + PrivateIp015: + Condition: CreateSlot015 + Value: !GetAtt WindowsInstance015.PrivateIp + PublicIp015: + Condition: CreateSlot015 + Value: !GetAtt WindowsInstance015.PublicIp + InstanceId016: + Condition: CreateSlot016 + Value: !Ref WindowsInstance016 + PrivateIp016: + Condition: CreateSlot016 + Value: !GetAtt WindowsInstance016.PrivateIp + PublicIp016: + Condition: CreateSlot016 + Value: !GetAtt WindowsInstance016.PublicIp + InstanceId017: + Condition: CreateSlot017 + Value: !Ref WindowsInstance017 + PrivateIp017: + Condition: CreateSlot017 + Value: !GetAtt WindowsInstance017.PrivateIp + PublicIp017: + Condition: CreateSlot017 + Value: !GetAtt WindowsInstance017.PublicIp + InstanceId018: + Condition: CreateSlot018 + Value: !Ref WindowsInstance018 + PrivateIp018: + Condition: CreateSlot018 + Value: !GetAtt WindowsInstance018.PrivateIp + PublicIp018: + Condition: CreateSlot018 + Value: !GetAtt WindowsInstance018.PublicIp + InstanceId019: + Condition: CreateSlot019 + Value: !Ref WindowsInstance019 + PrivateIp019: + Condition: CreateSlot019 + Value: !GetAtt WindowsInstance019.PrivateIp + PublicIp019: + Condition: CreateSlot019 + Value: !GetAtt WindowsInstance019.PublicIp + InstanceId020: + Condition: CreateSlot020 + Value: !Ref WindowsInstance020 + PrivateIp020: + Condition: CreateSlot020 + Value: !GetAtt WindowsInstance020.PrivateIp + PublicIp020: + Condition: CreateSlot020 + Value: !GetAtt WindowsInstance020.PublicIp diff --git a/docs/vms-portal-deployment-sop.md b/docs/vms-portal-deployment-sop.md index b9efc00..62ca88a 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,9 @@ OIDC role 必須允許 workflow 執行下列範圍: - 查詢 `prod/vms-portal/auth` 的 ARN。 - 建立/查詢 ECR repository `vms-portal` 並 push image。 -- 建立或更新 CloudFormation stack `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。 +- 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。 @@ -49,10 +51,12 @@ OIDC role 必須允許 workflow 執行下列範圍: | AWS 權限 | Resource 限制 | 用途 | | --- | --- | --- | -| `ec2:DescribeInstances` | `*` | admin 列出 VM、user 依 Public IPv4 查詢,以及每次開關機前重新驗證 tag/IP/狀態。此 API 不支援限制到單一 instance 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: @@ -68,11 +72,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 不會顯示或控制它。 -- Cost Explorer:用 payer/management account 開啟 **Billing and Cost Management** → **Cost Management preferences** → **Granular data**,啟用 EC2 resource-level data。資料只涵蓋最近 14 天,可能需等待 48 小時,且 granular data/API request 可能產生費用。 +- 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`、報表尚未準備及查詢失敗。 以上三項無法由本 repository 的 deploy workflow 安全代辦。 @@ -89,7 +95,8 @@ Linux EC2 不需要 `ec2:*`、`iam:*`、`secretsmanager:*` 或 ECR push 權限 Deploy job 會自動完成: - 建立 ECR repository(若不存在)。 -- 部署 runtime IAM policy 與 CloudWatch log group。 +- 依序部署 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。 @@ -100,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. Cost Explorer 尚未準備完成時,畫面顯示「成本資料尚未提供」屬正常情況。 +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 與故障排除 @@ -112,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:*`。 -- 成本空白:確認 granular data 已啟用並等待最多 48 小時。 +- 成本顯示尚未準備:確認 `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/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index 1e57022..8b01a3f 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -188,3 +188,36 @@ 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 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. + +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; an individual VM cannot be deleted +through this workflow. diff --git a/lambda/windows_vm_shutdown/lambda_function.py b/lambda/windows_vm_shutdown/lambda_function.py new file mode 100644 index 0000000..9cd8fed --- /dev/null +++ b/lambda/windows_vm_shutdown/lambda_function.py @@ -0,0 +1,47 @@ +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 matched=%d stopped=%d", + len(instance_ids), + stopped, + 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..9228f86 --- /dev/null +++ b/lambda/windows_vm_shutdown/tests/test_lambda_function.py @@ -0,0 +1,83 @@ +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_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/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/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/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/config.py b/vms_portal/src/vms_portal/config.py index 29b15a4..292f2ad 100644 --- a/vms_portal/src/vms_portal/config.py +++ b/vms_portal/src/vms_portal/config.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re from collections.abc import Mapping from dataclasses import dataclass +from pathlib import Path class ConfigurationError(ValueError): @@ -19,6 +21,13 @@ def _positive_int(env: Mapping[str, str], name: str, default: int) -> int: return value +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 + + @dataclass(frozen=True, slots=True) class Settings: auth_secret_id: str @@ -28,6 +37,10 @@ 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 + 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 def from_env(cls, env: Mapping[str, str]) -> Settings: @@ -41,8 +54,21 @@ 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, cost_cache_seconds=_positive_int(env, "COST_CACHE_SECONDS", 21_600), + 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 c6627c8..bc80c87 100644 --- a/vms_portal/src/vms_portal/costs.py +++ b/vms_portal/src/vms_portal/costs.py @@ -1,102 +1,241 @@ 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 -class CostUnavailable(RuntimeError): - pass +_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 - amount: Decimal + status: str + amount: Decimal | None currency: str - estimated: bool + period_start: date | None + period_end: date | None retrieved_at: datetime + query_execution_id: str | None = None class CostService: - def __init__(self, client: Any, cache_seconds: int = 21_600) -> None: + def __init__( + self, + client: Any, + *, + database: str, + table: str, + workgroup: str, + cache_seconds: int = 21_600, + 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._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] = {} 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) - 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) - 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(key)}}, - ] - }, - "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} + + 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 + response = self._client.start_query_execution( + QueryString=self._build_query(instance_ids, now), + QueryExecutionContext={"Database": self._database}, + WorkGroup=self._workgroup, + ) + 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 %s ended in %s: %s", + query_id, + 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: - code = exc.response.get("Error", {}).get("Code", "Unknown") - raise CostUnavailable(f"Cost Explorer unavailable: {code}") from exc - result = { - instance_id: InstanceCost( - instance_id, - amounts[instance_id], - currencies[instance_id], - estimated[instance_id], - now, + metadata = exc.response.get("ResponseMetadata", {}) + self._logger.exception( + "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, + }, ) - for instance_id in key - } - self._cache_key = key + result = self._empty(instance_ids, "failed", now, query_id) + except (InvalidOperation, ValueError, KeyError, IndexError): + self._logger.exception( + "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) + + 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 + 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, + 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 _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/ec2.py b/vms_portal/src/vms_portal/ec2.py index 037cf68..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 @@ -30,6 +32,7 @@ class InvalidStateTransition(VmError): class VmInstance: instance_id: str name: str + private_ip: IPv4Address public_ip: IPv4Address | None instance_type: str state: str @@ -57,18 +60,26 @@ def list_managed(self) -> list[VmInstance]: ] 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 instances[0] if instances else None + def find_managed_by_instance_id(self, instance_id: str) -> VmInstance | None: + 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 + 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 @@ -125,6 +136,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"], diff --git a/vms_portal/src/vms_portal/templates/admin.html b/vms_portal/src/vms_portal/templates/admin.html index b32dac2..9de6e99 100644 --- a/vms_portal/src/vms_portal/templates/admin.html +++ b/vms_portal/src/vms_portal/templates/admin.html @@ -1 +1,24 @@ -{% extends "base.html" %}{% block content %}
| 名稱 | Instance ID | Public 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 %} |
| 名稱 | Instance ID | 指派給 | Private IPv4 | 目前 Public IPv4 | 狀態 | 最近 60 天 EC2 成本 | 操作 |
|---|---|---|---|---|---|---|---|
| {{ vm.name }} | +{{ vm.instance_id }} | ++ | {{ vm.private_ip }} | +{{ vm.public_ip or '—' }} | +{{ vm.state }} | +{% 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 %} | +
{{ error }}
{% endif %}{% if vm %}狀態轉換中
{% endif %}{{ error }}
{% endif %} +{% if vm %} +{% set cost = costs.get(vm.instance_id) %} +狀態轉換中
{% endif %} +