diff --git a/README.md b/README.md index 02cb089..de0304d 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ checks: | ECS Exec | `r` refresh, `Enter` drill down / exec | | ECS Rollout / Exec | cluster/service lists support refresh and drill-down, service detail shows deployments/task definition images/events, `Enter` continues into tasks and exec | | EKS Browser | cluster/node group/add-on lists support `/` filter and `r` refresh, cluster view shows version/status/endpoint visibility/ARN summary, `a` opens managed add-ons, `U` opens current-version upgrade readiness, `u` opens kubeconfig access helper, node group detail shows desired/min/max scaling plus health issues | -| FIS | `Enter` template detail/run detail, `/` filter, `h` selected-template history, `H` all experiment history, `r` refresh, detail scrolls through targets/actions/stop conditions | +| FIS | `Enter` template detail/run detail, `/` filter, `h` selected-template history, `H` all experiment history, `r` refresh, template detail includes safe-run preview and detail scrolls through targets/actions/stop conditions | | Inspector Mode | `i` open mode from the service list, `Enter` open the selected workflow, `l` open the checklist file picker | | Security Inspector | `r` run/rescan, `1`-`5` severity filter, `Enter` finding detail | | Checklist Inspector | `l` load or switch checklist files, `r` run/rerun the loaded checklist, `Enter` result detail | @@ -358,7 +358,7 @@ The EKS Browser includes a managed add-on status view for each cluster. Add-on r The ECR Repository Browser opens image/tag lists from each repository. Image rows include tags, digest, pushed time, and size, and mark untagged images or images older than 90 days as cleanup candidates. Image detail exposes digest and tag values for clipboard copy. -The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata. +The FIS Experiment Template Browser lists experiment templates in the active region and opens a detail screen with role ARN, targets, actions, target mappings, parameters, filters, and stop condition summaries without leaving the TUI. Template detail includes a Safe Run Preview that summarizes blast radius, target selection modes, action count, active stop conditions, IAM role, and warnings for missing stop conditions, missing role ARN, broad selection, or unbounded selectors. The preview also states the template ID that any future execution path must type to confirm before a run can start. Press `h` on a selected template or template detail to inspect recent runs for that template, or `H` from the template list to inspect recent experiment history across the active account/region. History rows include run status, timing, and stop/failure summaries, with failed, stopped, stopping, and cancelled runs visually highlighted; `Enter` opens run detail with start/end times, duration, action states, targets, stop conditions, and failure metadata. The EKS Browser includes a current-version upgrade readiness view for each selected cluster. It compares the control plane version with managed node group versions, checks installed managed add-on versions against EKS compatibility metadata for the current cluster version, includes EKS `UPGRADE_READINESS` insights, and highlights blockers or warnings before planning a target-version upgrade. diff --git a/docs/architecture.en.md b/docs/architecture.en.md index f605fe8..4090c5f 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -211,7 +211,7 @@ Current screen families include: - ECS cluster/service/rollout detail/task/container flows - EKS cluster/node group/add-on status, upgrade readiness, and access helper flows - ECR repository/image/detail flows -- FIS experiment template list/detail and experiment history/detail flows +- FIS experiment template list/detail, safe-run preview, and experiment history/detail flows - S3 bucket/object/detail flows - Inspector mode home, checklist setup, security findings/detail, and checklist results/detail flows - context picker, context add, and TUI-native context setup/export/unset flows diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index 9f728bf..f5acbbe 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -211,7 +211,7 @@ UNIC은 현재 세 가지 인증 모드를 지원한다. - ECS cluster/service/rollout detail/task/container - EKS cluster/node group/add-on status, upgrade readiness, access helper - ECR repository/image/detail -- FIS experiment template list/detail 및 experiment history/detail +- FIS experiment template list/detail, safe-run preview 및 experiment history/detail - S3 bucket/object/detail - Inspector mode home, checklist setup, security findings/detail, checklist results/detail - context picker, context add, TUI-native context setup/export/unset diff --git a/docs/project-overview.en.md b/docs/project-overview.en.md index 32c26c1..367a1b9 100644 --- a/docs/project-overview.en.md +++ b/docs/project-overview.en.md @@ -25,7 +25,7 @@ Implemented service areas currently include: - Lambda - Inspector mode -The application already includes interactive mutation flows, polling-based status flows, context helpers, and per-service drill-down screens. 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 targets, actions, role ARN, stop condition summaries, and recent experiment history with status, timing, and failure/stop reasons. +The application already includes interactive mutation flows, polling-based status flows, context helpers, and per-service drill-down screens. 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. Inspector mode now includes built-in security scans plus checklist-driven readiness checks for RDS, security groups, secrets, Route53, VPCs/subnets, CloudWatch Logs, and baseline posture wrappers. ## Primary User Flows diff --git a/docs/project-overview.ko.md b/docs/project-overview.ko.md index 0382a6e..7ae6fff 100644 --- a/docs/project-overview.ko.md +++ b/docs/project-overview.ko.md @@ -25,7 +25,7 @@ UNIC은 다음 세 가지를 결합한 Go 기반 AWS 터미널 콘솔이다. - Lambda - Inspector mode -애플리케이션은 이미 상호작용형 변경 작업 플로우, polling 기반 상태 확인, context helper, 서비스별 drill-down 화면을 포함한다. 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 목록과 target, action, role ARN, stop condition 요약 상세 화면에 더해 최근 experiment history의 상태, 시간, failure/stop reason을 보여준다. +애플리케이션은 이미 상호작용형 변경 작업 플로우, polling 기반 상태 확인, context helper, 서비스별 drill-down 화면을 포함한다. 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을 보여준다. Inspector mode는 이제 built-in security scan과 함께 RDS, security group, secret, Route53, VPC/subnet, CloudWatch Logs, baseline posture wrapper를 다루는 checklist 기반 readiness check도 포함한다. ## 주요 사용자 흐름 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index dae5695..b4acb12 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1150,7 +1150,7 @@ func TestFISTemplateDetailViewShowsTargetsActionsAndStops(t *testing.T) { Description: "Terminate application targets", RoleARN: "arn:aws:iam::123456789012:role/fis-role", Targets: []awsservice.FISTemplateTarget{ - {Name: "instances", ResourceType: "aws:ec2:instance", SelectionMode: "COUNT(1)"}, + {Name: "instances", ResourceType: "aws:ec2:instance", SelectionMode: "COUNT(1)", ResourceTags: map[string]string{"env": "dev"}}, }, Actions: []awsservice.FISTemplateAction{ {Name: "stop", ActionID: "aws:ec2:stop-instances"}, @@ -1161,7 +1161,32 @@ func TestFISTemplateDetailViewShowsTargetsActionsAndStops(t *testing.T) { } view := stripANSI(m.View()) - for _, want := range []string{"FIS Experiment Template Detail", "Role ARN", "Targets", "aws:ec2:instance", "Actions", "aws:ec2:stop-instances", "Stop Conditions", "fis-stop"} { + for _, want := range []string{"FIS Experiment Template Detail", "Role ARN", "Safe Run Preview", "Risk", "guarded", "Blast radius", "Future execution must type", "Targets", "aws:ec2:instance", "Actions", "aws:ec2:stop-instances", "Stop Conditions", "fis-stop"} { + if !strings.Contains(view, want) { + t.Fatalf("expected view to contain %q, got %q", want, view) + } + } +} + +func TestFISTemplateDetailViewShowsSafetyWarnings(t *testing.T) { + m := New(testConfig(), "", "dev") + m.screen = screenFISTemplateDetail + m.height = 40 + m.fis.selectedTemplate = &awsservice.FISExperimentTemplate{ + ID: "broad-outage", + Targets: []awsservice.FISTemplateTarget{ + {Name: "instances", ResourceType: "aws:ec2:instance", SelectionMode: "ALL"}, + }, + Actions: []awsservice.FISTemplateAction{ + {Name: "terminate", ActionID: "aws:ec2:terminate-instances"}, + }, + StopConditions: []awsservice.FISTemplateStopCondition{ + {Source: "none"}, + }, + } + + view := stripANSI(m.View()) + for _, want := range []string{"Safe Run Preview", "review required", "Missing IAM role ARN", "No active stop conditions configured", "Broad target selection", "Unbounded target selector", "Future execution must type \"broad-outage\""} { if !strings.Contains(view, want) { t.Fatalf("expected view to contain %q, got %q", want, view) } diff --git a/internal/app/help.go b/internal/app/help.go index af8baeb..d7cecb0 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -485,7 +485,7 @@ func (m Model) currentScreenShortcuts() []helpShortcut { }, shortcuts[3:]...)...) case screenFISTemplateDetail: return []helpShortcut{ - {"↑/↓, j/k", "Scroll template targets, actions, and stop conditions"}, + {"↑/↓, j/k", "Scroll safe-run preview, targets, actions, and stop conditions"}, {"pgup / pgdn", "Scroll by one page"}, {"h", "Open experiment history for this template"}, {"r", "Refresh template detail"}, diff --git a/internal/app/screen_fis.go b/internal/app/screen_fis.go index cf12367..d4802b4 100644 --- a/internal/app/screen_fis.go +++ b/internal/app/screen_fis.go @@ -491,6 +491,12 @@ func (fm fisModel) viewExperimentDetail(m Model) string { } func (fm fisModel) templateDetailLines(template awsservice.FISExperimentTemplate) []string { + preview := template.SafeRunPreview() + risk := successStyle.Render(preview.RiskLevel) + if preview.HasWarnings() { + risk = warningStyle.Render(preview.RiskLevel) + } + lines := []string{ renderDetailLine("ID", normalStyle.Render(template.ID)), renderDetailLine("Description", normalStyle.Render(defaultDash(template.Description))), @@ -505,6 +511,31 @@ func (fm fisModel) templateDetailLines(template awsservice.FISExperimentTemplate } lines = append(lines, renderDetailLine("Tags", normalStyle.Render(defaultDash(formatDetailMap(template.Tags))))) + lines = append(lines, "", selectedStyle.Render("Safe Run Preview")) + lines = append(lines, renderDetailLine("Risk", risk)) + lines = append(lines, renderDetailLine("Targets", normalStyle.Render(fmt.Sprintf("%d target group(s)", preview.TargetCount)))) + lines = append(lines, renderDetailLine("Actions", normalStyle.Render(fmt.Sprintf("%d action(s)", preview.ActionCount)))) + lines = append(lines, renderDetailLine("Stop Conditions", normalStyle.Render(fmt.Sprintf("%d active", preview.StopConditionCount)))) + lines = append(lines, renderDetailLine("IAM Role", normalStyle.Render(defaultDash(template.RoleARN)))) + if len(preview.TargetModes) > 0 { + lines = append(lines, renderDetailLine("Selection Modes", normalStyle.Render(strings.Join(preview.TargetModes, ", ")))) + } + if len(preview.TargetSummaries) > 0 { + lines = append(lines, " "+dimStyle.Render("Blast radius")) + for _, summary := range preview.TargetSummaries { + lines = append(lines, " "+normalStyle.Render(summary)) + } + } + if len(preview.Warnings) == 0 { + lines = append(lines, " "+successStyle.Render("No obvious missing safeguards detected")) + } else { + lines = append(lines, " "+warningStyle.Render("Review before any future run")) + for _, warning := range preview.Warnings { + lines = append(lines, " "+warningStyle.Render(warning)) + } + } + lines = append(lines, " "+dimStyle.Render(fmt.Sprintf("Future execution must type %q to confirm.", preview.ConfirmationToken))) + lines = append(lines, "", selectedStyle.Render("Targets")) if len(template.Targets) == 0 { lines = append(lines, dimStyle.Render(" No targets")) diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index d402c52..9e9f851 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -110,7 +110,7 @@ func Catalog() []Service { Features: []Feature{ { Kind: FeatureFISTemplateBrowser, - Description: "Browse FIS experiment templates and recent experiment history with status, timing, and stop reasons", + Description: "Browse FIS experiment templates, safe-run preview, and recent experiment history", }, }, }, diff --git a/internal/services/aws/fis_model.go b/internal/services/aws/fis_model.go index 8ff06a6..52ca5c0 100644 --- a/internal/services/aws/fis_model.go +++ b/internal/services/aws/fis_model.go @@ -20,6 +20,21 @@ type FISExperimentTemplate struct { StopConditions []FISTemplateStopCondition } +type FISSafeRunPreview struct { + RiskLevel string + TargetCount int + ActionCount int + StopConditionCount int + TargetModes []string + TargetSummaries []string + Warnings []string + ConfirmationToken string +} + +func (p FISSafeRunPreview) HasWarnings() bool { + return len(p.Warnings) > 0 +} + type FISExperiment struct { ID string ARN string @@ -146,6 +161,56 @@ func (t FISExperimentTemplate) DisplayTitle() string { return fmt.Sprintf("%s %s", t.ID, description) } +func (t FISExperimentTemplate) SafeRunPreview() FISSafeRunPreview { + preview := FISSafeRunPreview{ + RiskLevel: "guarded", + TargetCount: len(t.Targets), + ActionCount: len(t.Actions), + ConfirmationToken: t.ID, + } + + stopConditionCount := 0 + for _, condition := range t.StopConditions { + if !condition.IsNone() { + stopConditionCount++ + } + } + preview.StopConditionCount = stopConditionCount + + if strings.TrimSpace(t.RoleARN) == "" { + preview.Warnings = append(preview.Warnings, "Missing IAM role ARN") + } + if preview.TargetCount == 0 { + preview.Warnings = append(preview.Warnings, "No experiment targets configured") + } + if preview.ActionCount == 0 { + preview.Warnings = append(preview.Warnings, "No experiment actions configured") + } + if preview.StopConditionCount == 0 { + preview.Warnings = append(preview.Warnings, "No active stop conditions configured") + } + + for _, target := range t.Targets { + mode := defaultString(target.SelectionMode, "-") + preview.TargetModes = append(preview.TargetModes, fmt.Sprintf("%s:%s", defaultString(target.Name, "-"), mode)) + preview.TargetSummaries = append(preview.TargetSummaries, target.BlastRadiusSummary()) + if target.IsBroadSelection() { + preview.Warnings = append(preview.Warnings, fmt.Sprintf("Broad target selection: %s uses %s", defaultString(target.Name, "-"), mode)) + } + if !target.HasTargetConstraint() { + preview.Warnings = append(preview.Warnings, fmt.Sprintf("Unbounded target selector: %s has no ARNs, tags, filters, or parameters", defaultString(target.Name, "-"))) + } + } + sort.Strings(preview.TargetModes) + sort.Strings(preview.TargetSummaries) + sort.Strings(preview.Warnings) + + if len(preview.Warnings) > 0 { + preview.RiskLevel = "review required" + } + return preview +} + func (t FISExperimentTemplate) FilterText() string { parts := []string{t.ID, t.ARN, t.Description, t.RoleARN, formatStringMap(t.Tags)} for _, target := range t.Targets { @@ -181,6 +246,36 @@ func (t FISTemplateTarget) Summary() string { return strings.Join(nonEmptyStrings(parts), " ") } +func (t FISTemplateTarget) BlastRadiusSummary() string { + parts := []string{ + defaultString(t.Name, "-"), + defaultString(t.ResourceType, "-"), + "mode:" + defaultString(t.SelectionMode, "-"), + } + if len(t.ResourceARNs) > 0 { + parts = append(parts, fmt.Sprintf("arns:%d", len(t.ResourceARNs))) + } + if len(t.ResourceTags) > 0 { + parts = append(parts, fmt.Sprintf("tags:%d", len(t.ResourceTags))) + } + if len(t.Filters) > 0 { + parts = append(parts, fmt.Sprintf("filters:%d", len(t.Filters))) + } + if len(t.Parameters) > 0 { + parts = append(parts, fmt.Sprintf("parameters:%d", len(t.Parameters))) + } + return strings.Join(nonEmptyStrings(parts), " ") +} + +func (t FISTemplateTarget) HasTargetConstraint() bool { + return len(t.ResourceARNs) > 0 || len(t.ResourceTags) > 0 || len(t.Filters) > 0 || len(t.Parameters) > 0 +} + +func (t FISTemplateTarget) IsBroadSelection() bool { + mode := strings.ToUpper(strings.TrimSpace(t.SelectionMode)) + return mode == "ALL" || mode == "PERCENT(100)" || mode == "COUNT(0)" +} + func (t FISTemplateTarget) FilterText() string { parts := []string{t.Name, t.ResourceType, t.SelectionMode, strings.Join(t.ResourceARNs, " "), formatStringMap(t.ResourceTags), formatStringMap(t.Parameters)} for _, filter := range t.Filters { @@ -245,6 +340,11 @@ func (s FISTemplateStopCondition) Summary() string { return fmt.Sprintf("%s %s", s.Source, s.Value) } +func (s FISTemplateStopCondition) IsNone() bool { + source := strings.TrimSpace(s.Source) + return source == "" || strings.EqualFold(source, "none") +} + func (s FISTemplateStopCondition) FilterText() string { return s.Source + " " + s.Value } diff --git a/internal/services/aws/fis_test.go b/internal/services/aws/fis_test.go index fd7a700..21fae7b 100644 --- a/internal/services/aws/fis_test.go +++ b/internal/services/aws/fis_test.go @@ -157,6 +157,95 @@ func TestGetFISExperimentTemplateMapsDetail(t *testing.T) { } } +func TestFISExperimentTemplateSafeRunPreviewGuarded(t *testing.T) { + template := FISExperimentTemplate{ + ID: "app-outage", + RoleARN: "arn:aws:iam::123456789012:role/fis-role", + Targets: []FISTemplateTarget{ + { + Name: "instances", + ResourceType: "aws:ec2:instance", + SelectionMode: "COUNT(1)", + ResourceTags: map[string]string{ + "env": "dev", + }, + }, + }, + Actions: []FISTemplateAction{ + {Name: "stop", ActionID: "aws:ec2:stop-instances"}, + }, + StopConditions: []FISTemplateStopCondition{ + {Source: "aws:cloudwatch:alarm", Value: "arn:aws:cloudwatch:us-east-1:123456789012:alarm:fis-stop"}, + }, + } + + preview := template.SafeRunPreview() + if preview.RiskLevel != "guarded" { + t.Fatalf("expected guarded risk, got %q with warnings %#v", preview.RiskLevel, preview.Warnings) + } + if preview.TargetCount != 1 || preview.ActionCount != 1 || preview.StopConditionCount != 1 { + t.Fatalf("unexpected preview counts: %#v", preview) + } + if preview.HasWarnings() { + t.Fatalf("expected no warnings, got %#v", preview.Warnings) + } +} + +func TestFISExperimentTemplateSafeRunPreviewFlagsUnsafeState(t *testing.T) { + template := FISExperimentTemplate{ + ID: "broad-outage", + Targets: []FISTemplateTarget{ + { + Name: "instances", + ResourceType: "aws:ec2:instance", + SelectionMode: "ALL", + }, + }, + Actions: []FISTemplateAction{ + {Name: "terminate", ActionID: "aws:ec2:terminate-instances"}, + }, + StopConditions: []FISTemplateStopCondition{ + {Source: "none"}, + }, + } + + preview := template.SafeRunPreview() + if preview.RiskLevel != "review required" { + t.Fatalf("expected review required risk, got %q", preview.RiskLevel) + } + for _, want := range []string{"Missing IAM role ARN", "No active stop conditions configured", "Broad target selection", "Unbounded target selector"} { + found := false + for _, warning := range preview.Warnings { + if strings.Contains(warning, want) { + found = true + break + } + } + if !found { + t.Fatalf("expected warning containing %q, got %#v", want, preview.Warnings) + } + } + if preview.StopConditionCount != 0 { + t.Fatalf("expected none stop condition to be inactive, got %d", preview.StopConditionCount) + } +} + +func TestFISTemplateStopConditionIsNoneTreatsBlankSourceAsInactive(t *testing.T) { + for _, condition := range []FISTemplateStopCondition{ + {}, + {Source: " "}, + {Source: "none"}, + {Source: "NONE"}, + } { + if !condition.IsNone() { + t.Fatalf("expected %#v to be inactive", condition) + } + } + if (FISTemplateStopCondition{Source: "aws:cloudwatch:alarm"}).IsNone() { + t.Fatal("expected CloudWatch alarm stop condition to be active") + } +} + func TestListFISExperimentTemplatesError(t *testing.T) { mock := &mockFISClient{ listExperimentTemplatesFunc: func(_ context.Context, _ *fis.ListExperimentTemplatesInput, _ ...func(*fis.Options)) (*fis.ListExperimentTemplatesOutput, error) {