diff --git a/README.md b/README.md index 0c69ccf..c5623f0 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,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 | @@ -375,7 +376,7 @@ checks: | EC2 Instance Browser | `r` refresh, `/` filter, `A` toggle all-regions scope (multi-region contexts), `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 | @@ -400,6 +401,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 8d3bbd2..f69d840 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -39,6 +39,7 @@ const ( screenReachabilityResult screenRDSList screenRDSDetail + screenRDSClassPicker screenRDSConfirm screenRoute53ZoneList screenRoute53RecordList diff --git a/internal/app/help.go b/internal/app/help.go index 5d9e215..3e86da9 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -229,6 +229,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"}, } @@ -242,6 +243,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{ @@ -249,6 +252,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" @@ -838,6 +849,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 2c93d74..7731f91 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -87,6 +87,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..22816be 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() @@ -62,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 @@ -85,6 +109,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 +126,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 +194,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 +210,50 @@ 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 + } + 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 = "" + 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 +271,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 +282,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 +309,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 +358,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 +372,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 { @@ -379,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" @@ -434,6 +560,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 +642,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..aaddde6 --- /dev/null +++ b/internal/app/screen_rds_modify_test.go @@ -0,0 +1,173 @@ +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) + } +} + +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/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..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)) } @@ -159,3 +165,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_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 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 f98b647..10fcedc 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)