diff --git a/README.md b/README.md index 4048e47..26c3d0f 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ Context ordering: | S3 | S3 Browser | | Lambda | Lambda Browser | | DynamoDB | Table Browser & Key Lookup | +| AWS Backup | Recovery Browser | | WAF | WAFv2 Web ACL Browser | | Bedrock | API Key Manager | | IAM | IAM User Browser | @@ -471,6 +472,7 @@ checks: | Step Functions | `/` filter state machines by name/ARN/type/region or executions by status/name/ARN, `r` refresh, `Enter` executions/detail, detail `↑`/`↓` scroll, `PgUp`/`PgDn` page | | Lambda | `A` toggle all-regions scope (multi-region contexts), `Enter` invoke, `d` detail, `l` view CloudWatch Logs, `/` filter, `r` refresh | | DynamoDB | `/` filter, `r` refresh, `Enter` table detail, detail `l` prompts for the complete partition/sort key and performs one `GetItem`, `↑`/`↓` and `PgUp`/`PgDn` scroll details or item JSON | +| AWS Backup | `/` filter vaults, `r` refresh, `Enter` recovery-readiness detail, detail `↑`/`↓` scroll and `PgUp`/`PgDn` page through recovery points, protected resources, and recent failed/expired jobs | | WAFv2 | `/` filter, `r` refresh regional and CloudFront scopes, `Enter` logging/association/rule detail, detail `↑`/`↓` scroll, `PgUp`/`PgDn` page | The command palette (`P`) fuzzy-searches three kinds of items from anywhere outside text-entry screens: service features (jump straight into a browser), contexts (switch without opening the picker), and resources indexed across services. Opening the palette starts an async index of EC2 instances, RDS instances, Lambda functions, S3 buckets, ECS clusters, and Route53 zones in the current context. Press `Tab` to opt into searching the active context plus sync-managed contexts; context fan-out is bounded, rows show context and region tags, and per-context/service failures are shown inline. Matching covers names, IDs, ARNs, contexts, and regions where available. Selecting a resource in another context switches context and then jumps to the owning browser with the shared filter prefilled to that resource. @@ -510,6 +512,8 @@ The DynamoDB Table Browser lists billing mode, provisioned capacity or on-demand The WAFv2 Web ACL Browser combines regional ACLs from the active region with global ACLs queried through the required `us-east-1` endpoint. Rows surface default action, WCU capacity, managed/total rule counts, logging state, protected-resource counts, and informational flags for logging disabled, permissive default actions, and unassociated ACLs. Detail shows priority-ordered rules, actions/overrides, visibility settings, log destinations, regional associations, Amplify associations, CloudFront distributions, and CloudFront distribution tenants. Regional and CloudFront failures, plus individual detail, logging, and association failures, remain isolated so successful results stay visible. The active identity needs `wafv2:ListWebACLs`, `wafv2:GetWebACL`, `wafv2:GetLoggingConfiguration`, `wafv2:ListResourcesForWebACL`, `cloudfront:ListDistributionsByWebACLId`, and `cloudfront:ListDistributionTenantsByCustomization`; some protected resource types require additional service-specific list permissions. +The AWS Backup Recovery Browser lists vault state, type, recovery-point count, encryption key, and Vault Lock retention metadata in the active region. Opening a vault shows failure/expiry-prioritized recovery points, protected resources with their latest backup, and failed, expired, aborted, partial, or completed-with-issues jobs from AWS Backup's recent job window. Each paginated section is independent: completed pages and other sections remain visible when a later page or one section is denied, with the failure summarized inline. The browser is read-only and requires `backup:ListBackupVaults`, `backup:ListRecoveryPointsByBackupVault`, `backup:ListProtectedResourcesByBackupVault`, and `backup:ListBackupJobs`. + The CloudTrail Event Lookup answers "who changed what, and when": recent API events list newest-first with mutations marked `*`, actor, call, and source service per row. Keys `1`-`5` switch the time window (1h/6h/24h/3d/7d), `m` restricts to mutations (server-side via the `ReadOnly=false` lookup attribute), and `n` runs a server-side resource-name lookup — CloudTrail accepts one lookup attribute per call, so combining both applies the mutations restriction client-side. Results are capped at 100 events per query, so narrow the window or use the resource lookup when a busy account truncates. Event detail shows actor, source, region, source IP, touched resources, and the full raw event JSON with scrolling. The CloudWatch Alarm Browser is an alarm-first incident entry point: alarms list firing-first (ALARM, then INSUFFICIENT_DATA, then OK) with a `tab`-cycled state filter and text filtering across names, states, metrics, and dimensions. Alarm detail shows the state reason, condition, dimensions, and the most recent state transitions. When an alarm's dimensions map to a supported browser (`DBInstanceIdentifier`, `InstanceId`, `ClusterName`, `FunctionName`, `LoadBalancer`, `TargetGroup`), `g` jumps into that resource browser with the filter prefilled to the unhealthy resource (for ELB alarms the target group filter is prefilled too, so the drill-down lands on the alarmed target group), and `l` opens CloudWatch Logs prefilled with the derived log group (e.g. `/aws/lambda/`). diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 2d9fe32..e38adec 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -111,6 +111,7 @@ Current repository clients include: - KMS - DynamoDB - WAFv2 (regional and `us-east-1` CloudFront-scope clients, plus distribution and distribution-tenant association lookups) +- AWS Backup Pattern: @@ -268,6 +269,7 @@ Current screen families include: - EventBridge rule list/detail, scrollable complete event patterns, and type-to-confirm state changes for eligible rule modes - DynamoDB table list/detail and complete-primary-key `GetItem` flows - WAFv2 regional/CloudFront Web ACL list and scrollable posture/rule detail flows +- AWS Backup vault list and scrollable recovery-point/protected-resource/failed-job detail flows - Inspector mode home, checklist setup, security findings/detail (including KMS rotation findings), and checklist results/detail flows - context picker, context add, and TUI-native context setup/export/unset flows - SSO account / role selection and exit notice flows diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index d1c51ac..f2bf758 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -111,6 +111,7 @@ repository와 서비스별 AWS 연동 계층이다. - KMS - DynamoDB - WAFv2 (regional 및 `us-east-1` CloudFront scope client와 distribution 및 distribution tenant association lookup) +- AWS Backup 패턴: @@ -268,6 +269,7 @@ UNIC은 현재 다섯 가지 인증 모드를 지원한다. - EventBridge rule list/detail, 스크롤 가능한 전체 event pattern, 변경 가능한 rule mode의 type-to-confirm 상태 변경 flow - DynamoDB table list/detail, 전체 primary key 기반 `GetItem` - WAFv2 regional/CloudFront Web ACL list, 스크롤 가능한 posture/rule detail +- AWS Backup vault list, recovery point/protected resource/failed job 스크롤 상세 화면 - Inspector mode home, checklist setup, KMS rotation finding을 포함한 security findings/detail, checklist results/detail - context picker, context add, TUI-native context setup/export/unset - SSO account / role selection, exit notice diff --git a/docs/project-overview.en.md b/docs/project-overview.en.md index 5ba59d8..c53a999 100644 --- a/docs/project-overview.en.md +++ b/docs/project-overview.en.md @@ -37,6 +37,7 @@ Implemented service areas currently include: The application already includes interactive mutation flows, polling-based status flows, context helpers, and per-service drill-down screens. EC2 includes a first-class Auto Scaling Group browser for capacity, instance health, recent activity failures, and type-confirmed desired-capacity changes. CloudFormation includes failure-prioritized stack browsing, parameters, outputs, recent events with failure reasons, and polling-based drift detection. CloudWatch Metrics now includes resource-centric preset groups plus time-range, period, and statistic controls for faster terminal triage. EKS includes managed add-on status review, current-version upgrade readiness checks that compare control plane, managed node group, managed add-on version alignment, and EKS upgrade insights before a target upgrade is planned, plus a kubeconfig access helper that prepares copyable `aws eks update-kubeconfig` and `kubectl` handoff commands. ECR includes repository and image/tag browsing with cleanup-oriented untagged and stale image signals. FIS includes experiment template browsing with safe-run blast-radius preview, targets, actions, role ARN, stop condition summaries, and recent experiment history with status, timing, and failure/stop reasons. ACM includes an expiry-sorted certificate browser with validation, renewal, domain, and in-use details. KMS includes key browsing with aliases, state, manager, and automatic-rotation posture. Both browsers keep successfully loaded resources visible when an individual detail lookup fails and surface the failure inline; denied KMS rotation lookups are shown as unknown. ElastiCache includes replication-group and standalone-cluster browsing with node metadata and copyable endpoints. Step Functions includes state machine browsing and failure-first STANDARD execution triage with failed-state, error/cause, and input/output previews. EventBridge includes cross-bus rule browsing with complete scrollable event patterns, targets, best-effort seven-day CloudWatch trigger activity, and type-to-confirm enable/disable actions for eligible customer-managed rules; all-management-events rules remain read-only so their exact matching mode is preserved. SNS includes name-sorted topic browsing with subscription counts, encryption, and delivery policies, plus a pending-first subscription list; topics whose attribute lookup is denied stay listed with their attributes marked unavailable. DynamoDB includes table capacity, size, key, GSI, TTL, and stream inspection plus a single `GetItem` lookup by complete primary key; it has no scan path. WAF combines regional and CloudFront-scope Web ACL posture, priority-ordered rules, logging, and supported resource associations while keeping scope and per-resource authorization failures isolated. API Gateway v2 includes HTTP/WebSocket API, stage, route, and integration browsing with partial-detail warnings, copyable targets, and filtered Lambda handoff. +AWS Backup includes read-only vault browsing with recovery points, protected resources, Vault Lock/encryption metadata, and recent failed or expired jobs; paginated partial results remain visible with inline warnings. Inspector mode now includes built-in security and cost/waste scans, including customer-managed KMS key rotation checks and ACM certificate expiry findings, plus checklist-driven readiness checks for RDS, security groups, secrets, Route53, VPCs/subnets, CloudWatch Logs, and baseline posture wrappers. The cost/waste pack surfaces unattached EIPs and EBS volumes, stopped EC2 instances, empty target groups, untagged EC2-family resources, and EBS snapshots aged 90 days or more. Per-resource lookup failures appear as warnings while findings from successful lookups remain available. When `inspector.required_tags` is configured, it additionally reports missing required keys on Elastic IPs, EBS volumes and snapshots, and EC2 instances. ## Primary User Flows diff --git a/docs/project-overview.ko.md b/docs/project-overview.ko.md index 8050625..957d7e4 100644 --- a/docs/project-overview.ko.md +++ b/docs/project-overview.ko.md @@ -37,6 +37,7 @@ UNIC은 다음 세 가지를 결합한 Go 기반 AWS 터미널 콘솔이다. 애플리케이션은 이미 상호작용형 변경 작업 플로우, polling 기반 상태 확인, context helper, 서비스별 drill-down 화면을 포함한다. EC2에는 capacity, instance health, 최근 activity failure를 확인하고 type-to-confirm으로 desired capacity를 변경하는 Auto Scaling Group browser가 포함된다. CloudFormation은 실패/rollback 상태를 우선한 stack 목록, parameter, output, 실패 원인을 포함한 최근 event, polling 기반 drift detection을 제공한다. CloudWatch Metrics는 이제 resource-centric preset 그룹과 time-range / period / statistic control을 제공해 터미널에서 더 빠르게 triage할 수 있다. EKS는 cluster 화면에서 managed add-on 상태를 확인하고, target upgrade를 계획하기 전에 control plane, managed node group, managed add-on의 current-version alignment와 EKS upgrade insight를 함께 확인하는 upgrade readiness check와 복사 가능한 `aws eks update-kubeconfig` / `kubectl` handoff 명령을 준비하는 kubeconfig access helper를 포함한다. ECR은 repository와 image/tag 탐색을 제공하고, untagged image와 오래된 image를 cleanup 후보로 드러낸다. FIS는 experiment template 목록과 safe-run blast-radius preview, target, action, role ARN, stop condition 요약 상세 화면에 더해 최근 experiment history의 상태, 시간, failure/stop reason을 보여준다. ACM은 만료일 순서의 인증서 목록과 validation, renewal, domain, 사용 리소스 상세 정보를 제공한다. KMS는 alias, 상태, 관리 주체, 자동 rotation 상태를 포함한 key 탐색을 제공한다. 두 browser 모두 개별 detail lookup이 실패해도 성공적으로 불러온 resource를 유지하고 실패 내용을 inline으로 표시하며, 권한이 거부된 KMS rotation lookup은 unknown으로 표시한다. ElastiCache는 replication group과 standalone cluster 탐색, node metadata, endpoint 복사를 제공한다. Step Functions는 state machine 탐색과 실패 우선 STANDARD execution triage를 제공하며 failed state, error/cause, input/output preview를 보여준다. EventBridge는 전체 event bus의 rule과 스크롤 가능한 전체 event pattern, target, 최근 7일 CloudWatch 기반 best-effort trigger activity를 보여주고 변경 가능한 customer-managed rule의 enable/disable을 type-to-confirm으로 보호한다. all-management-events rule은 정확한 matching mode를 보존하기 위해 read-only로 유지한다. SNS는 이름순 topic 탐색과 subscription 수, 암호화, delivery policy를 보여주고 pending 우선 subscription 목록을 제공하며, attribute 조회가 거부된 topic도 attribute를 unavailable로 표시한 채 목록에 남긴다. DynamoDB는 table capacity, size, key, GSI, TTL, stream 정보를 보여주고 전체 primary key를 입력받아 단일 `GetItem`만 수행하며 scan 경로는 제공하지 않는다. WAF는 regional 및 CloudFront scope Web ACL의 posture, priority 순서의 rule, logging, 지원되는 resource association을 함께 보여주며 scope 또는 개별 resource 권한 오류가 다른 결과를 숨기지 않도록 격리한다. API Gateway v2는 부분 detail 실패 warning, target 복사, filter가 적용된 Lambda handoff를 포함해 HTTP/WebSocket API, stage, route, integration 탐색을 제공한다. +AWS Backup은 recovery point, protected resource, Vault Lock/encryption metadata, 최근 실패 또는 만료 job을 확인하는 read-only vault browser를 제공하며, pagination 일부가 실패해도 성공한 결과를 inline warning과 함께 유지한다. Inspector mode는 이제 customer-managed KMS key rotation 검사와 ACM 인증서 만료 finding을 포함한 built-in security 및 cost/waste scan과 함께 RDS, security group, secret, Route53, VPC/subnet, CloudWatch Logs, baseline posture wrapper를 다루는 checklist 기반 readiness check도 포함한다. cost/waste rule pack은 연결되지 않은 EIP와 EBS volume, 중지된 EC2 instance, 비어 있는 target group, 사용자 정의 tag가 없는 EC2 계열 resource, 90일 이상 된 EBS snapshot을 표시한다. 개별 resource lookup 실패는 warning으로 표시되며 성공한 lookup의 finding은 그대로 유지된다. `inspector.required_tags`가 설정되면 Elastic IP, EBS volume과 snapshot, EC2 instance에서 누락된 필수 tag key도 추가로 표시한다. ## 주요 사용자 흐름 diff --git a/go.mod b/go.mod index acc8719..34c43fd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/acm v1.44.1 github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.37.7 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1 + github.com/aws/aws-sdk-go-v2/service/backup v1.60.2 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3 github.com/aws/aws-sdk-go-v2/service/cloudfront v1.68.0 github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.9 diff --git a/go.sum b/go.sum index 392ee68..8b8c85e 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.37.7 h1:E/fv46bTpl5E9V52ju9 github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.37.7/go.mod h1:YNiiAniVgPBIoa9cAFsHR/xscIdpbj57yu7gPYUkwdY= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1 h1:VB+9RFYfUUY8TyL6W025CZToo6h9vC3zeYob7M7/2CE= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.61.1/go.mod h1:6q/I1pH386VpPfB6FE62X/MOs6NW/oCsY9FXU33YXOU= +github.com/aws/aws-sdk-go-v2/service/backup v1.60.2 h1:b3QwmC6vV20LLbUGedok6UlOe45HgnW7C93MThgk0P4= +github.com/aws/aws-sdk-go-v2/service/backup v1.60.2/go.mod h1:zMHhtRP9145uUJxHZM7yURy5/d/mn837hyVWBSz5RaM= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3 h1:FjNSXIPC9bbvVRh67j7jGf37gJo/5THzf+pS3T+Don0= github.com/aws/aws-sdk-go-v2/service/cloudformation v1.76.3/go.mod h1:yQcvrM5JfBihExrlz+2k7W6mBEM6xexhWT8eHr0akzs= github.com/aws/aws-sdk-go-v2/service/cloudfront v1.68.0 h1:JEXUV287ovPx1ttxFSO2O7Cy4IqkB5EJhmDz+wir1pI= diff --git a/internal/app/app.go b/internal/app/app.go index 050a436..bea599a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -133,6 +133,8 @@ const ( screenDynamoDBTableDetail screenDynamoDBLookupInput screenDynamoDBLookupResult + screenBackupVaultList + screenBackupVaultDetail screenWAFWebACLList screenWAFWebACLDetail screenBedrockKeyList @@ -230,6 +232,7 @@ type Model struct { eventBridge eventBridgeModel lambda lambdaModel dynamodb dynamoDBModel + backup backupModel waf wafModel inspector inspectorModel @@ -365,6 +368,7 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. model.apiGatewayV2 = newAPIGatewayV2Model() model.lambda = newLambdaModel() model.dynamodb = newDynamoDBModel() + model.backup = newBackupModel() model.waf = newWAFModel() model.inspector = newInspectorModel(configuredChecklistPath) model.applyServiceListFilter() @@ -984,6 +988,8 @@ func (m Model) startFeature(kind domain.FeatureKind) (tea.Model, tea.Cmd) { return m.lambda.Start(&m) case domain.FeatureDynamoDBBrowser: return m.dynamodb.Start(&m) + case domain.FeatureBackupBrowser: + return m.backup.Start(&m) case domain.FeatureWAFWebACLBrowser: return m.waf.Start(&m) case domain.FeatureBedrockAPIKeys: diff --git a/internal/app/context_terminal.go b/internal/app/context_terminal.go index 18a8fe6..dc0ed6a 100644 --- a/internal/app/context_terminal.go +++ b/internal/app/context_terminal.go @@ -53,6 +53,7 @@ func (m Model) selectedContextInfo() (config.ContextInfo, bool) { } func (m Model) beginContextSetup(selected config.ContextInfo) (tea.Model, tea.Cmd) { + normalizeBackupContextReturn(&m) if pendingAPIGatewayV2ContextLoad(&m) { normalizeAPIGatewayV2ContextReturns(&m) } diff --git a/internal/app/feature_submodel.go b/internal/app/feature_submodel.go index 73099fd..0214672 100644 --- a/internal/app/feature_submodel.go +++ b/internal/app/feature_submodel.go @@ -13,7 +13,7 @@ type featureSubmodel interface { // such as service selection, context selection, SSM session launch, loading, and // errors remain root-owned unless a separate shell abstraction is introduced. func (m *Model) featureSubmodels() []featureSubmodel { - return []featureSubmodel{&m.ec2Browser, &m.autoScaling, &m.ecs, &m.eks, &m.ecr, &m.fis, &m.vpc, &m.reachability, &m.cwMetrics, &m.cwAlarms, &m.cloudTrail, &m.cwLogs, &m.rds, &m.cloudFormation, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.security, &m.s3, &m.sns, &m.sqs, &m.elb, &m.ssmParams, &m.elasticache, &m.kms, &m.acm, &m.stepFunctions, &m.apiGatewayV2, &m.eventBridge, &m.lambda, &m.dynamodb, &m.waf, &m.inspector} + return []featureSubmodel{&m.ec2Browser, &m.autoScaling, &m.ecs, &m.eks, &m.ecr, &m.fis, &m.vpc, &m.reachability, &m.cwMetrics, &m.cwAlarms, &m.cloudTrail, &m.cwLogs, &m.rds, &m.cloudFormation, &m.route53, &m.iam, &m.bedrock, &m.secrets, &m.security, &m.s3, &m.sns, &m.sqs, &m.elb, &m.ssmParams, &m.elasticache, &m.kms, &m.acm, &m.stepFunctions, &m.apiGatewayV2, &m.eventBridge, &m.lambda, &m.dynamodb, &m.backup, &m.waf, &m.inspector} } // overlayPreviousScreen returns the pointer holding the screen a global diff --git a/internal/app/filter.go b/internal/app/filter.go index b92ce58..eb563cb 100644 --- a/internal/app/filter.go +++ b/internal/app/filter.go @@ -58,6 +58,7 @@ const ( filterStepFunctionStateMachines filterStepFunctionExecutions filterDynamoDBTables + filterBackupVaults filterAPIGatewayV2APIs filterAPIGatewayV2Routes filterWAFWebACLs diff --git a/internal/app/help.go b/internal/app/help.go index d96ee7a..5654651 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -878,6 +878,10 @@ func (m Model) helpScreenTitle() string { return "CloudFormation Stacks" case screenCloudFormationStackDetail: return "CloudFormation Stack Detail" + case screenBackupVaultList: + return "AWS Backup Vaults" + case screenBackupVaultDetail: + return "AWS Backup Recovery Detail" case screenElastiCacheNodeList: return "ElastiCache Nodes" case screenElastiCacheNodeDetail: diff --git a/internal/app/keymap.go b/internal/app/keymap.go index 10c8ad6..51b0f85 100644 --- a/internal/app/keymap.go +++ b/internal/app/keymap.go @@ -391,6 +391,21 @@ var screenKeymaps = map[screen][]keyBinding{ {keys: "q / esc", bar: "esc: detail", help: "Return to table detail"}, {bar: "H: home"}, }, + screenBackupVaultList: { + {keys: "↑/↓, j/k", bar: "↑/↓: navigate", help: "Move between backup vaults"}, + {keys: "/", bar: "/: filter", help: "Filter by vault name, state, type, region, or encryption key"}, + {keys: "r", bar: "r: refresh", help: "Refresh backup vaults"}, + {keys: "enter", bar: "enter: recovery", help: "Open recovery points, protected resources, and failed jobs"}, + {keys: "q / esc", bar: "esc: back", help: "Go back to the feature list"}, + {bar: "H: home"}, + }, + screenBackupVaultDetail: { + {keys: "↑/↓, j/k", bar: "↑/↓: scroll", help: "Scroll backup recovery details"}, + {keys: "pgup/pgdn", bar: "pgup/pgdn: page", help: "Scroll backup recovery details by one page"}, + {keys: "r", bar: "r: refresh", help: "Refresh the selected backup vault"}, + {keys: "q / esc", bar: "esc: vaults", help: "Go back to the backup vault list"}, + {bar: "H: home"}, + }, screenWAFWebACLList: { {keys: "↑/↓, j/k", bar: "↑/↓: navigate", help: "Move between WAF web ACLs"}, {keys: "/", bar: "/: filter", help: "Filter by name, scope, rule, ARN, or protected resource"}, diff --git a/internal/app/messages.go b/internal/app/messages.go index bbd277d..a6a714f 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -518,3 +518,16 @@ type dynamoDBTableDetailLoadedMsg struct { type dynamoDBItemLoadedMsg struct { item *awsservice.DynamoDBItem } + +type backupVaultsLoadedMsg struct { + vaults []awsservice.BackupVault + warnings []error + err error +} + +type backupVaultDetailLoadedMsg struct { + vaultName string + detail *awsservice.BackupVaultDetail + warnings []error + err error +} diff --git a/internal/app/screen_backup.go b/internal/app/screen_backup.go new file mode 100644 index 0000000..fe564e2 --- /dev/null +++ b/internal/app/screen_backup.go @@ -0,0 +1,613 @@ +package app + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + awsservice "unic/internal/services/aws" +) + +type backupModel struct { + vaults []awsservice.BackupVault + filtered []awsservice.BackupVault + idx int + selected *awsservice.BackupVault + detail *awsservice.BackupVaultDetail + detailScroll int + warnings []error + detailErrors []error + errorActive bool +} + +func newBackupModel() backupModel { return backupModel{} } + +func isBackupScreen(value screen) bool { + return value == screenBackupVaultList || value == screenBackupVaultDetail +} + +func resetBackupContextState(m *Model) { + m.backup = newBackupModel() + m.resetFilter(filterBackupVaults) +} + +func normalizeBackupContextReturn(m *Model) { + previous := &m.ctxPrevScreen + seen := make(map[screen]struct{}) + for range 8 { + current := *previous + if _, ok := seen[current]; ok { + return + } + seen[current] = struct{}{} + if isBackupScreen(current) || current == screenLoading && isBackupScreen(m.loadingReturnScreen) || current == screenError && m.backup.errorActive { + *previous = screenServiceList + return + } + previous = backupOverlayPrevious(m, current) + if previous == nil { + return + } + } +} + +func backupOverlayPrevious(m *Model, current screen) *screen { + switch current { + case screenSettings: + return &m.settingsPrevScreen + case screenCommandPalette: + return &m.palette.prevScreen + case screenViewList: + return &m.views.prevScreen + case screenContextPicker: + return &m.ctxPrevScreen + case screenContextAdd: + return &m.ctxPrevScreen + case screenRegionPicker: + return &m.regionPrevScreen + default: + return nil + } +} + +func (bm *backupModel) Start(m *Model) (tea.Model, tea.Cmd) { + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultList, "Loading AWS Backup vaults...", nil, bm.loadVaults(*m)) +} + +func (bm *backupModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case backupVaultsLoadedMsg: + if msg.err != nil { + bm.finishError(m, msg.err) + return *m, nil, true + } + bm.vaults = msg.vaults + bm.filtered = applyFilter(bm.vaults, m.filterValue(filterBackupVaults)) + bm.idx = 0 + bm.selected = nil + bm.detail = nil + bm.warnings = msg.warnings + bm.detailErrors = nil + bm.errorActive = false + finishBackupLoad(m, screenBackupVaultList) + return *m, nil, true + case backupVaultDetailLoadedMsg: + if bm.selected == nil || bm.selected.Name != msg.vaultName { + return *m, nil, true + } + if msg.err != nil { + bm.finishError(m, msg.err) + return *m, nil, true + } + if msg.detail != nil { + selected := msg.detail.Vault + bm.reconcileVault(selected, m.filterValue(filterBackupVaults)) + bm.selected = &selected + } + bm.detail = msg.detail + bm.detailErrors = msg.warnings + bm.detailScroll = 0 + bm.errorActive = false + finishBackupLoad(m, screenBackupVaultDetail) + return *m, nil, true + } + return *m, nil, false +} + +func (bm *backupModel) finishError(m *Model, err error) { + bm.errorActive = true + m.errMsg = err.Error() + m.loadingTitle = "" + m.loadingDetails = nil + finishBackupLoad(m, screenError) +} + +func finishBackupLoad(m *Model, target screen) { + if !isBackupScreen(m.loadingReturnScreen) { + return + } + if m.ctxPickerPending && m.ctxPrevScreen == screenLoading { + m.ctxPrevScreen = target + } + if m.screen == screenLoading { + m.screen = target + m.loadingReturnScreen = 0 + return + } + current := m.screen + seen := make(map[screen]struct{}) + for range 8 { + if _, ok := seen[current]; ok { + return + } + seen[current] = struct{}{} + previous := backupOverlayPrevious(m, current) + if previous == nil { + return + } + if *previous == screenLoading { + *previous = target + m.loadingReturnScreen = 0 + return + } + current = *previous + } +} + +func (bm *backupModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch m.screen { + case screenBackupVaultList: + updated, cmd := bm.updateList(m, msg) + return updated, cmd, true + case screenBackupVaultDetail: + updated, cmd := bm.updateDetail(m, msg) + return updated, cmd, true + default: + return *m, nil, false + } +} + +func (bm backupModel) View(m Model) (string, bool) { + switch m.screen { + case screenBackupVaultList: + return bm.viewList(m), true + case screenBackupVaultDetail: + return bm.viewDetail(m), true + default: + return "", false + } +} + +func (bm *backupModel) ApplyFilter(m *Model, target filterTarget) bool { + if target != filterBackupVaults { + return false + } + bm.filtered = applyFilter(bm.vaults, m.filterValue(target)) + bm.idx = 0 + return true +} + +func (bm *backupModel) reconcileVault(vault awsservice.BackupVault, query string) { + for i := range bm.vaults { + if !sameBackupVault(bm.vaults[i], vault) { + continue + } + bm.vaults[i] = vault + bm.filtered = applyFilter(bm.vaults, query) + bm.idx = clampListIndex(bm.idx, len(bm.filtered)) + for i := range bm.filtered { + if sameBackupVault(bm.filtered[i], vault) { + bm.idx = i + break + } + } + return + } +} + +func (bm *backupModel) updateList(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if cmd, handled := m.updateSharedFilter(msg, filterBackupVaults); handled { + return *m, cmd + } + switch msg.String() { + case "q", "esc": + m.resetFilter(filterBackupVaults) + m.screen = screenFeatureList + case "up", "k": + bm.idx = previousListIndex(bm.idx, len(bm.filtered)) + case "down", "j": + bm.idx = nextListIndex(bm.idx, len(bm.filtered)) + case "/": + return *m, m.activateFilter(filterBackupVaults) + case "r": + return bm.Start(m) + case "enter": + if bm.idx < len(bm.filtered) { + selected := bm.filtered[bm.idx] + bm.selected = &selected + bm.detail = nil + bm.detailErrors = nil + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultDetail, "Loading backup recovery posture...", []string{selected.Name}, bm.loadDetail(*m, selected, false)) + } + } + return *m, nil +} + +func (bm *backupModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + lines := bm.detailLines(*m) + visibleLines := bm.detailVisibleLines(*m) + maxOffset := max(len(lines)-visibleLines, 0) + switch msg.String() { + case "q", "esc": + bm.detailScroll = 0 + m.screen = screenBackupVaultList + case "up", "k": + bm.detailScroll = max(bm.detailScroll-1, 0) + case "down", "j": + bm.detailScroll = min(bm.detailScroll+1, maxOffset) + case "pgup": + bm.detailScroll = max(bm.detailScroll-visibleLines, 0) + case "pgdown": + bm.detailScroll = min(bm.detailScroll+visibleLines, maxOffset) + case "r": + if bm.selected != nil { + bm.errorActive = false + return m.startLoadingFor(screenBackupVaultDetail, "Refreshing backup recovery posture...", []string{bm.selected.Name}, bm.loadDetail(*m, *bm.selected, true)) + } + } + return *m, nil +} + +func (bm backupModel) loadVaults(m Model) tea.Cmd { + return func() tea.Msg { + ctx := m.commandContext() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return backupVaultsLoadedMsg{err: err} + } + } + vaults, warnings, err := repo.ListBackupVaults(ctx) + return backupVaultsLoadedMsg{vaults: vaults, warnings: warnings, err: err} + } +} + +func (bm backupModel) loadDetail(m Model, vault awsservice.BackupVault, refreshVault bool) tea.Cmd { + return func() tea.Msg { + ctx := m.commandContext() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: err} + } + } + var warnings []error + if refreshVault { + vaults, listWarnings, err := repo.ListBackupVaults(ctx) + if err != nil { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: err} + } + warnings = append(warnings, listWarnings...) + refreshed, ok := findBackupVault(vaults, vault) + if !ok { + if len(listWarnings) == 0 { + return backupVaultDetailLoadedMsg{vaultName: vault.Name, err: fmt.Errorf("backup vault %s is no longer available", vault.Name)} + } + } else { + vault = refreshed + } + } + + detail, detailWarnings, err := repo.GetBackupVaultDetail(ctx, vault) + warnings = append(warnings, detailWarnings...) + return backupVaultDetailLoadedMsg{vaultName: vault.Name, detail: detail, warnings: warnings, err: err} + } +} + +func findBackupVault(vaults []awsservice.BackupVault, selected awsservice.BackupVault) (awsservice.BackupVault, bool) { + if selected.ARN != "" { + for _, vault := range vaults { + if sameBackupVault(vault, selected) { + return vault, true + } + } + } + for _, vault := range vaults { + if vault.Name == selected.Name { + return vault, true + } + } + return awsservice.BackupVault{}, false +} + +func sameBackupVault(left, right awsservice.BackupVault) bool { + if left.ARN != "" && right.ARN != "" { + return left.ARN == right.ARN + } + return left.Name == right.Name +} + +func (bm backupModel) viewList(m Model) string { + var b, panel strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("AWS Backup Vaults")) + b.WriteString("\n") + b.WriteString(m.renderFilterValue(filterBackupVaults)) + b.WriteString("\n") + warningLines := 0 + if len(bm.warnings) > 0 { + b.WriteString(m.renderWarningSummary(len(bm.warnings), "vault listing failures", bm.warnings[0].Error())) + warningLines = 2 + } + b.WriteString("\n") + + if len(bm.filtered) == 0 { + message := " No backup vaults found" + if len(bm.vaults) > 0 { + message = " No matching backup vaults" + } + panel.WriteString(dimStyle.Render(message)) + panel.WriteString("\n") + } else { + widths := backupVaultColumnWidths(m) + panel.WriteString(dimStyle.Render(" " + renderBackupVaultColumns(widths, []string{"VAULT", "STATE", "POINTS", "LOCK", "TYPE"}))) + panel.WriteString("\n") + visibleLines := max(m.height-11-warningLines, 5) + start := max(bm.idx-visibleLines+1, 0) + for i := start; i < min(start+visibleLines, len(bm.filtered)); i++ { + cursor, style := " ", normalStyle + if i == bm.idx { + cursor, style = "> ", selectedStyle + } + vault := bm.filtered[i] + lock := "-" + if vault.Locked { + lock = "locked" + } + row := renderBackupVaultColumns(widths, []string{ + vault.Name, + valueOrDashApp(vault.State), + fmt.Sprintf("%d", vault.RecoveryPointCount), + lock, + valueOrDashApp(vault.Type), + }) + panel.WriteString(style.Render(cursor + m.renderHighlightedValue(filterBackupVaults, row))) + panel.WriteString("\n") + } + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d vaults", len(bm.filtered), len(bm.vaults)))) + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar(m.keymapHelpBar())) + return b.String() +} + +func (bm backupModel) viewDetail(m Model) string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + name := "" + if bm.selected != nil { + name = escapeTerminalControls(bm.selected.Name) + } + b.WriteString(titleStyle.Render("AWS Backup Recovery — " + name)) + b.WriteString("\n") + if len(bm.detailErrors) > 0 { + b.WriteString(m.renderWarningSummary(len(bm.detailErrors), "detail lookup failures", bm.detailErrors[0].Error())) + for _, detailError := range bm.detailErrors[1:] { + b.WriteString(m.renderWarningDetail(detailError.Error())) + } + } + b.WriteString("\n") + + lines := bm.detailLines(m) + visibleLines := bm.detailVisibleLines(m) + start := min(bm.detailScroll, max(len(lines)-visibleLines, 0)) + end := min(start+visibleLines, len(lines)) + b.WriteString(m.renderListPanel(strings.Join(lines[start:end], "\n"))) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar(m.keymapHelpBar())) + return b.String() +} + +func (bm backupModel) detailVisibleLines(m Model) int { + warningLines := 0 + if len(bm.detailErrors) > 0 { + warningLines = len(bm.detailErrors) + 1 + } + return max(m.height-8-warningLines, 5) +} + +func backupVaultColumnWidths(m Model) []int { + desired := []int{34, 14, 10, 10, 18} + if m.width <= 0 { + return desired + } + + widths := []int{10, 8, 8, 7, 6} + available := max(m.width-m.currentListPanelStyle().GetHorizontalFrameSize()-2, len(widths)) + total := 0 + for _, width := range widths { + total += width + } + for total > available { + for i := range widths { + if total <= available { + break + } + if widths[i] > 1 { + widths[i]-- + total-- + } + } + } + for total < available { + grew := false + for i := range widths { + if total >= available { + break + } + if widths[i] < desired[i] { + widths[i]++ + total++ + grew = true + } + } + if !grew { + break + } + } + return widths +} + +func renderBackupVaultColumns(widths []int, values []string) string { + var row strings.Builder + for i, width := range widths { + valueWidth := width + if i < len(widths)-1 { + valueWidth = max(width-2, 1) + } + value := truncateEC2DetailValue(escapeTerminalControls(values[i]), valueWidth) + column := lipgloss.NewStyle().Width(width).MaxWidth(width) + if i == 2 { + column = column.Align(lipgloss.Right) + } + row.WriteString(column.Render(value)) + } + return row.String() +} + +func (bm backupModel) detailLines(m Model) []string { + if bm.detail == nil { + return []string{dimStyle.Render(" No backup vault detail loaded")} + } + detail := bm.detail + vault := detail.Vault + lines := []string{ + backupDetailLine(m, "Vault", vault.Name), + backupDetailLine(m, "State", vault.State), + backupDetailLine(m, "Type", vault.Type), + backupDetailLine(m, "Region", vault.Region), + backupDetailLine(m, "Recovery Points", fmt.Sprintf("%d", vault.RecoveryPointCount)), + backupDetailLine(m, "Encryption Key", valueOrDashApp(vault.EncryptionKeyARN)), + backupDetailLine(m, "Key Type", valueOrDashApp(vault.EncryptionKeyType)), + backupDetailLine(m, "Vault Lock", backupLockSummary(vault)), + backupDetailLine(m, "ARN", vault.ARN), + "", + titleStyle.Render(fmt.Sprintf(" Recovery Points (%d)", len(detail.RecoveryPoints))), + } + if len(detail.RecoveryPoints) == 0 { + lines = append(lines, dimStyle.Render(" None available")) + } + for _, point := range detail.RecoveryPoints { + style := normalStyle + if point.NeedsAttention() { + style = warningStyle + } + lines = append(lines, + style.Render(" "+escapeTerminalControls(valueOrDashApp(point.ResourceName))), + backupDetailLine(m, "Type / Status", fmt.Sprintf("%s / %s", valueOrDashApp(point.ResourceType), valueOrDashApp(point.Status))), + backupDetailLine(m, "Resource ARN", point.ResourceARN), + backupDetailLine(m, "Created", backupTime(point.CreatedAt)), + backupDetailLine(m, "Completed", backupTime(point.CompletedAt)), + backupDetailLine(m, "Cold Storage", backupTime(point.MoveToColdAt)), + backupDetailLine(m, "Expires", backupTime(point.DeleteAt)), + backupDetailLine(m, "Size", backupRecoveryPointSize(point)), + backupDetailLine(m, "Encrypted", fmt.Sprintf("%t", point.Encrypted)), + backupDetailLine(m, "Source Vault", valueOrDashApp(point.SourceVaultARN)), + backupDetailLine(m, "Recovery Point", point.ARN), + ) + if point.StatusMessage != "" { + lines = append(lines, backupDetailLine(m, "Reason", point.StatusMessage)) + } + } + + lines = append(lines, "", titleStyle.Render(fmt.Sprintf(" Protected Resources (%d)", len(detail.ProtectedResources)))) + if len(detail.ProtectedResources) == 0 { + lines = append(lines, dimStyle.Render(" None available")) + } + for _, resource := range detail.ProtectedResources { + lines = append(lines, + normalStyle.Render(" "+escapeTerminalControls(valueOrDashApp(resource.Name))), + backupDetailLine(m, "Type", resource.Type), + backupDetailLine(m, "Last Backup", backupTime(resource.LastBackupAt)), + backupDetailLine(m, "Resource ARN", resource.ARN), + backupDetailLine(m, "Recovery Point", resource.LastRecoveryPointARN), + ) + } + + lines = append(lines, "", titleStyle.Render(fmt.Sprintf(" Recent Failed / Expired Jobs (%d)", len(detail.FailedJobs)))) + if len(detail.FailedJobs) == 0 { + lines = append(lines, dimStyle.Render(" None in the AWS Backup 30-day job window")) + } + for _, job := range detail.FailedJobs { + lines = append(lines, + warningStyle.Render(" "+escapeTerminalControls(valueOrDashApp(job.ResourceName))), + backupDetailLine(m, "Type / State", fmt.Sprintf("%s / %s", valueOrDashApp(job.ResourceType), valueOrDashApp(job.State))), + backupDetailLine(m, "Created", backupTime(job.CreatedAt)), + backupDetailLine(m, "Completed", backupTime(job.CompletedAt)), + backupDetailLine(m, "Job ID", job.ID), + ) + if job.StatusMessage != "" { + lines = append(lines, backupDetailLine(m, "Reason", job.StatusMessage)) + } + if job.MessageCategory != "" { + lines = append(lines, backupDetailLine(m, "Message Category", job.MessageCategory)) + } + } + return lines +} + +func backupDetailLine(m Model, label, value string) string { + return strings.TrimSuffix(m.renderEC2DetailLine(label, value), "\n") +} + +func backupTime(value time.Time) string { + if value.IsZero() { + return "-" + } + return value.Local().Format("2006-01-02 15:04 MST") +} + +func backupLockSummary(vault awsservice.BackupVault) string { + if !vault.Locked { + return "unlocked" + } + retention := "locked" + switch { + case vault.MinRetentionKnown && vault.MaxRetentionKnown: + retention += fmt.Sprintf(" (%d-%d days)", vault.MinRetentionDays, vault.MaxRetentionDays) + case vault.MinRetentionKnown: + retention += fmt.Sprintf(" (minimum %d days)", vault.MinRetentionDays) + case vault.MaxRetentionKnown: + retention += fmt.Sprintf(" (maximum %d days)", vault.MaxRetentionDays) + } + if !vault.LockDate.IsZero() { + retention += " lock date " + backupTime(vault.LockDate) + } + return retention +} + +func backupRecoveryPointSize(point awsservice.BackupRecoveryPoint) string { + if !point.SizeBytesKnown { + return "-" + } + return formatBytes(point.SizeBytes) +} + +func valueOrDashApp(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/internal/app/screen_backup_test.go b/internal/app/screen_backup_test.go new file mode 100644 index 0000000..e1f93fa --- /dev/null +++ b/internal/app/screen_backup_test.go @@ -0,0 +1,418 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +type appBackupClient struct { + listVaults func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) +} + +func (c *appBackupClient) ListBackupVaults(ctx context.Context, input *backup.ListBackupVaultsInput, opts ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return c.listVaults(ctx, input, opts...) +} + +func (*appBackupClient) ListRecoveryPointsByBackupVault(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return &backup.ListRecoveryPointsByBackupVaultOutput{}, nil +} + +func (*appBackupClient) ListProtectedResourcesByBackupVault(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return &backup.ListProtectedResourcesByBackupVaultOutput{}, nil +} + +func (*appBackupClient) ListBackupJobs(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return &backup.ListBackupJobsOutput{}, nil +} + +func backupTestVaults() []awsservice.BackupVault { + return []awsservice.BackupVault{ + {Name: "prod", ARN: "arn:vault:prod", Region: "ap-northeast-2", State: "AVAILABLE", Type: "BACKUP_VAULT", RecoveryPointCount: 3, Locked: true, EncryptionKeyARN: "arn:kms:prod"}, + {Name: "dev\x1b]52;c;spoof\a", ARN: "arn:vault:dev", Region: "ap-northeast-2", State: "FAILED", Type: "BACKUP_VAULT"}, + } +} + +func TestBackupHelpScreenTitles(t *testing.T) { + m := New(testConfig(), "", "dev") + for _, tc := range []struct { + screen screen + want string + }{ + {screenBackupVaultList, "AWS Backup Vaults"}, + {screenBackupVaultDetail, "AWS Backup Recovery Detail"}, + } { + m.screen = tc.screen + if got := m.helpScreenTitle(); got != tc.want { + t.Errorf("helpScreenTitle() = %q, want %q", got, tc.want) + } + } +} + +func TestBackupVaultListRendersFiltersWarningsAndEscapesControls(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + started, _ := m.backup.Start(&m) + m = started.(Model) + _, _, handled := m.backup.HandleMessage(&m, backupVaultsLoadedMsg{ + vaults: backupTestVaults(), warnings: []error{errors.New("second page denied")}, + }) + if !handled || m.screen != screenBackupVaultList { + t.Fatalf("expected backup vault list, screen=%v handled=%v", m.screen, handled) + } + view := m.backup.viewList(m) + plain := stripANSI(view) + for _, want := range []string{"AWS Backup Vaults", "prod", "AVAILABLE", "3", "vault listing failures"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in vault list, got:\n%s", want, plain) + } + } + if strings.Contains(view, "\x1b]52;c;spoof\a") || !strings.Contains(plain, `\x1b]52;c;spoof\a`) { + t.Fatalf("expected terminal controls to be escaped, got %q", view) + } + + m.backup.filtered = []awsservice.BackupVault{{Name: "운영", State: "AVAILABLE", Type: "BACKUP_VAULT"}} + unicodeView := strings.Split(stripANSI(m.backup.viewList(m)), "\n") + headerColumn, rowColumn := -1, -1 + for _, line := range unicodeView { + if index := strings.Index(line, "STATE"); index >= 0 { + headerColumn = lipgloss.Width(line[:index]) + } + if index := strings.Index(line, "AVAILABLE"); index >= 0 { + rowColumn = lipgloss.Width(line[:index]) + } + } + if headerColumn < 0 || rowColumn != headerColumn { + t.Fatalf("expected Unicode vault row to align with header, header=%d row=%d", headerColumn, rowColumn) + } + + m.storeFilterValue(filterBackupVaults, "failed") + m.backup.vaults = backupTestVaults() + m.applyFilterTarget(filterBackupVaults) + if len(m.backup.filtered) != 1 || !strings.Contains(m.backup.filtered[0].Name, "dev") { + t.Fatalf("expected one failed vault match, got %+v", m.backup.filtered) + } +} + +func TestBackupDrillDownRendersPartialDetailAndScrolls(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 100 + m.height = 10 + m.screen = screenBackupVaultList + m.backup.vaults = backupTestVaults()[:1] + m.backup.filtered = append([]awsservice.BackupVault(nil), m.backup.vaults...) + + updated, cmd := m.backup.updateList(&m, tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if cmd == nil || m.screen != screenLoading || m.backup.selected == nil || m.backup.selected.Name != "prod" { + t.Fatalf("expected detail load, screen=%v selected=%+v command=%v", m.screen, m.backup.selected, cmd) + } + now := time.Date(2026, 8, 26, 3, 0, 0, 0, time.UTC) + detail := &awsservice.BackupVaultDetail{ + Vault: *m.backup.selected, + RecoveryPoints: []awsservice.BackupRecoveryPoint{{ + ARN: "arn:point", ResourceName: "database\x1b[31m", ResourceType: "RDS", Status: "PARTIAL", StatusMessage: "access denied", CreatedAt: now, DeleteAt: now.Add(24 * time.Hour), SizeBytes: 2048, SizeBytesKnown: true, + }}, + ProtectedResources: []awsservice.BackupProtectedResource{{Name: "database", Type: "RDS", ARN: "arn:db", LastBackupAt: now, LastRecoveryPointARN: "arn:point"}}, + FailedJobs: []awsservice.BackupJob{{ID: "job-1", ResourceName: "database", ResourceType: "RDS", State: "COMPLETED", MessageCategory: "PERMISSIONS", CreatedAt: now}}, + } + m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{vaultName: "prod", detail: detail, warnings: []error{errors.New("protected resources denied"), errors.New("jobs denied")}}) + if m.screen != screenBackupVaultDetail { + t.Fatalf("expected backup detail, got %v", m.screen) + } + initial := m.backup.viewDetail(m) + plain := stripANSI(initial) + for _, want := range []string{"AWS Backup Recovery", "detail lookup failures", "protected resources denied", "jobs denied", "Recovery Points"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in initial detail, got:\n%s", want, plain) + } + } + if strings.Contains(initial, "\x1b[31m") { + t.Fatalf("expected recovery point controls to be escaped, got %q", initial) + } + + scrolled := "" + for range 10 { + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyPgDown}) + scrolled = stripANSI(m.backup.viewDetail(m)) + if strings.Contains(scrolled, "Protected Resources") || strings.Contains(scrolled, "Failed / Expired Jobs") { + break + } + } + if m.backup.detailScroll == 0 || (!strings.Contains(scrolled, "Protected Resources") && !strings.Contains(scrolled, "Failed / Expired Jobs")) { + t.Fatalf("expected page-down to reveal later recovery sections, scroll=%d view:\n%s", m.backup.detailScroll, scrolled) + } + + m.height = 18 + m.backup.detailScroll = 0 + for range 100 { + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyDown}) + } + wantOffset := max(len(m.backup.detailLines(m))-m.backup.detailVisibleLines(m), 0) + if m.backup.detailScroll != wantOffset || !strings.Contains(stripANSI(m.backup.viewDetail(m)), "PERMISSIONS") { + t.Fatalf("expected warning-adjusted final detail lines to be reachable, scroll=%d want=%d", m.backup.detailScroll, wantOffset) + } +} + +func TestBackupDetailRefreshReloadsVaultMetadata(t *testing.T) { + m := New(testConfig(), "", "dev") + stale := awsservice.BackupVault{ + Name: "prod", ARN: "arn:vault:prod", RecoveryPointCount: 1, + } + m.screen = screenBackupVaultDetail + m.backup.selected = &stale + m.backup.detail = &awsservice.BackupVaultDetail{Vault: stale} + m.backup.vaults = []awsservice.BackupVault{stale} + m.backup.filtered = []awsservice.BackupVault{stale} + m.awsRepo = &awsservice.AwsRepository{Region: "ap-northeast-2", BackupClient: &appBackupClient{ + listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + if input.NextToken != nil { + t.Fatalf("expected a single vault-list page, got token %q", awssdk.ToString(input.NextToken)) + } + return &backup.ListBackupVaultsOutput{BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("prod"), BackupVaultArn: awssdk.String("arn:vault:prod"), + NumberOfRecoveryPoints: 9, Locked: awssdk.Bool(true), MinRetentionDays: awssdk.Int64(30), + }}}, nil + }, + }} + + updated, cmd := m.backup.updateDetail(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + m = updated.(Model) + result := runBatchedUserCmd(t, cmd) + next, _ := m.Update(result) + m = next.(Model) + + if m.backup.detail == nil || m.backup.detail.Vault.RecoveryPointCount != 9 || !m.backup.detail.Vault.Locked || !m.backup.detail.Vault.MinRetentionKnown { + t.Fatalf("expected refreshed vault metadata in detail, got %+v", m.backup.detail) + } + if m.backup.selected == nil || m.backup.selected.RecoveryPointCount != 9 || !m.backup.selected.Locked { + t.Fatalf("expected refreshed selected vault, got %+v", m.backup.selected) + } + lines := stripANSI(strings.Join(m.backup.detailLines(m), "\n")) + if !strings.Contains(lines, "9") || !strings.Contains(lines, "locked (minimum 30 days)") { + t.Fatalf("expected refreshed recovery count and Vault Lock posture, got:\n%s", lines) + } + + m.backup.HandleKey(&m, tea.KeyMsg{Type: tea.KeyEsc}) + if len(m.backup.vaults) != 1 || m.backup.vaults[0].RecoveryPointCount != 9 || len(m.backup.filtered) != 1 || !m.backup.filtered[0].Locked { + t.Fatalf("expected refreshed vault metadata in the canonical list, vaults=%+v filtered=%+v", m.backup.vaults, m.backup.filtered) + } + updated, cmd = m.backup.updateList(&m, tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + result = runBatchedUserCmd(t, cmd) + next, _ = m.Update(result) + m = next.(Model) + if m.backup.detail == nil || m.backup.detail.Vault.RecoveryPointCount != 9 || !m.backup.detail.Vault.Locked { + t.Fatalf("expected refreshed metadata after returning to the detail, got %+v", m.backup.detail) + } +} + +func TestBackupDetailRefreshKeepsPriorVaultWhenListingIsPartial(t *testing.T) { + m := New(testConfig(), "", "dev") + selected := awsservice.BackupVault{Name: "prod", ARN: "arn:vault:prod", RecoveryPointCount: 1} + m.screen = screenBackupVaultDetail + m.backup.selected = &selected + m.backup.detail = &awsservice.BackupVaultDetail{Vault: selected} + m.backup.vaults = []awsservice.BackupVault{selected} + m.backup.filtered = []awsservice.BackupVault{selected} + calls := 0 + m.awsRepo = &awsservice.AwsRepository{BackupClient: &appBackupClient{ + listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + calls++ + if calls == 1 { + return &backup.ListBackupVaultsOutput{ + NextToken: awssdk.String("page-2"), + BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("dev"), BackupVaultArn: awssdk.String("arn:vault:dev"), + }}, + }, nil + } + if awssdk.ToString(input.NextToken) != "page-2" { + t.Fatalf("expected second-page token, got %q", awssdk.ToString(input.NextToken)) + } + return nil, errors.New("page denied") + }, + }} + + updated, cmd := m.backup.updateDetail(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + m = updated.(Model) + result := runBatchedUserCmd(t, cmd) + next, _ := m.Update(result) + m = next.(Model) + + if calls != 2 || m.screen != screenBackupVaultDetail || m.backup.errorActive { + t.Fatalf("expected usable detail after partial listing, calls=%d screen=%v state=%+v", calls, m.screen, m.backup) + } + if m.backup.detail == nil || m.backup.detail.Vault.ARN != selected.ARN || m.backup.detail.Vault.RecoveryPointCount != 1 { + t.Fatalf("expected prior selected vault metadata to remain available, got %+v", m.backup.detail) + } + if len(m.backup.detailErrors) != 1 || !strings.Contains(m.backup.detailErrors[0].Error(), "page denied") { + t.Fatalf("expected partial-list warning, got %v", m.backup.detailErrors) + } +} + +func TestBackupOptionalNumericFieldsRenderWithoutFalseZeroes(t *testing.T) { + for _, tc := range []struct { + name string + vault awsservice.BackupVault + want string + }{ + {name: "minimum only", vault: awsservice.BackupVault{Locked: true, MinRetentionDays: 7, MinRetentionKnown: true}, want: "locked (minimum 7 days)"}, + {name: "maximum only", vault: awsservice.BackupVault{Locked: true, MaxRetentionDays: 365, MaxRetentionKnown: true}, want: "locked (maximum 365 days)"}, + {name: "unbounded", vault: awsservice.BackupVault{Locked: true}, want: "locked"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := backupLockSummary(tc.vault); got != tc.want { + t.Fatalf("backupLockSummary() = %q, want %q", got, tc.want) + } + }) + } + + if got := backupRecoveryPointSize(awsservice.BackupRecoveryPoint{}); got != "-" { + t.Fatalf("unknown recovery-point size = %q, want -", got) + } + if got := backupRecoveryPointSize(awsservice.BackupRecoveryPoint{SizeBytesKnown: true}); got != "0 B" { + t.Fatalf("known zero recovery-point size = %q, want 0 B", got) + } +} + +func TestBackupIgnoresStaleDetailLoads(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenLoading + m.loadingReturnScreen = screenBackupVaultDetail + m.backup.selected = &awsservice.BackupVault{Name: "current"} + _, _, handled := m.backup.HandleMessage(&m, backupVaultDetailLoadedMsg{ + vaultName: "stale", detail: &awsservice.BackupVaultDetail{Vault: awsservice.BackupVault{Name: "stale"}}, + }) + if !handled || m.screen != screenLoading || m.backup.detail != nil { + t.Fatalf("expected stale detail to be ignored, screen=%v detail=%+v handled=%v", m.screen, m.backup.detail, handled) + } +} + +func TestBackupDropsPriorContextCompletionForSameNamedVault(t *testing.T) { + m := New(testConfig(), "", "dev") + staleGeneration := m.commands.Renew() + m.commands.Renew() + + next := testConfig() + next.ContextName = "next" + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + m.screen = screenLoading + m.loadingReturnScreen = screenBackupVaultDetail + m.backup.selected = &awsservice.BackupVault{Name: "prod"} + + stale, _ := m.Update(genBoundMsg{gen: staleGeneration, msg: backupVaultDetailLoadedMsg{ + vaultName: "prod", + detail: &awsservice.BackupVaultDetail{Vault: awsservice.BackupVault{Name: "prod", Region: "old"}}, + }}) + model := stale.(Model) + if model.backup.detail != nil || model.screen != screenLoading { + t.Fatalf("expected prior-context Backup detail to be dropped, screen=%v detail=%+v", model.screen, model.backup.detail) + } +} + +func TestBackupLoadCompletionStaysBehindGlobalOverlays(t *testing.T) { + for _, tc := range []struct { + name string + screen screen + prepare func(*Model) + result func(Model) screen + }{ + {name: "settings", screen: screenSettings, prepare: func(m *Model) { m.settingsPrevScreen = screenLoading }, result: func(m Model) screen { return m.settingsPrevScreen }}, + {name: "palette", screen: screenCommandPalette, prepare: func(m *Model) { m.palette.prevScreen = screenLoading }, result: func(m Model) screen { return m.palette.prevScreen }}, + {name: "views", screen: screenViewList, prepare: func(m *Model) { m.views.prevScreen = screenLoading }, result: func(m Model) screen { return m.views.prevScreen }}, + {name: "region", screen: screenRegionPicker, prepare: func(m *Model) { m.regionPrevScreen = screenLoading }, result: func(m Model) screen { return m.regionPrevScreen }}, + } { + t.Run(tc.name, func(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = tc.screen + m.loadingReturnScreen = screenBackupVaultList + tc.prepare(&m) + m.backup.HandleMessage(&m, backupVaultsLoadedMsg{vaults: backupTestVaults()}) + if m.screen != tc.screen || tc.result(m) != screenBackupVaultList { + t.Fatalf("expected load completion behind %s, screen=%v return=%v", tc.name, m.screen, tc.result(m)) + } + }) + } +} + +func TestBackupLoadCompletionBehindContextAddReturnsToVaultList(t *testing.T) { + m := New(testConfig(), "", "dev") + m.cfg.ContextName = "dev" + started, _ := m.backup.Start(&m) + m = started.(Model) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'C'}}) + m = updated.(Model) + updated, _ = m.Update(contextsLoadedMsg{contexts: testContexts()}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + m = updated.(Model) + + updated, _ = m.Update(backupVaultsLoadedMsg{vaults: backupTestVaults()}) + m = updated.(Model) + if m.screen != screenContextAdd || m.ctxPrevScreen != screenBackupVaultList { + t.Fatalf("expected context add to stay open over the loaded vault list, screen=%v previous=%v", m.screen, m.ctxPrevScreen) + } + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if cmd == nil { + t.Fatal("expected context add cancel to reload the context picker") + } + updated, _ = m.Update(cmd()) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.screen != screenBackupVaultList || len(m.backup.vaults) != len(backupTestVaults()) { + t.Fatalf("expected context flow to return to the loaded vault list, screen=%v vaults=%d", m.screen, len(m.backup.vaults)) + } +} + +func TestBackupErrorBehindContextPickerIsClearedByContextSwitch(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextPicker + m.ctxPrevScreen = screenLoading + m.loadingReturnScreen = screenBackupVaultList + m.backup.HandleMessage(&m, backupVaultsLoadedMsg{err: errors.New("backup denied")}) + if m.screen != screenContextPicker || m.ctxPrevScreen != screenError || !m.backup.errorActive { + t.Fatalf("expected error behind picker, screen=%v return=%v state=%+v", m.screen, m.ctxPrevScreen, m.backup) + } + + next := testConfig() + next.ContextName = "next" + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + if m.screen != screenServiceList || len(m.backup.vaults) != 0 || m.backup.errorActive { + t.Fatalf("expected context switch to clear backup state and return, screen=%v state=%+v", m.screen, m.backup) + } +} + +func TestBackupContextSwitchClearsStateAndFilter(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenContextPicker + m.ctxPrevScreen = screenBackupVaultDetail + m.backup.vaults = backupTestVaults() + m.backup.selected = &m.backup.vaults[0] + m.storeFilterValue(filterBackupVaults, "prod") + + next := &config.Config{ContextName: "next", Region: "us-east-1"} + updated, _ := m.Update(contextSwitchedMsg{cfg: next}) + m = updated.(Model) + if m.screen != screenServiceList || len(m.backup.vaults) != 0 || m.backup.selected != nil || m.filterValue(filterBackupVaults) != "" { + t.Fatalf("expected clean backup state after context switch, screen=%v state=%+v filter=%q", m.screen, m.backup, m.filterValue(filterBackupVaults)) + } +} diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index 3582d33..a555b33 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -90,6 +90,8 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } m.dynamodb = newDynamoDBModel() m.resetFilter(filterDynamoDBTables) + normalizeBackupContextReturn(&m) + resetBackupContextState(&m) m.ctxPrevWasLoading = false if m.settingsPrevScreen == screenLoading || isDynamoDBScreen(m.settingsPrevScreen) { m.settingsPrevScreen = screenServiceList @@ -119,6 +121,7 @@ func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.cfg.Region = msg.region m.awsRepo = msg.repo resetStepFunctionsContextState(&m) + resetBackupContextState(&m) resetSNSContextState(&m) resetAPIGatewayV2ContextState(&m) m.ctxPrevWasLoading = false @@ -272,6 +275,7 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { normalizeWAFContextReturn(&m) } m.eventBridge.preserveOverlay(&m, screenFeatureList) + normalizeBackupContextReturn(&m) m.ctxPrevWasLoading = false return m.startLoading(m.switchContext(selected.Name)) } diff --git a/internal/app/screen_views.go b/internal/app/screen_views.go index a522a18..0c83cef 100644 --- a/internal/app/screen_views.go +++ b/internal/app/screen_views.go @@ -46,6 +46,7 @@ var featurePrimaryFilter = map[domain.FeatureKind]filterTarget{ domain.FeatureKMSKeyBrowser: filterKMSKeys, domain.FeatureLambdaBrowser: filterLambdaFunctions, domain.FeatureDynamoDBBrowser: filterDynamoDBTables, + domain.FeatureBackupBrowser: filterBackupVaults, domain.FeatureAPIGatewayV2Browser: filterAPIGatewayV2APIs, domain.FeatureWAFWebACLBrowser: filterWAFWebACLs, domain.FeatureBedrockAPIKeys: filterBedrockKeys, @@ -200,10 +201,8 @@ func (m Model) jumpToView(view config.ViewEntry) (tea.Model, tea.Cmd) { service: domain.AwsService(view.Service), feature: kind, }) - if view.Filter != "" { - if target, ok := featurePrimaryFilter[kind]; ok { - m.storeFilterValue(target, view.Filter) - } + if target, ok := featurePrimaryFilter[kind]; ok { + m.storeFilterValue(target, view.Filter) } return m.startFeature(kind) } diff --git a/internal/app/screen_views_test.go b/internal/app/screen_views_test.go index c4d3448..4df3fb9 100644 --- a/internal/app/screen_views_test.go +++ b/internal/app/screen_views_test.go @@ -113,6 +113,21 @@ func TestViewsCaptureAndApplyKMSFilter(t *testing.T) { } } +func TestViewsApplyUnfilteredBackupClearsExistingFilter(t *testing.T) { + m := viewsTestModel(t) + m.storeFilterValue(filterBackupVaults, "prod") + view := config.ViewEntry{ + Name: "all-backups", Context: "dev", + Service: string(domain.ServiceBackup), Feature: string(domain.FeatureBackupBrowser), + } + + next, cmd := m.applyView(view) + model := next.(Model) + if cmd == nil || model.filterValue(filterBackupVaults) != "" { + t.Fatalf("expected unfiltered Backup view to clear the prior filter, filter=%q", model.filterValue(filterBackupVaults)) + } +} + func TestViewsApplyAcrossContextsDefersJumpUntilSwitch(t *testing.T) { m := viewsTestModel(t) view := config.ViewEntry{ diff --git a/internal/app/styles.go b/internal/app/styles.go index 65a4f8d..e7880d0 100644 --- a/internal/app/styles.go +++ b/internal/app/styles.go @@ -172,13 +172,17 @@ func (m Model) renderListPanel(content string) string { } func (m Model) renderWarningSummary(count int, label, first string) string { - first, _, _ = strings.Cut(first, "\n") + return errorStyle.Render(fmt.Sprintf(" Warnings: %d %s", count, label)) + "\n" + + m.renderWarningDetail(first) +} + +func (m Model) renderWarningDetail(detail string) string { + detail, _, _ = strings.Cut(detail, "\n") width := m.width - 4 if width <= 0 { width = 88 } - return errorStyle.Render(fmt.Sprintf(" Warnings: %d %s", count, label)) + "\n" + - dimStyle.Render(" "+truncateEC2DetailValue(escapeTerminalControls(first), width)) + "\n" + return dimStyle.Render(" "+truncateEC2DetailValue(escapeTerminalControls(detail), width)) + "\n" } func (m Model) renderHelpBar(content string) string { diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 41fab65..e1bd55e 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -236,6 +236,15 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceBackup, + Features: []Feature{ + { + Kind: FeatureBackupBrowser, + Description: "Inspect backup vaults, recovery points, protected resources, and failed jobs", + }, + }, + }, { Name: ServiceAPIGateway, Features: []Feature{ diff --git a/internal/domain/catalog_test.go b/internal/domain/catalog_test.go index f5f81f2..239e20d 100644 --- a/internal/domain/catalog_test.go +++ b/internal/domain/catalog_test.go @@ -308,6 +308,22 @@ func TestCatalogContainsDynamoDBBrowserFeature(t *testing.T) { t.Error("DynamoDB service not found in catalog") } +func TestCatalogContainsBackupBrowserFeature(t *testing.T) { + for _, svc := range Catalog() { + if svc.Name != ServiceBackup { + continue + } + for _, feat := range svc.Features { + if feat.Kind == FeatureBackupBrowser { + return + } + } + t.Error("AWS Backup service should have Backup Recovery Browser feature") + return + } + t.Error("AWS Backup service not found in catalog") +} + func TestCatalogContainsEventBridgeRulesFeature(t *testing.T) { for _, svc := range Catalog() { if svc.Name != ServiceEventBridge { diff --git a/internal/domain/model.go b/internal/domain/model.go index 1c0c41f..841b7d7 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -33,6 +33,7 @@ const ( ServiceBedrock AwsService = "Bedrock" ServiceCloudFormation AwsService = "CloudFormation" ServiceDynamoDB AwsService = "DynamoDB" + ServiceBackup AwsService = "AWS Backup" ServiceAPIGateway AwsService = "API Gateway" ServiceWAF AwsService = "WAF" ) @@ -76,6 +77,7 @@ const ( FeatureBedrockAPIKeys FeatureKind = "Bedrock API Keys" FeatureCloudFormationBrowser FeatureKind = "CloudFormation Stack Browser" FeatureDynamoDBBrowser FeatureKind = "DynamoDB Table Browser" + FeatureBackupBrowser FeatureKind = "Backup Recovery Browser" FeatureAPIGatewayV2Browser FeatureKind = "API Gateway v2 Browser" FeatureWAFWebACLBrowser FeatureKind = "WAFv2 Web ACL Browser" ) diff --git a/internal/services/aws/backup.go b/internal/services/aws/backup.go new file mode 100644 index 0000000..8856914 --- /dev/null +++ b/internal/services/aws/backup.go @@ -0,0 +1,323 @@ +package aws + +import ( + "context" + "fmt" + "sort" + "strings" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" + + uniclog "unic/internal/log" +) + +// ListBackupVaults returns vaults in the active region. Completed pages remain +// available when a later page fails. +func (r *AwsRepository) ListBackupVaults(ctx context.Context) ([]BackupVault, []error, error) { + uniclog.Debug("aws", "ListBackupVaults called") + + var vaults []BackupVault + var warnings []error + var nextToken *string + for { + out, err := r.BackupClient.ListBackupVaults(ctx, &backup.ListBackupVaultsInput{NextToken: nextToken}) + if err != nil { + wrapped := fmt.Errorf("failed to list AWS Backup vaults: %w", err) + if len(vaults) == 0 { + return nil, nil, wrapped + } + warnings = append(warnings, wrapped) + break + } + for _, vault := range out.BackupVaultList { + vaults = append(vaults, mapBackupVault(vault, r.Region)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + + sort.Slice(vaults, func(i, j int) bool { + left, right := normalizedSortKey(vaults[i].Name), normalizedSortKey(vaults[j].Name) + if left != right { + return left < right + } + return vaults[i].ARN < vaults[j].ARN + }) + return vaults, warnings, nil +} + +// GetBackupVaultDetail loads independent recovery point, protected resource, +// and recent failed-job sections. A denied section becomes a warning without +// hiding the others. +func (r *AwsRepository) GetBackupVaultDetail(ctx context.Context, vault BackupVault) (*BackupVaultDetail, []error, error) { + detail := &BackupVaultDetail{Vault: vault} + var warnings []error + + recoveryPoints, err := r.listBackupRecoveryPoints(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.RecoveryPoints = recoveryPoints + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + resources, err := r.listBackupProtectedResources(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.ProtectedResources = resources + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + jobs, err := r.listFailedBackupJobs(ctx, vault.Name) + if err != nil { + warnings = append(warnings, err) + } + detail.FailedJobs = jobs + + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return detail, warnings, nil +} + +func (r *AwsRepository) listBackupRecoveryPoints(ctx context.Context, vaultName string) ([]BackupRecoveryPoint, error) { + var items []BackupRecoveryPoint + var nextToken *string + for { + out, err := r.BackupClient.ListRecoveryPointsByBackupVault(ctx, &backup.ListRecoveryPointsByBackupVaultInput{ + BackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupRecoveryPoints(items) + return items, fmt.Errorf("failed to list recovery points for backup vault %s: %w", vaultName, err) + } + for _, item := range out.RecoveryPoints { + items = append(items, mapBackupRecoveryPoint(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupRecoveryPoints(items) + return items, nil +} + +func (r *AwsRepository) listBackupProtectedResources(ctx context.Context, vaultName string) ([]BackupProtectedResource, error) { + var items []BackupProtectedResource + var nextToken *string + for { + out, err := r.BackupClient.ListProtectedResourcesByBackupVault(ctx, &backup.ListProtectedResourcesByBackupVaultInput{ + BackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupProtectedResources(items) + return items, fmt.Errorf("failed to list protected resources for backup vault %s: %w", vaultName, err) + } + for _, item := range out.Results { + items = append(items, mapBackupProtectedResource(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupProtectedResources(items) + return items, nil +} + +func (r *AwsRepository) listFailedBackupJobs(ctx context.Context, vaultName string) ([]BackupJob, error) { + var items []BackupJob + var nextToken *string + for { + out, err := r.BackupClient.ListBackupJobs(ctx, &backup.ListBackupJobsInput{ + ByBackupVaultName: awssdk.String(vaultName), NextToken: nextToken, + }) + if err != nil { + sortBackupJobs(items) + return items, fmt.Errorf("failed to list recent jobs for backup vault %s: %w", vaultName, err) + } + for _, item := range out.BackupJobs { + if !backupJobNeedsAttention(string(item.State), awssdk.ToString(item.MessageCategory)) { + continue + } + items = append(items, mapBackupJob(item)) + } + if awssdk.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + sortBackupJobs(items) + return items, nil +} + +func sortBackupProtectedResources(items []BackupProtectedResource) { + sort.Slice(items, func(i, j int) bool { + if !items[i].LastBackupAt.Equal(items[j].LastBackupAt) { + return items[i].LastBackupAt.After(items[j].LastBackupAt) + } + left, right := normalizedSortKey(items[i].Name), normalizedSortKey(items[j].Name) + if left != right { + return left < right + } + return normalizedSortKey(items[i].ARN) < normalizedSortKey(items[j].ARN) + }) +} + +func sortBackupJobs(items []BackupJob) { + sort.SliceStable(items, func(i, j int) bool { + left, right := backupJobStatusRank(items[i].State), backupJobStatusRank(items[j].State) + if left != right { + return left < right + } + if !items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].CreatedAt.After(items[j].CreatedAt) + } + return normalizedSortKey(items[i].ID) < normalizedSortKey(items[j].ID) + }) +} + +func mapBackupVault(item backuptypes.BackupVaultListMember, region string) BackupVault { + vault := BackupVault{ + ARN: awssdk.ToString(item.BackupVaultArn), + Name: awssdk.ToString(item.BackupVaultName), + Region: region, + State: string(item.VaultState), + Type: string(item.VaultType), + EncryptionKeyARN: awssdk.ToString(item.EncryptionKeyArn), + EncryptionKeyType: string(item.EncryptionKeyType), + RecoveryPointCount: item.NumberOfRecoveryPoints, + Locked: awssdk.ToBool(item.Locked), + MinRetentionDays: awssdk.ToInt64(item.MinRetentionDays), + MinRetentionKnown: item.MinRetentionDays != nil, + MaxRetentionDays: awssdk.ToInt64(item.MaxRetentionDays), + MaxRetentionKnown: item.MaxRetentionDays != nil, + } + if item.CreationDate != nil { + vault.CreatedAt = *item.CreationDate + } + if item.LockDate != nil { + vault.LockDate = *item.LockDate + } + return vault +} + +func mapBackupRecoveryPoint(item backuptypes.RecoveryPointByBackupVault) BackupRecoveryPoint { + mapped := BackupRecoveryPoint{ + ARN: awssdk.ToString(item.RecoveryPointArn), + ResourceARN: awssdk.ToString(item.ResourceArn), + ResourceName: awssdk.ToString(item.ResourceName), + ResourceType: awssdk.ToString(item.ResourceType), + Status: string(item.Status), + StatusMessage: awssdk.ToString(item.StatusMessage), + SourceVaultARN: awssdk.ToString(item.SourceBackupVaultArn), + SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + SizeBytesKnown: item.BackupSizeInBytes != nil, + Encrypted: item.IsEncrypted, + } + if item.CreationDate != nil { + mapped.CreatedAt = *item.CreationDate + } + if item.CompletionDate != nil { + mapped.CompletedAt = *item.CompletionDate + } + if item.CalculatedLifecycle != nil { + if item.CalculatedLifecycle.MoveToColdStorageAt != nil { + mapped.MoveToColdAt = *item.CalculatedLifecycle.MoveToColdStorageAt + } + if item.CalculatedLifecycle.DeleteAt != nil { + mapped.DeleteAt = *item.CalculatedLifecycle.DeleteAt + } + } + return mapped +} + +func mapBackupProtectedResource(item backuptypes.ProtectedResource) BackupProtectedResource { + mapped := BackupProtectedResource{ + ARN: awssdk.ToString(item.ResourceArn), + Name: awssdk.ToString(item.ResourceName), + Type: awssdk.ToString(item.ResourceType), + LastRecoveryPointARN: awssdk.ToString(item.LastRecoveryPointArn), + } + if item.LastBackupTime != nil { + mapped.LastBackupAt = *item.LastBackupTime + } + return mapped +} + +func mapBackupJob(item backuptypes.BackupJob) BackupJob { + mapped := BackupJob{ + ID: awssdk.ToString(item.BackupJobId), + ResourceARN: awssdk.ToString(item.ResourceArn), + ResourceName: awssdk.ToString(item.ResourceName), + ResourceType: awssdk.ToString(item.ResourceType), + State: string(item.State), + StatusMessage: awssdk.ToString(item.StatusMessage), + MessageCategory: awssdk.ToString(item.MessageCategory), + SizeBytes: awssdk.ToInt64(item.BackupSizeInBytes), + } + if item.CreationDate != nil { + mapped.CreatedAt = *item.CreationDate + } + if item.CompletionDate != nil { + mapped.CompletedAt = *item.CompletionDate + } + return mapped +} + +func sortBackupRecoveryPoints(items []BackupRecoveryPoint) { + sort.SliceStable(items, func(i, j int) bool { + if items[i].NeedsAttention() != items[j].NeedsAttention() { + return items[i].NeedsAttention() + } + if !items[i].DeleteAt.Equal(items[j].DeleteAt) { + if items[i].DeleteAt.IsZero() { + return false + } + if items[j].DeleteAt.IsZero() { + return true + } + return items[i].DeleteAt.Before(items[j].DeleteAt) + } + if !items[i].CreatedAt.Equal(items[j].CreatedAt) { + return items[i].CreatedAt.After(items[j].CreatedAt) + } + left, right := normalizedSortKey(items[i].ResourceName), normalizedSortKey(items[j].ResourceName) + if left != right { + return left < right + } + return normalizedSortKey(items[i].ARN) < normalizedSortKey(items[j].ARN) + }) +} + +func backupJobNeedsAttention(state, category string) bool { + switch strings.ToUpper(state) { + case "FAILED", "ABORTED", "EXPIRED", "PARTIAL": + return true + case "COMPLETED": + return category != "" && !strings.EqualFold(category, "SUCCESS") + default: + return false + } +} + +func backupJobStatusRank(state string) int { + switch strings.ToUpper(state) { + case "FAILED": + return 0 + case "EXPIRED": + return 1 + case "ABORTED", "PARTIAL": + return 2 + default: + return 3 + } +} diff --git a/internal/services/aws/backup_model.go b/internal/services/aws/backup_model.go new file mode 100644 index 0000000..9478076 --- /dev/null +++ b/internal/services/aws/backup_model.go @@ -0,0 +1,89 @@ +package aws + +import ( + "strings" + "time" +) + +// BackupVault contains vault metadata rendered by the browser. +type BackupVault struct { + ARN string + Name string + Region string + State string + Type string + EncryptionKeyARN string + EncryptionKeyType string + RecoveryPointCount int64 + Locked bool + LockDate time.Time + MinRetentionDays int64 + MinRetentionKnown bool + MaxRetentionDays int64 + MaxRetentionKnown bool + CreatedAt time.Time +} + +// FilterText returns searchable vault metadata. +func (v BackupVault) FilterText() string { + return strings.ToLower(strings.Join([]string{v.ARN, v.Name, v.Region, v.State, v.Type, v.EncryptionKeyARN, v.EncryptionKeyType}, " ")) +} + +// BackupVaultDetail contains the recovery-readiness signals for one vault. +type BackupVaultDetail struct { + Vault BackupVault + RecoveryPoints []BackupRecoveryPoint + ProtectedResources []BackupProtectedResource + FailedJobs []BackupJob +} + +// BackupRecoveryPoint contains recovery point metadata used by the detail screen. +type BackupRecoveryPoint struct { + ARN string + ResourceARN string + ResourceName string + ResourceType string + Status string + StatusMessage string + SourceVaultARN string + SizeBytes int64 + SizeBytesKnown bool + Encrypted bool + CreatedAt time.Time + CompletedAt time.Time + MoveToColdAt time.Time + DeleteAt time.Time +} + +// NeedsAttention reports whether the recovery point is not currently usable. +func (r BackupRecoveryPoint) NeedsAttention() bool { + switch strings.ToUpper(r.Status) { + case "AVAILABLE", "COMPLETED": + return false + default: + return true + } +} + +// BackupProtectedResource contains the latest backup metadata for one resource. +type BackupProtectedResource struct { + ARN string + Name string + Type string + LastBackupAt time.Time + LastRecoveryPointARN string +} + +// BackupJob contains a recent failed or expired backup job. +type BackupJob struct { + ID string + ResourceARN string + ResourceName string + ResourceType string + State string + StatusMessage string + MessageCategory string + SizeBytes int64 + CreatedAt time.Time + CompletedAt time.Time +} diff --git a/internal/services/aws/backup_test.go b/internal/services/aws/backup_test.go new file mode 100644 index 0000000..fc5ee7c --- /dev/null +++ b/internal/services/aws/backup_test.go @@ -0,0 +1,254 @@ +package aws + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/backup" + backuptypes "github.com/aws/aws-sdk-go-v2/service/backup/types" +) + +type mockBackupClient struct { + listVaults func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) + listPoints func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) + listResources func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) + listJobs func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) +} + +func (m *mockBackupClient) ListBackupVaults(ctx context.Context, input *backup.ListBackupVaultsInput, opts ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return m.listVaults(ctx, input, opts...) +} + +func (m *mockBackupClient) ListRecoveryPointsByBackupVault(ctx context.Context, input *backup.ListRecoveryPointsByBackupVaultInput, opts ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return m.listPoints(ctx, input, opts...) +} + +func (m *mockBackupClient) ListProtectedResourcesByBackupVault(ctx context.Context, input *backup.ListProtectedResourcesByBackupVaultInput, opts ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return m.listResources(ctx, input, opts...) +} + +func (m *mockBackupClient) ListBackupJobs(ctx context.Context, input *backup.ListBackupJobsInput, opts ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return m.listJobs(ctx, input, opts...) +} + +func TestListBackupVaultsPaginatesMapsAndPreservesCompletedPages(t *testing.T) { + now := time.Date(2026, 8, 26, 1, 0, 0, 0, time.UTC) + calls := 0 + client := &mockBackupClient{listVaults: func(_ context.Context, input *backup.ListBackupVaultsInput, _ ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + calls++ + if calls == 1 { + if input.NextToken != nil { + t.Fatalf("expected no first-page token, got %q", awssdk.ToString(input.NextToken)) + } + return &backup.ListBackupVaultsOutput{ + NextToken: awssdk.String("page-2"), + BackupVaultList: []backuptypes.BackupVaultListMember{{ + BackupVaultName: awssdk.String("prod"), BackupVaultArn: awssdk.String("arn:prod"), + CreationDate: awssdk.Time(now), EncryptionKeyArn: awssdk.String("arn:kms"), + EncryptionKeyType: backuptypes.EncryptionKeyTypeCustomerManagedKmsKey, + Locked: awssdk.Bool(true), LockDate: awssdk.Time(now.Add(time.Hour)), + MinRetentionDays: awssdk.Int64(7), MaxRetentionDays: awssdk.Int64(365), + NumberOfRecoveryPoints: 4, VaultState: backuptypes.VaultStateAvailable, + VaultType: backuptypes.VaultTypeBackupVault, + }}, + }, nil + } + if awssdk.ToString(input.NextToken) != "page-2" { + t.Fatalf("expected second-page token, got %q", awssdk.ToString(input.NextToken)) + } + return nil, errors.New("page denied") + }} + + vaults, warnings, err := (&AwsRepository{BackupClient: client, Region: "ap-northeast-2"}).ListBackupVaults(context.Background()) + if err != nil { + t.Fatal(err) + } + if calls != 2 || len(vaults) != 1 || len(warnings) != 1 { + t.Fatalf("expected one retained vault and one warning, calls=%d vaults=%+v warnings=%v", calls, vaults, warnings) + } + vault := vaults[0] + if vault.Name != "prod" || vault.Region != "ap-northeast-2" || !vault.Locked || vault.RecoveryPointCount != 4 || vault.EncryptionKeyARN != "arn:kms" { + t.Fatalf("unexpected vault mapping: %+v", vault) + } + for _, value := range []string{"prod", "available", "arn:kms", "ap-northeast-2"} { + if !strings.Contains(vault.FilterText(), value) { + t.Fatalf("expected filter text to contain %q: %q", value, vault.FilterText()) + } + } +} + +func TestListBackupVaultsReturnsFatalInitialError(t *testing.T) { + client := &mockBackupClient{listVaults: func(context.Context, *backup.ListBackupVaultsInput, ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) { + return nil, errors.New("denied") + }} + _, _, err := (&AwsRepository{BackupClient: client}).ListBackupVaults(context.Background()) + if err == nil || !strings.Contains(err.Error(), "list AWS Backup vaults") { + t.Fatalf("expected contextual fatal list error, got %v", err) + } +} + +func TestBackupOptionalNumericFieldsPreservePresence(t *testing.T) { + for _, tc := range []struct { + name string + min, max *int64 + wantMin bool + wantMax bool + }{ + {name: "minimum only", min: awssdk.Int64(7), wantMin: true}, + {name: "maximum only", max: awssdk.Int64(365), wantMax: true}, + {name: "unbounded"}, + } { + t.Run(tc.name, func(t *testing.T) { + vault := mapBackupVault(backuptypes.BackupVaultListMember{ + MinRetentionDays: tc.min, + MaxRetentionDays: tc.max, + }, "ap-northeast-2") + if vault.MinRetentionKnown != tc.wantMin || vault.MaxRetentionKnown != tc.wantMax { + t.Fatalf("unexpected retention presence: %+v", vault) + } + }) + } + + unknown := mapBackupRecoveryPoint(backuptypes.RecoveryPointByBackupVault{}) + zero := mapBackupRecoveryPoint(backuptypes.RecoveryPointByBackupVault{BackupSizeInBytes: awssdk.Int64(0)}) + if unknown.SizeBytesKnown || !zero.SizeBytesKnown || zero.SizeBytes != 0 { + t.Fatalf("expected nil size to remain unknown and explicit zero to remain known: unknown=%+v zero=%+v", unknown, zero) + } +} + +func TestGetBackupVaultDetailKeepsPartialSectionsAndPrioritizesFailures(t *testing.T) { + now := time.Date(2026, 8, 26, 2, 0, 0, 0, time.UTC) + pointCalls := 0 + client := &mockBackupClient{ + listPoints: func(_ context.Context, input *backup.ListRecoveryPointsByBackupVaultInput, _ ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + pointCalls++ + if awssdk.ToString(input.BackupVaultName) != "prod" { + t.Fatalf("expected prod vault, got %q", awssdk.ToString(input.BackupVaultName)) + } + if pointCalls == 1 { + return &backup.ListRecoveryPointsByBackupVaultOutput{ + NextToken: awssdk.String("page-2"), + RecoveryPoints: []backuptypes.RecoveryPointByBackupVault{ + {RecoveryPointArn: awssdk.String("arn:healthy"), ResourceName: awssdk.String("healthy"), ResourceType: awssdk.String("RDS"), Status: backuptypes.RecoveryPointStatusCompleted, CreationDate: awssdk.Time(now.Add(-time.Hour))}, + {RecoveryPointArn: awssdk.String("arn:failed"), ResourceName: awssdk.String("failed"), ResourceType: awssdk.String("EBS"), Status: backuptypes.RecoveryPointStatusPartial, StatusMessage: awssdk.String("snapshot failed"), CreationDate: awssdk.Time(now), CalculatedLifecycle: &backuptypes.CalculatedLifecycle{DeleteAt: awssdk.Time(now.Add(24 * time.Hour))}}, + }, + }, nil + } + return nil, errors.New("second page denied") + }, + listResources: func(_ context.Context, input *backup.ListProtectedResourcesByBackupVaultInput, _ ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + if awssdk.ToString(input.BackupVaultName) != "prod" { + t.Fatalf("expected prod vault, got %q", awssdk.ToString(input.BackupVaultName)) + } + return &backup.ListProtectedResourcesByBackupVaultOutput{Results: []backuptypes.ProtectedResource{{ + ResourceArn: awssdk.String("arn:resource"), ResourceName: awssdk.String("database"), ResourceType: awssdk.String("RDS"), LastBackupTime: awssdk.Time(now), LastRecoveryPointArn: awssdk.String("arn:healthy"), + }}}, nil + }, + listJobs: func(_ context.Context, input *backup.ListBackupJobsInput, _ ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + if awssdk.ToString(input.ByBackupVaultName) != "prod" { + t.Fatalf("expected prod vault filter, got %q", awssdk.ToString(input.ByBackupVaultName)) + } + return &backup.ListBackupJobsOutput{BackupJobs: []backuptypes.BackupJob{ + {BackupJobId: awssdk.String("ok"), State: backuptypes.BackupJobStateCompleted, MessageCategory: awssdk.String("SUCCESS")}, + {BackupJobId: awssdk.String("completed-with-issues"), State: backuptypes.BackupJobStateCompleted, MessageCategory: awssdk.String("PERMISSIONS")}, + {BackupJobId: awssdk.String("expired"), ResourceName: awssdk.String("old"), State: backuptypes.BackupJobStateExpired, CreationDate: awssdk.Time(now.Add(-time.Hour))}, + {BackupJobId: awssdk.String("failed"), ResourceName: awssdk.String("db"), State: backuptypes.BackupJobStateFailed, StatusMessage: awssdk.String("access denied"), CreationDate: awssdk.Time(now)}, + }}, nil + }, + } + + vault := BackupVault{Name: "prod", ARN: "arn:vault"} + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(context.Background(), vault) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 1 || !strings.Contains(warnings[0].Error(), "second page denied") { + t.Fatalf("expected retained-page warning, got %v", warnings) + } + if len(detail.RecoveryPoints) != 2 || detail.RecoveryPoints[0].ARN != "arn:failed" { + t.Fatalf("expected failed recovery point first after partial pagination, got %+v", detail.RecoveryPoints) + } + if len(detail.ProtectedResources) != 1 || detail.ProtectedResources[0].LastRecoveryPointARN != "arn:healthy" { + t.Fatalf("unexpected protected resources: %+v", detail.ProtectedResources) + } + if len(detail.FailedJobs) != 3 || detail.FailedJobs[0].ID != "failed" || detail.FailedJobs[1].ID != "expired" || detail.FailedJobs[2].MessageCategory != "PERMISSIONS" { + t.Fatalf("expected only failure jobs in priority order, got %+v", detail.FailedJobs) + } +} + +func TestGetBackupVaultDetailStopsBetweenSectionsWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + resourcesCalled, jobsCalled := false, false + client := &mockBackupClient{ + listPoints: func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + cancel() + return &backup.ListRecoveryPointsByBackupVaultOutput{}, nil + }, + listResources: func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + resourcesCalled = true + return &backup.ListProtectedResourcesByBackupVaultOutput{}, nil + }, + listJobs: func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + jobsCalled = true + return &backup.ListBackupJobsOutput{}, nil + }, + } + + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(ctx, BackupVault{Name: "prod"}) + if !errors.Is(err, context.Canceled) || detail != nil || warnings != nil || resourcesCalled || jobsCalled { + t.Fatalf("expected cancellation before later sections, detail=%+v warnings=%v err=%v resources=%t jobs=%t", detail, warnings, err, resourcesCalled, jobsCalled) + } +} + +func TestBackupDetailSortsUseUniqueTieBreakers(t *testing.T) { + now := time.Date(2026, 8, 26, 4, 0, 0, 0, time.UTC) + + resources := []BackupProtectedResource{ + {ARN: "arn:z", Name: "database", LastBackupAt: now}, + {ARN: "arn:a", Name: "database", LastBackupAt: now}, + } + sortBackupProtectedResources(resources) + if resources[0].ARN != "arn:a" { + t.Fatalf("expected protected resources to use ARN tie-breaker, got %+v", resources) + } + + jobs := []BackupJob{ + {ID: "job-z", State: "FAILED", CreatedAt: now}, + {ID: "job-a", State: "FAILED", CreatedAt: now}, + } + sortBackupJobs(jobs) + if jobs[0].ID != "job-a" { + t.Fatalf("expected jobs to use ID tie-breaker, got %+v", jobs) + } + + points := []BackupRecoveryPoint{ + {ARN: "arn:z", ResourceName: "database", Status: "COMPLETED", CreatedAt: now}, + {ARN: "arn:a", ResourceName: "database", Status: "COMPLETED", CreatedAt: now}, + } + sortBackupRecoveryPoints(points) + if points[0].ARN != "arn:a" { + t.Fatalf("expected recovery points to use ARN tie-breaker, got %+v", points) + } +} + +func TestGetBackupVaultDetailReturnsWarningsWhenAllSectionsAreDenied(t *testing.T) { + client := &mockBackupClient{ + listPoints: func(context.Context, *backup.ListRecoveryPointsByBackupVaultInput, ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) { + return nil, errors.New("points denied") + }, + listResources: func(context.Context, *backup.ListProtectedResourcesByBackupVaultInput, ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) { + return nil, errors.New("resources denied") + }, + listJobs: func(context.Context, *backup.ListBackupJobsInput, ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) { + return nil, errors.New("jobs denied") + }, + } + detail, warnings, err := (&AwsRepository{BackupClient: client}).GetBackupVaultDetail(context.Background(), BackupVault{Name: "prod"}) + if err != nil || detail == nil || len(warnings) != 3 { + t.Fatalf("expected empty detail with three warnings, detail=%+v warnings=%v err=%v", detail, warnings, err) + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index c87ad41..0997add 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -10,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/aws/aws-sdk-go-v2/service/apigatewayv2" "github.com/aws/aws-sdk-go-v2/service/autoscaling" + "github.com/aws/aws-sdk-go-v2/service/backup" "github.com/aws/aws-sdk-go-v2/service/cloudformation" "github.com/aws/aws-sdk-go-v2/service/cloudfront" "github.com/aws/aws-sdk-go-v2/service/cloudtrail" @@ -51,6 +52,7 @@ var _ KMSClientAPI = (*kms.Client)(nil) var _ ACMClientAPI = (*acm.Client)(nil) var _ StepFunctionsClientAPI = (*sfn.Client)(nil) var _ DynamoDBClientAPI = (*dynamodb.Client)(nil) +var _ BackupClientAPI = (*backup.Client)(nil) var _ APIGatewayV2ClientAPI = (*apigatewayv2.Client)(nil) var _ WAFV2ClientAPI = (*wafv2.Client)(nil) var _ CloudFrontClientAPI = (*cloudfront.Client)(nil) @@ -181,6 +183,14 @@ type DynamoDBClientAPI interface { GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) } +// BackupClientAPI is the interface for read-only AWS Backup browser operations. +type BackupClientAPI interface { + ListBackupVaults(ctx context.Context, params *backup.ListBackupVaultsInput, optFns ...func(*backup.Options)) (*backup.ListBackupVaultsOutput, error) + ListRecoveryPointsByBackupVault(ctx context.Context, params *backup.ListRecoveryPointsByBackupVaultInput, optFns ...func(*backup.Options)) (*backup.ListRecoveryPointsByBackupVaultOutput, error) + ListProtectedResourcesByBackupVault(ctx context.Context, params *backup.ListProtectedResourcesByBackupVaultInput, optFns ...func(*backup.Options)) (*backup.ListProtectedResourcesByBackupVaultOutput, error) + ListBackupJobs(ctx context.Context, params *backup.ListBackupJobsInput, optFns ...func(*backup.Options)) (*backup.ListBackupJobsOutput, error) +} + // APIGatewayV2ClientAPI is the interface for HTTP and WebSocket API browser operations. type APIGatewayV2ClientAPI interface { GetApis(ctx context.Context, params *apigatewayv2.GetApisInput, optFns ...func(*apigatewayv2.Options)) (*apigatewayv2.GetApisOutput, error) @@ -455,6 +465,7 @@ type AwsRepository struct { ACMClient ACMClientAPI StepFunctionsClient StepFunctionsClientAPI DynamoDBClient DynamoDBClientAPI + BackupClient BackupClientAPI APIGatewayV2Client APIGatewayV2ClientAPI WAFV2Client WAFV2ClientAPI @@ -590,6 +601,7 @@ func newRepositoryFromConfig(awsCfg aws.Config, region, profile string) *AwsRepo ACMClient: acm.NewFromConfig(awsCfg), StepFunctionsClient: sfn.NewFromConfig(awsCfg), DynamoDBClient: dynamodb.NewFromConfig(awsCfg), + BackupClient: backup.NewFromConfig(awsCfg), APIGatewayV2Client: apigatewayv2.NewFromConfig(awsCfg), WAFV2Client: wafv2.NewFromConfig(awsCfg),