From a8d382395b8dffaae676fea91c5538ded90625e5 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Thu, 13 Aug 2026 17:52:48 +0900 Subject: [PATCH 1/2] feat: add RDS instance class modification Add a modify-class action to RDS instance detail (`m`). unic loads the orderable DB instance classes for the instance's engine/version in the active region into a filterable picker that marks the current class, then a confirmation screen shows current vs. new class with an explicit apply-immediately toggle (Tab, default: next maintenance window) and requires typing the instance identifier before calling ModifyDBInstance. Class modification is instance-level even for Aurora cluster members, and the detail screen polls status after submitting. Closes #175 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFhLrnpVxivu62cC3k9NZB --- README.md | 5 +- internal/app/app.go | 1 + internal/app/help.go | 13 ++ internal/app/messages.go | 5 + internal/app/screen_rds.go | 194 +++++++++++++++++++++++- internal/app/screen_rds_modify_test.go | 111 ++++++++++++++ internal/inspector/test_helpers_test.go | 16 ++ internal/services/aws/rds.go | 49 ++++++ internal/services/aws/rds_test.go | 63 ++++++++ internal/services/aws/repository.go | 2 + 10 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 internal/app/screen_rds_modify_test.go diff --git a/README.md b/README.md index bc1e469..baa4b7f 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ Context ordering: | VPC | VPC Browser | | VPC | Reachability Analyzer | | RDS | RDS Browser | +| RDS | Instance Class Modification | | Route53 | Route53 Browser | | Secrets Manager | Secrets Browser | | CloudWatch | Metrics Viewer | @@ -366,7 +367,7 @@ checks: | EC2 Instance Browser | `r` refresh, `/` filter, `Enter` detail, detail `g/a/t/b/n` opens related security groups/ASG/target groups/load balancers/listeners | | Security Groups | `a` add rule, `d` delete rule, `Tab` switch ingress/egress | | Reachability Analyzer | Region select first, `←`/`→` or `Tab` change type, `/` filter, `Enter` advance, `Tab`/`↑`/`↓` move config fields, `←`/`→` protocol, `r` rerun | -| RDS | `s` start, `x` stop, `f` failover, `r` refresh | +| RDS | `s` start, `x` stop, `f` failover, `m` modify instance class (filterable class picker, `Tab` apply-immediately toggle, type-to-confirm), `r` refresh | | Route53 | `c` create, `e` edit, `d` delete | | IAM Key Rotation | `r` rotate, `c` copy exports, `a` apply and verify, `d` deactivate old key, `x` delete old key | | Bedrock API Keys | `c` create, choose current IAM user or another user, `r` rotate secret, `d` delete, type the IAM user/key ID to confirm, `c` copy one-time key without printing it, `e` copy `AWS_BEARER_TOKEN_BEDROCK` export | @@ -391,6 +392,8 @@ 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 RDS detail screen can resize an instance with `m`: unic loads the orderable DB instance classes for the instance's engine/version in the active region into a filterable picker (current class marked), then a confirmation screen shows the current and new class with a `Tab`-toggleable apply-immediately choice (default: next maintenance window) and requires typing the instance identifier before calling ModifyDBInstance. After submitting, the detail screen polls the instance status until the change settles. + 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/internal/app/app.go b/internal/app/app.go index 389ed57..95e2e67 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,6 +38,7 @@ const ( screenReachabilityResult screenRDSList screenRDSDetail + screenRDSClassPicker screenRDSConfirm screenRoute53ZoneList screenRoute53RecordList diff --git a/internal/app/help.go b/internal/app/help.go index 124d69f..aa7af30 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -225,6 +225,7 @@ func (m Model) currentScreenShortcuts() []helpShortcut { return listScreenShortcuts("open the selected instance", "go back to the feature list", true, false) case screenRDSDetail: shortcuts := []helpShortcut{ + {"m", "Modify the instance class"}, {"r", "Refresh the selected instance status"}, {"q / esc", "Go back to the instance list"}, } @@ -238,6 +239,8 @@ func (m Model) currentScreenShortcuts() []helpShortcut { shortcuts = append([]helpShortcut{{"f", "Trigger failover for the selected instance or cluster"}}, shortcuts...) } return shortcuts + case screenRDSClassPicker: + return listScreenShortcuts("choose the class and continue to confirmation", "go back to the instance detail", true, false) case screenRDSConfirm: if m.rds.action == "start" { return []helpShortcut{ @@ -245,6 +248,14 @@ func (m Model) currentScreenShortcuts() []helpShortcut { {"n / esc", "Cancel and return to the detail screen"}, } } + if m.rds.action == "modify" { + return []helpShortcut{ + {"type", "Enter the instance identifier to confirm"}, + {"tab", "Toggle apply-immediately"}, + {"enter", "Confirm the class modification"}, + {"esc", "Go back to the class picker"}, + } + } target := "instance identifier" if m.rds.selected != nil && m.rds.selected.IsClusterMember() { target = "cluster identifier" @@ -834,6 +845,8 @@ func (m Model) helpScreenTitle() string { return "RDS Instances" case screenRDSDetail: return "RDS Detail" + case screenRDSClassPicker: + return "RDS Instance Class Picker" case screenRDSConfirm: return "RDS Confirmation" case screenRoute53ZoneList: diff --git a/internal/app/messages.go b/internal/app/messages.go index 7865d1b..8aed0bd 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -86,6 +86,11 @@ type rdsInstancesLoadedMsg struct { instances []awsservice.RDSInstance } +type rdsClassesLoadedMsg struct { + instanceID string + classes []string +} + type rdsActionDoneMsg struct { action string instanceID string diff --git a/internal/app/screen_rds.go b/internal/app/screen_rds.go index 5fd7a51..9e6adda 100644 --- a/internal/app/screen_rds.go +++ b/internal/app/screen_rds.go @@ -16,9 +16,18 @@ type rdsModel struct { filtered []awsservice.RDSInstance idx int selected *awsservice.RDSInstance - action string // "start", "stop", "failover" + action string // "start", "stop", "failover", "modify" confirmInput string // typed input for destructive action confirmation polling bool + + // Instance class modification + classes []string + filteredClasses []string + classIdx int + classFilter string + classFiltering bool + pendingClass string + applyImmediately bool } func newRDSModel() rdsModel { @@ -38,6 +47,18 @@ func (rm *rdsModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bo m.screen = screenRDSList return *m, nil, true + case rdsClassesLoadedMsg: + if rm.selected == nil || rm.selected.DBInstanceID != msg.instanceID { + return *m, nil, true + } + rm.classes = msg.classes + rm.filteredClasses = msg.classes + rm.classIdx = 0 + rm.classFilter = "" + rm.classFiltering = false + m.screen = screenRDSClassPicker + return *m, nil, true + case rdsActionDoneMsg: if msg.err != nil { m.errMsg = msg.err.Error() @@ -85,6 +106,9 @@ func (rm *rdsModel) HandleKey(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd, boo case screenRDSDetail: newM, cmd := rm.updateDetail(m, msg) return newM, cmd, true + case screenRDSClassPicker: + newM, cmd := rm.updateClassPicker(m, msg) + return newM, cmd, true case screenRDSConfirm: newM, cmd := rm.updateConfirm(m, msg) return newM, cmd, true @@ -99,6 +123,8 @@ func (rm rdsModel) View(m Model) (string, bool) { return rm.viewList(m), true case screenRDSDetail: return rm.viewDetail(m), true + case screenRDSClassPicker: + return rm.viewClassPicker(m), true case screenRDSConfirm: return rm.viewConfirm(m), true default: @@ -165,6 +191,14 @@ func (rm *rdsModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) rm.confirmInput = "" m.screen = screenRDSConfirm } + case "m": + if rm.selected != nil { + return m.startLoadingWithMessage( + "Loading instance classes...", + []string{rm.selected.DBInstanceID, fmt.Sprintf("engine=%s %s", rm.selected.Engine, rm.selected.EngineVersion)}, + rm.loadClasses(*m), + ) + } case "r": if rm.selected != nil { return *m, rm.pollStatus(*m, rm.selected.DBInstanceID) @@ -173,6 +207,45 @@ func (rm *rdsModel) updateDetail(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) return *m, nil } +func (rm *rdsModel) updateClassPicker(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if rm.classFiltering { + newFilter, deactivate, changed := handleFilterKey(key, rm.classFilter) + rm.classFilter = newFilter + if deactivate { + rm.classFiltering = false + } + if changed { + rm.filteredClasses = applyStringFilter(rm.classes, rm.classFilter) + rm.classIdx = 0 + } + if !isFilterNavigationKey(key) { + return *m, nil + } + } + + switch key { + case "q", "esc": + m.screen = screenRDSDetail + case "up", "k": + rm.classIdx = previousListIndex(rm.classIdx, len(rm.filteredClasses)) + case "down", "j": + rm.classIdx = nextListIndex(rm.classIdx, len(rm.filteredClasses)) + case "/": + rm.classFiltering = true + case "enter": + if len(rm.filteredClasses) == 0 || rm.classIdx >= len(rm.filteredClasses) { + return *m, nil + } + rm.pendingClass = rm.filteredClasses[rm.classIdx] + rm.applyImmediately = false + rm.action = "modify" + rm.confirmInput = "" + m.screen = screenRDSConfirm + } + return *m, nil +} + func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Start action uses simple y/n confirmation if rm.action == "start" { @@ -190,9 +263,10 @@ func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) // Stop/failover require typing the identifier to confirm // For Aurora cluster members, confirm with cluster ID; for standalone, instance ID + // Class modification is instance-level, so it always confirms with the instance ID confirmTarget := "" if rm.selected != nil { - if rm.selected.IsClusterMember() { + if rm.selected.IsClusterMember() && rm.action != "modify" { confirmTarget = rm.selected.ClusterID } else { confirmTarget = rm.selected.DBInstanceID @@ -200,7 +274,16 @@ func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) } switch msg.String() { case "esc": + if rm.action == "modify" { + m.screen = screenRDSClassPicker + return *m, nil + } m.screen = screenRDSDetail + case "tab": + if rm.action == "modify" { + rm.applyImmediately = !rm.applyImmediately + return *m, nil + } case "enter": if rm.selected != nil && rm.confirmInput == confirmTarget { m.screen = screenRDSDetail @@ -218,6 +301,30 @@ func (rm *rdsModel) updateConfirm(m *Model, msg tea.KeyMsg) (tea.Model, tea.Cmd) return *m, nil } +func (rm rdsModel) loadClasses(m Model) tea.Cmd { + instance := *rm.selected + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + } + + classes, err := repo.ListOrderableDBInstanceClasses(ctx, instance.Engine, instance.EngineVersion) + if err != nil { + return errMsg{err: err} + } + if len(classes) == 0 { + return errMsg{err: fmt.Errorf("no orderable instance classes found for %s %s in this region", instance.Engine, instance.EngineVersion)} + } + return rdsClassesLoadedMsg{instanceID: instance.DBInstanceID, classes: classes} + } +} + func (rm rdsModel) loadInstances(m Model) tea.Cmd { return func() tea.Msg { ctx := context.Background() @@ -243,6 +350,8 @@ func (rm rdsModel) executeAction(m Model, action, dbInstanceID string) tea.Cmd { if rm.selected != nil { clusterID = rm.selected.ClusterID } + pendingClass := rm.pendingClass + applyImmediately := rm.applyImmediately return func() tea.Msg { ctx := context.Background() repo := m.awsRepo @@ -255,6 +364,11 @@ func (rm rdsModel) executeAction(m Model, action, dbInstanceID string) tea.Cmd { } var err error + if action == "modify" { + // Class modification is always instance-level, even for cluster members. + err = repo.ModifyDBInstanceClass(ctx, dbInstanceID, pendingClass, applyImmediately) + return rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} + } if clusterID != "" { // Aurora cluster-level actions switch action { @@ -434,6 +548,63 @@ func (rm rdsModel) viewDetail(m Model) string { return b.String() } +func (rm rdsModel) viewClassPicker(m Model) string { + var b strings.Builder + var panel strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Select Instance Class")) + b.WriteString("\n") + if rm.selected != nil { + b.WriteString(dimStyle.Render(fmt.Sprintf(" Instance: %s current: %s engine: %s %s", + rm.selected.DBInstanceID, rm.selected.InstanceClass, rm.selected.Engine, rm.selected.EngineVersion))) + b.WriteString("\n") + } + if rm.classFiltering || rm.classFilter != "" { + b.WriteString(filterStyle.Render(fmt.Sprintf(" /%s▏", rm.classFilter))) + b.WriteString("\n") + } + b.WriteString("\n") + + if len(rm.filteredClasses) == 0 { + emptyText := " No instance classes found" + if len(rm.classes) > 0 { + emptyText = " No matching instance classes" + } + panel.WriteString(dimStyle.Render(emptyText)) + panel.WriteString("\n") + } else { + visibleLines := max(m.height-11, 5) + start := 0 + if rm.classIdx >= visibleLines { + start = rm.classIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(rm.filteredClasses)) + for i := start; i < end; i++ { + class := rm.filteredClasses[i] + cursor := " " + style := normalStyle + marker := "" + if rm.selected != nil && class == rm.selected.InstanceClass { + marker = " (current)" + } + if i == rm.classIdx { + cursor = "> " + style = selectedStyle + } + panel.WriteString(style.Render(cursor + class)) + panel.WriteString(dimStyle.Render(marker)) + panel.WriteString("\n") + } + panel.WriteString("\n") + panel.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d classes", len(rm.filteredClasses), len(rm.classes)))) + } + + b.WriteString(m.renderListPanel(panel.String())) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("↑/↓: navigate • /: filter • enter: choose • esc: back")) + return b.String() +} + func (rm rdsModel) viewConfirm(m Model) string { if rm.selected == nil { return "" @@ -459,6 +630,25 @@ func (rm rdsModel) viewConfirm(m Model) string { b.WriteString("\n\n") b.WriteString(normalStyle.Render(" [y] Yes [n] No")) b.WriteString("\n") + } else if rm.action == "modify" { + b.WriteString(normalStyle.Render(" You are about to modify the instance class of:")) + b.WriteString("\n") + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", r.DBInstanceID))) + b.WriteString("\n\n") + b.WriteString(m.renderEC2DetailLine("Current class", ec2ValueOrDash(r.InstanceClass))) + b.WriteString(m.renderEC2DetailLine("New class", ec2ValueOrDash(rm.pendingClass))) + applyLabel := "no (next maintenance window)" + if rm.applyImmediately { + applyLabel = "yes (may cause downtime now)" + } + b.WriteString(m.renderEC2DetailLine("Apply immediately", applyLabel)) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" Type the instance identifier to confirm:")) + b.WriteString("\n") + b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", rm.confirmInput))) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar("tab: toggle apply immediately • enter: confirm • esc: back")) + b.WriteString("\n") } else { b.WriteString(normalStyle.Render(fmt.Sprintf(" You are about to %s %s:", rm.action, targetLabel))) b.WriteString("\n") diff --git a/internal/app/screen_rds_modify_test.go b/internal/app/screen_rds_modify_test.go new file mode 100644 index 0000000..c7d76dd --- /dev/null +++ b/internal/app/screen_rds_modify_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +func rdsModifyTestModel() Model { + m := Model{screen: screenRDSDetail, cfg: &config.Config{Region: "us-east-1"}} + m.rds = newRDSModel() + m.rds.selected = &awsservice.RDSInstance{ + DBInstanceID: "prod-db", + Engine: "postgres", + EngineVersion: "16.3", + InstanceClass: "db.t3.medium", + } + return m +} + +func TestRDSClassesLoadedOpensPicker(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenLoading + + msg := rdsClassesLoadedMsg{ + instanceID: "prod-db", + classes: []string{"db.r6g.large", "db.t3.medium", "db.t3.large"}, + } + _, _, handled := m.rds.HandleMessage(&m, msg) + if !handled || m.screen != screenRDSClassPicker { + t.Fatalf("expected class picker screen, got %v handled=%v", m.screen, handled) + } + + view, ok := m.rds.View(m) + if !ok || !strings.Contains(view, "db.t3.medium") || !strings.Contains(view, "(current)") { + t.Fatalf("expected picker view with current marker, got:\n%s", view) + } +} + +func TestRDSClassPickerFilterAndSelect(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSClassPicker + m.rds.classes = []string{"db.r6g.large", "db.t3.medium", "db.t3.large"} + m.rds.filteredClasses = m.rds.classes + + m.rds.classFiltering = true + m.rds.updateClassPicker(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m.rds.updateClassPicker(&m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'6'}}) + if len(m.rds.filteredClasses) != 1 || m.rds.filteredClasses[0] != "db.r6g.large" { + t.Fatalf("expected filter to narrow classes, got %+v", m.rds.filteredClasses) + } + + m.rds.classFiltering = false + m.rds.updateClassPicker(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.rds.pendingClass != "db.r6g.large" || m.rds.action != "modify" { + t.Fatalf("expected pending class selection, got %q action=%q", m.rds.pendingClass, m.rds.action) + } + if m.rds.applyImmediately { + t.Fatal("expected apply-immediately to default to false") + } + if m.screen != screenRDSConfirm { + t.Fatalf("expected confirm screen, got %v", m.screen) + } +} + +func TestRDSModifyConfirmRequiresInstanceIDAndTogglesApply(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSConfirm + m.rds.action = "modify" + m.rds.pendingClass = "db.r6g.large" + + m.rds.updateConfirm(&m, tea.KeyMsg{Type: tea.KeyTab}) + if !m.rds.applyImmediately { + t.Fatal("expected tab to toggle apply-immediately on") + } + + // Wrong confirmation input must not execute + m.rds.confirmInput = "wrong" + _, cmd := m.rds.updateConfirm(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil || m.screen != screenRDSConfirm { + t.Fatal("expected wrong identifier to be rejected") + } + + m.rds.confirmInput = "prod-db" + _, cmd = m.rds.updateConfirm(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil || m.screen != screenRDSDetail { + t.Fatalf("expected confirmed modify to execute, screen=%v", m.screen) + } + + view := m.rds.viewConfirm(m) + for _, want := range []string{"db.t3.medium", "db.r6g.large", "yes (may cause downtime now)"} { + if !strings.Contains(view, want) { + t.Fatalf("expected confirm view to contain %q, got:\n%s", want, view) + } + } +} + +func TestRDSModifyConfirmEscReturnsToPicker(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSConfirm + m.rds.action = "modify" + + m.rds.updateConfirm(&m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.screen != screenRDSClassPicker { + t.Fatalf("expected esc to return to the class picker, got %v", m.screen) + } +} diff --git a/internal/inspector/test_helpers_test.go b/internal/inspector/test_helpers_test.go index a780aee..d4641d5 100644 --- a/internal/inspector/test_helpers_test.go +++ b/internal/inspector/test_helpers_test.go @@ -84,6 +84,22 @@ type mockRDSClient struct { stopDBClusterFunc func(ctx context.Context, params *rds.StopDBClusterInput, optFns ...func(*rds.Options)) (*rds.StopDBClusterOutput, error) startDBClusterFunc func(ctx context.Context, params *rds.StartDBClusterInput, optFns ...func(*rds.Options)) (*rds.StartDBClusterOutput, error) failoverDBClusterFunc func(ctx context.Context, params *rds.FailoverDBClusterInput, optFns ...func(*rds.Options)) (*rds.FailoverDBClusterOutput, error) + describeOrderableOptionsFunc func(ctx context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, optFns ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) + modifyDBInstanceFunc func(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) +} + +func (m *mockRDSClient) DescribeOrderableDBInstanceOptions(ctx context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, optFns ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) { + if m.describeOrderableOptionsFunc != nil { + return m.describeOrderableOptionsFunc(ctx, params, optFns...) + } + return &rds.DescribeOrderableDBInstanceOptionsOutput{}, nil +} + +func (m *mockRDSClient) ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) { + if m.modifyDBInstanceFunc != nil { + return m.modifyDBInstanceFunc(ctx, params, optFns...) + } + return &rds.ModifyDBInstanceOutput{}, nil } func (m *mockRDSClient) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { diff --git a/internal/services/aws/rds.go b/internal/services/aws/rds.go index 5fd144a..5b3b30e 100644 --- a/internal/services/aws/rds.go +++ b/internal/services/aws/rds.go @@ -159,3 +159,52 @@ func (r *AwsRepository) FailoverDBCluster(ctx context.Context, clusterID string) } return nil } + +// ListOrderableDBInstanceClasses returns the distinct DB instance classes +// orderable for the given engine (and optional engine version) in the active +// region, sorted alphabetically. +func (r *AwsRepository) ListOrderableDBInstanceClasses(ctx context.Context, engine, engineVersion string) ([]string, error) { + uniclog.Debug("aws", "ListOrderableDBInstanceClasses called", "engine", engine, "engine_version", engineVersion) + + input := &rds.DescribeOrderableDBInstanceOptionsInput{Engine: awssdk.String(engine)} + if engineVersion != "" { + input.EngineVersion = awssdk.String(engineVersion) + } + + seen := make(map[string]struct{}) + var classes []string + paginator := rds.NewDescribeOrderableDBInstanceOptionsPaginator(r.RDSClient, input) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list orderable DB instance classes: %w", err) + } + for _, option := range page.OrderableDBInstanceOptions { + class := awssdk.ToString(option.DBInstanceClass) + if class == "" { + continue + } + if _, ok := seen[class]; ok { + continue + } + seen[class] = struct{}{} + classes = append(classes, class) + } + } + sort.Strings(classes) + return classes, nil +} + +// ModifyDBInstanceClass changes the DB instance class of an instance. +func (r *AwsRepository) ModifyDBInstanceClass(ctx context.Context, dbInstanceID, instanceClass string, applyImmediately bool) error { + uniclog.Info("aws", "ModifyDBInstanceClass called", "instance", dbInstanceID, "class", instanceClass, "apply_immediately", applyImmediately) + _, err := r.RDSClient.ModifyDBInstance(ctx, &rds.ModifyDBInstanceInput{ + DBInstanceIdentifier: awssdk.String(dbInstanceID), + DBInstanceClass: awssdk.String(instanceClass), + ApplyImmediately: awssdk.Bool(applyImmediately), + }) + if err != nil { + return fmt.Errorf("failed to modify DB instance class for %s: %w", dbInstanceID, err) + } + return nil +} diff --git a/internal/services/aws/rds_test.go b/internal/services/aws/rds_test.go index 622977f..25b7c51 100644 --- a/internal/services/aws/rds_test.go +++ b/internal/services/aws/rds_test.go @@ -24,6 +24,22 @@ type mockRDSClient struct { stopDBClusterFunc func(ctx context.Context, params *rds.StopDBClusterInput, optFns ...func(*rds.Options)) (*rds.StopDBClusterOutput, error) startDBClusterFunc func(ctx context.Context, params *rds.StartDBClusterInput, optFns ...func(*rds.Options)) (*rds.StartDBClusterOutput, error) failoverDBClusterFunc func(ctx context.Context, params *rds.FailoverDBClusterInput, optFns ...func(*rds.Options)) (*rds.FailoverDBClusterOutput, error) + describeOrderableOptionsFunc func(ctx context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, optFns ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) + modifyDBInstanceFunc func(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) +} + +func (m *mockRDSClient) DescribeOrderableDBInstanceOptions(ctx context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, optFns ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) { + if m.describeOrderableOptionsFunc != nil { + return m.describeOrderableOptionsFunc(ctx, params, optFns...) + } + return &rds.DescribeOrderableDBInstanceOptionsOutput{}, nil +} + +func (m *mockRDSClient) ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) { + if m.modifyDBInstanceFunc != nil { + return m.modifyDBInstanceFunc(ctx, params, optFns...) + } + return &rds.ModifyDBInstanceOutput{}, nil } func (m *mockRDSClient) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { @@ -572,3 +588,50 @@ func TestIsTransitionalStatus(t *testing.T) { } } } + +func TestListOrderableDBInstanceClassesDedupesAndSorts(t *testing.T) { + mock := &mockRDSClient{ + describeOrderableOptionsFunc: func(_ context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, _ ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) { + if awssdk.ToString(params.Engine) != "postgres" || awssdk.ToString(params.EngineVersion) != "16.3" { + t.Fatalf("expected engine filters, got %+v", params) + } + return &rds.DescribeOrderableDBInstanceOptionsOutput{ + OrderableDBInstanceOptions: []rdstypes.OrderableDBInstanceOption{ + {DBInstanceClass: awssdk.String("db.t3.medium")}, + {DBInstanceClass: awssdk.String("db.r6g.large")}, + {DBInstanceClass: awssdk.String("db.t3.medium")}, + {DBInstanceClass: awssdk.String("")}, + }, + }, nil + }, + } + repo := &AwsRepository{RDSClient: mock} + + classes, err := repo.ListOrderableDBInstanceClasses(context.Background(), "postgres", "16.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(classes) != 2 || classes[0] != "db.r6g.large" || classes[1] != "db.t3.medium" { + t.Fatalf("expected deduped sorted classes, got %+v", classes) + } +} + +func TestModifyDBInstanceClassPassesParameters(t *testing.T) { + var got *rds.ModifyDBInstanceInput + mock := &mockRDSClient{ + modifyDBInstanceFunc: func(_ context.Context, params *rds.ModifyDBInstanceInput, _ ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) { + got = params + return &rds.ModifyDBInstanceOutput{}, nil + }, + } + repo := &AwsRepository{RDSClient: mock} + + if err := repo.ModifyDBInstanceClass(context.Background(), "prod-db", "db.r6g.large", true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if awssdk.ToString(got.DBInstanceIdentifier) != "prod-db" || + awssdk.ToString(got.DBInstanceClass) != "db.r6g.large" || + !awssdk.ToBool(got.ApplyImmediately) { + t.Fatalf("expected modify parameters to be passed, got %+v", got) + } +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 73dc364..0c34350 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -110,6 +110,8 @@ type RDSClientAPI interface { DescribeDBSnapshotAttributes(ctx context.Context, params *rds.DescribeDBSnapshotAttributesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBSnapshotAttributesOutput, error) DescribeDBClusterSnapshots(ctx context.Context, params *rds.DescribeDBClusterSnapshotsInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterSnapshotsOutput, error) DescribeDBClusterSnapshotAttributes(ctx context.Context, params *rds.DescribeDBClusterSnapshotAttributesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBClusterSnapshotAttributesOutput, error) + DescribeOrderableDBInstanceOptions(ctx context.Context, params *rds.DescribeOrderableDBInstanceOptionsInput, optFns ...func(*rds.Options)) (*rds.DescribeOrderableDBInstanceOptionsOutput, error) + ModifyDBInstance(ctx context.Context, params *rds.ModifyDBInstanceInput, optFns ...func(*rds.Options)) (*rds.ModifyDBInstanceOutput, error) StopDBInstance(ctx context.Context, params *rds.StopDBInstanceInput, optFns ...func(*rds.Options)) (*rds.StopDBInstanceOutput, error) StartDBInstance(ctx context.Context, params *rds.StartDBInstanceInput, optFns ...func(*rds.Options)) (*rds.StartDBInstanceOutput, error) RebootDBInstance(ctx context.Context, params *rds.RebootDBInstanceInput, optFns ...func(*rds.Options)) (*rds.RebootDBInstanceOutput, error) From 2eb8e97f1f478085649144c69e540ecc539d8d64 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Fri, 14 Aug 2026 09:27:47 +0900 Subject: [PATCH 2/2] fix: track pending class changes and block no-op modifications Address review: RDSInstance now carries PendingModifiedValues' DBInstanceClass. The detail screen shows a Pending Class line so a deferred modification no longer looks complete, and an immediate modify keeps polling until the pending value clears instead of terminating on the first available status. The class picker refuses selecting the instance's current class, closing the no-op ModifyDBInstance path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BFhLrnpVxivu62cC3k9NZB --- internal/app/screen_rds.go | 16 ++++++- internal/app/screen_rds_modify_test.go | 62 ++++++++++++++++++++++++++ internal/services/aws/rds.go | 6 +++ internal/services/aws/rds_model.go | 1 + 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/internal/app/screen_rds.go b/internal/app/screen_rds.go index 9e6adda..22816be 100644 --- a/internal/app/screen_rds.go +++ b/internal/app/screen_rds.go @@ -83,7 +83,10 @@ func (rm *rdsModel) HandleMessage(m *Model, msg tea.Msg) (tea.Model, tea.Cmd, bo } rm.filtered = applyFilter(rm.instances, m.filterValue(filterRDS)) rm.idx = 0 - if awsservice.IsTransitionalStatus(msg.instance.Status) { + // An immediate class modify can briefly report `available` with the + // change still pending; keep polling until the pending value clears. + modifyInFlight := rm.action == "modify" && rm.applyImmediately && msg.instance.PendingInstanceClass != "" + if awsservice.IsTransitionalStatus(msg.instance.Status) || modifyInFlight { return *m, rm.tickPoll(msg.instance.DBInstanceID), true } rm.polling = false @@ -237,7 +240,12 @@ func (rm *rdsModel) updateClassPicker(m *Model, msg tea.KeyMsg) (tea.Model, tea. if len(rm.filteredClasses) == 0 || rm.classIdx >= len(rm.filteredClasses) { return *m, nil } - rm.pendingClass = rm.filteredClasses[rm.classIdx] + chosen := rm.filteredClasses[rm.classIdx] + if rm.selected != nil && chosen == rm.selected.InstanceClass { + // Modifying to the identical class is a no-op API call; refuse it. + return *m, nil + } + rm.pendingClass = chosen rm.applyImmediately = false rm.action = "modify" rm.confirmInput = "" @@ -493,6 +501,10 @@ func (rm rdsModel) viewDetail(m Model) string { b.WriteString(renderDetailLine("Class", normalStyle.Render(r.InstanceClass))) b.WriteString("\n") + if r.PendingInstanceClass != "" { + b.WriteString(renderDetailLine("Pending Class", filterStyle.Render(r.PendingInstanceClass)+dimStyle.Render(" (applies at next maintenance window unless applied immediately)"))) + b.WriteString("\n") + } multiAZStr := "No" if r.MultiAZ { multiAZStr = "Yes" diff --git a/internal/app/screen_rds_modify_test.go b/internal/app/screen_rds_modify_test.go index c7d76dd..aaddde6 100644 --- a/internal/app/screen_rds_modify_test.go +++ b/internal/app/screen_rds_modify_test.go @@ -109,3 +109,65 @@ func TestRDSModifyConfirmEscReturnsToPicker(t *testing.T) { t.Fatalf("expected esc to return to the class picker, got %v", m.screen) } } + +func TestRDSClassPickerRejectsCurrentClass(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSClassPicker + m.rds.classes = []string{"db.t3.medium", "db.t3.large"} + m.rds.filteredClasses = m.rds.classes + m.rds.classIdx = 0 // db.t3.medium == current + + m.rds.updateClassPicker(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.screen != screenRDSClassPicker || m.rds.action == "modify" { + t.Fatalf("expected current-class selection to be a no-op, screen=%v action=%q", m.screen, m.rds.action) + } +} + +func TestRDSImmediateModifyKeepsPollingWhilePending(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSDetail + m.rds.action = "modify" + m.rds.applyImmediately = true + m.rds.polling = true + m.rds.instances = []awsservice.RDSInstance{*m.rds.selected} + + // available but the class change is still pending -> keep polling + pending := *m.rds.selected + pending.Status = "available" + pending.PendingInstanceClass = "db.r6g.large" + _, cmd, _ := m.rds.HandleMessage(&m, rdsStatusRefreshedMsg{instance: &pending}) + if cmd == nil || !m.rds.polling { + t.Fatal("expected polling to continue while an immediate modify is pending") + } + + // pending cleared -> polling stops + settled := pending + settled.PendingInstanceClass = "" + settled.InstanceClass = "db.r6g.large" + _, _, _ = m.rds.HandleMessage(&m, rdsStatusRefreshedMsg{instance: &settled}) + if m.rds.polling { + t.Fatal("expected polling to stop once the pending class clears") + } +} + +func TestRDSDeferredModifyStopsPollingAndShowsPendingClass(t *testing.T) { + m := rdsModifyTestModel() + m.screen = screenRDSDetail + m.rds.action = "modify" + m.rds.applyImmediately = false + m.rds.polling = true + m.rds.instances = []awsservice.RDSInstance{*m.rds.selected} + + deferred := *m.rds.selected + deferred.Status = "available" + deferred.PendingInstanceClass = "db.r6g.large" + _, _, _ = m.rds.HandleMessage(&m, rdsStatusRefreshedMsg{instance: &deferred}) + if m.rds.polling { + t.Fatal("expected deferred modify to stop polling once status is stable") + } + + view := m.rds.viewDetail(m) + if !strings.Contains(view, "db.r6g.large") || !strings.Contains(view, "next maintenance window") { + t.Fatalf("expected pending class line in detail view, got:\n%s", view) + } +} diff --git a/internal/services/aws/rds.go b/internal/services/aws/rds.go index 5b3b30e..e71aa38 100644 --- a/internal/services/aws/rds.go +++ b/internal/services/aws/rds.go @@ -34,6 +34,9 @@ func (r *AwsRepository) ListDBInstances(ctx context.Context) ([]RDSInstance, err BackupRetentionPeriod: awssdk.ToInt32(db.BackupRetentionPeriod), ClusterID: awssdk.ToString(db.DBClusterIdentifier), } + if db.PendingModifiedValues != nil { + inst.PendingInstanceClass = awssdk.ToString(db.PendingModifiedValues.DBInstanceClass) + } // Endpoint may be nil for stopped instances if db.Endpoint != nil { @@ -80,6 +83,9 @@ func (r *AwsRepository) DescribeDBInstance(ctx context.Context, dbInstanceID str BackupRetentionPeriod: awssdk.ToInt32(db.BackupRetentionPeriod), ClusterID: awssdk.ToString(db.DBClusterIdentifier), } + if db.PendingModifiedValues != nil { + inst.PendingInstanceClass = awssdk.ToString(db.PendingModifiedValues.DBInstanceClass) + } if db.Endpoint != nil { inst.Endpoint = fmt.Sprintf("%s:%d", awssdk.ToString(db.Endpoint.Address), awssdk.ToInt32(db.Endpoint.Port)) } diff --git a/internal/services/aws/rds_model.go b/internal/services/aws/rds_model.go index c601b05..d725ac4 100644 --- a/internal/services/aws/rds_model.go +++ b/internal/services/aws/rds_model.go @@ -12,6 +12,7 @@ type RDSInstance struct { EngineVersion string Status string InstanceClass string + PendingInstanceClass string MultiAZ bool StorageGB int32 StorageEncrypted bool