diff --git a/PLAN.md b/PLAN.md index 49cc59a..8877c03 100644 --- a/PLAN.md +++ b/PLAN.md @@ -111,11 +111,13 @@ unic/ - List VPCs → subnets → show available IP count per subnet - Reachability Analysis: create/run `NetworkInsights` path, display results -**M3.2 — RDS** +**M3.2 — RDS** ✅ - List DB instances/clusters with status - Start / Stop (with confirmation) - Failover for Multi-AZ (with confirmation) - Real-time status polling after action +- Aurora cluster-level stop/start/failover +- Type-to-confirm for destructive actions (stop, failover) **M3.3 — IAM Credentials** - List access keys, show key age/status @@ -184,9 +186,9 @@ M1.1 → M1.2 → M2.1 → M2.2 → M2.3 → M2.4 M4.1 → M4.2 → M4.3 ``` -- M1 is complete; M2 is deferred (relying on AWS SDK default credential chain) +- M1 is complete; M2 is partially complete (SSO, credential, assume role done; Okta deferred) - M3 services are independent of each other, build in any order -- M3.1 (VPC) and M3.4 (SSM Sessions) are complete +- M3.1 (VPC), M3.2 (RDS), and M3.4 (SSM Sessions) are complete - M4.3 (Distribution) is partially done (GoReleaser + GitHub Actions) --- diff --git a/README.md b/README.md index 1c377c0..df0c5e6 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ contexts: |---------|---------|--------| | EC2 | SSM Session Manager (connect to EC2 instances) | ✅ Implemented | | VPC | VPC Browser (VPCs → subnets → available IPs) | ✅ Implemented | -| RDS | ListDBInstances | 🚧 Coming Soon | +| RDS | RDS Browser (list, start/stop, failover, Aurora cluster support) | ✅ Implemented | | Route53 | ListHostedZones | 🚧 Coming Soon | | IAM | ListUsers | 🚧 Coming Soon | @@ -81,6 +81,8 @@ contexts: | `Esc`/`q` | Go back | | `H` | Go to home (service list) | | `/` | Filter (instances, IPs) | +| `C` | Context switcher | +| `s`/`x`/`f` | Start/Stop/Failover (RDS detail) | | `q` (on service list) | Quit | ## Documentation diff --git a/internal/app/app.go b/internal/app/app.go index c51545b..74ea9d3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -143,6 +143,7 @@ type Model struct { rdsFilterActive bool selectedRDS *awsservice.RDSInstance rdsAction string // "start", "stop", "failover" + rdsConfirmInput string // typed input for destructive action confirmation rdsPolling bool // Context picker @@ -652,16 +653,19 @@ func (m Model) updateRDSDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": if m.selectedRDS != nil && m.selectedRDS.CanStart() { m.rdsAction = "start" + m.rdsConfirmInput = "" m.screen = screenRDSConfirm } case "x": if m.selectedRDS != nil && m.selectedRDS.CanStop() { m.rdsAction = "stop" + m.rdsConfirmInput = "" m.screen = screenRDSConfirm } case "f": if m.selectedRDS != nil && m.selectedRDS.CanFailover() { m.rdsAction = "failover" + m.rdsConfirmInput = "" m.screen = screenRDSConfirm } case "r": @@ -673,14 +677,46 @@ func (m Model) updateRDSDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m Model) updateRDSConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Start action uses simple y/n confirmation + if m.rdsAction == "start" { + switch msg.String() { + case "y", "enter": + if m.selectedRDS != nil { + m.screen = screenRDSDetail + return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) + } + case "n", "esc": + m.screen = screenRDSDetail + } + return m, nil + } + + // Stop/failover require typing the identifier to confirm + // For Aurora cluster members, confirm with cluster ID; for standalone, instance ID + confirmTarget := "" + if m.selectedRDS != nil { + if m.selectedRDS.IsClusterMember() { + confirmTarget = m.selectedRDS.ClusterID + } else { + confirmTarget = m.selectedRDS.DBInstanceID + } + } switch msg.String() { - case "y", "enter": - if m.selectedRDS != nil { + case "esc": + m.screen = screenRDSDetail + case "enter": + if m.selectedRDS != nil && m.rdsConfirmInput == confirmTarget { m.screen = screenRDSDetail return m, m.executeRDSAction(m.rdsAction, m.selectedRDS.DBInstanceID) } - case "n", "esc": - m.screen = screenRDSDetail + case "backspace": + if len(m.rdsConfirmInput) > 0 { + m.rdsConfirmInput = m.rdsConfirmInput[:len(m.rdsConfirmInput)-1] + } + default: + if runes := msg.Runes; len(runes) > 0 { + m.rdsConfirmInput += string(runes) + } } return m, nil } @@ -826,6 +862,10 @@ func (m Model) loadRDSInstances() tea.Cmd { } func (m Model) executeRDSAction(action, dbInstanceID string) tea.Cmd { + clusterID := "" + if m.selectedRDS != nil { + clusterID = m.selectedRDS.ClusterID + } return func() tea.Msg { ctx := context.Background() repo := m.awsRepo @@ -838,13 +878,26 @@ func (m Model) executeRDSAction(action, dbInstanceID string) tea.Cmd { } var err error - switch action { - case "start": - err = repo.StartDBInstance(ctx, dbInstanceID) - case "stop": - err = repo.StopDBInstance(ctx, dbInstanceID) - case "failover": - err = repo.RebootDBInstance(ctx, dbInstanceID, true) + if clusterID != "" { + // Aurora cluster-level actions + switch action { + case "start": + err = repo.StartDBCluster(ctx, clusterID) + case "stop": + err = repo.StopDBCluster(ctx, clusterID) + case "failover": + err = repo.FailoverDBCluster(ctx, clusterID) + } + } else { + // Standalone instance actions + switch action { + case "start": + err = repo.StartDBInstance(ctx, dbInstanceID) + case "stop": + err = repo.StopDBInstance(ctx, dbInstanceID) + case "failover": + err = repo.RebootDBInstance(ctx, dbInstanceID, true) + } } return rdsActionDoneMsg{action: action, instanceID: dbInstanceID, err: err} } @@ -908,42 +961,77 @@ func (m Model) startSSMSession(inst awsservice.EC2Instance) tea.Cmd { } } +// fitToHeight ensures the rendered output is exactly m.height lines. +// It pads short content with blank lines and truncates long content, +// keeping both the header (top) and footer (bottom) visible by trimming +// from the middle of the content area. +func (m Model) fitToHeight(s string) string { + if m.height <= 0 { + return s + } + lines := strings.Split(s, "\n") + // Remove trailing empty line if present (common from trailing \n) + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) <= m.height { + // Pad to exact height so the terminal doesn't shift + for len(lines) < m.height { + lines = append(lines, "") + } + return strings.Join(lines, "\n") + } + // Content overflows: keep first (height-2) lines + last 1 line (footer) + // with a "..." indicator + footerLines := 1 + headerLines := m.height - footerLines - 1 // -1 for the "..." line + if headerLines < 1 { + headerLines = 1 + } + result := make([]string, 0, m.height) + result = append(result, lines[:headerLines]...) + result = append(result, dimStyle.Render(" ...")) + result = append(result, lines[len(lines)-footerLines:]...) + return strings.Join(result, "\n") +} + // View renders the current screen. func (m Model) View() string { if m.quitting { return "" } + var v string switch m.screen { case screenServiceList: - return m.viewServiceList() + v = m.viewServiceList() case screenFeatureList: - return m.viewFeatureList() + v = m.viewFeatureList() case screenInstanceList: - return m.viewInstanceList() + v = m.viewInstanceList() case screenVPCList: - return m.viewVPCList() + v = m.viewVPCList() case screenSubnetList: - return m.viewSubnetList() + v = m.viewSubnetList() case screenSubnetDetail: - return m.viewSubnetDetail() + v = m.viewSubnetDetail() case screenRDSList: - return m.viewRDSList() + v = m.viewRDSList() case screenRDSDetail: - return m.viewRDSDetail() + v = m.viewRDSDetail() case screenRDSConfirm: - return m.viewRDSConfirm() + v = m.viewRDSConfirm() case screenContextPicker: - return m.viewContextPicker() + v = m.viewContextPicker() case screenContextAdd: - return m.viewContextAdd() + v = m.viewContextAdd() case screenLoading: - return m.viewLoading() + v = m.viewLoading() case screenError: - return m.viewError() + v = m.viewError() } - return "" + return m.fitToHeight(v) } var ( @@ -1097,7 +1185,16 @@ func (m Model) viewContextPicker() string { b.WriteString(dimStyle.Render(" " + nameCol.Render("NAME") + regionCol.Render("REGION") + "AUTH")) b.WriteString("\n") - for i, ctx := range m.ctxList { + // overhead: title (1) + blank (1) + table header (1) + blank (1) + footer (1) = 5 + visibleLines := max(m.height-5, 3) + start := 0 + if m.ctxIdx >= visibleLines { + start = m.ctxIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.ctxList)) + + for i := start; i < end; i++ { + ctx := m.ctxList[i] cursor := " " style := normalStyle if i == m.ctxIdx { @@ -1152,7 +1249,16 @@ func (m Model) viewServiceList() string { b.WriteString(titleStyle.Render("Select AWS Service")) b.WriteString("\n\n") - for i, svc := range m.services { + // overhead: status bar (2 lines) + title (1) + blank (1) + blank (1) + footer (1) = 6 + visibleLines := max(m.height-6, 3) + start := 0 + if m.svcIdx >= visibleLines { + start = m.svcIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.services)) + + for i := start; i < end; i++ { + svc := m.services[i] cursor := " " style := normalStyle if i == m.svcIdx { @@ -1175,7 +1281,44 @@ func (m Model) viewFeatureList() string { b.WriteString(titleStyle.Render(fmt.Sprintf("%s > Select Feature", svcName))) b.WriteString("\n\n") - for i, feat := range m.features { + // Each selected item takes 2 lines (name + description), others take 1. + // overhead: status bar (2) + title (1) + blank (1) + blank (1) + footer (1) = 6 + visibleLines := max(m.height-6, 3) + start := 0 + // Count lines from start to cursor to determine if we need to scroll + linesFromStart := 0 + for i := 0; i <= m.featIdx && i < len(m.features); i++ { + linesFromStart++ + if i == m.featIdx { + linesFromStart++ // selected item has description line + } + } + if linesFromStart > visibleLines { + // Scroll forward: find start index that fits cursor in view + linesFromStart = 0 + for i := m.featIdx; i >= 0; i-- { + needed := 1 + if i == m.featIdx { + needed = 2 + } + if linesFromStart+needed > visibleLines { + start = i + 1 + break + } + linesFromStart += needed + } + } + + linesUsed := 0 + for i := start; i < len(m.features); i++ { + feat := m.features[i] + needed := 1 + if i == m.featIdx { + needed = 2 + } + if linesUsed+needed > visibleLines { + break + } cursor := " " style := normalStyle if i == m.featIdx { @@ -1188,6 +1331,7 @@ func (m Model) viewFeatureList() string { b.WriteString(dimStyle.Render(fmt.Sprintf(" %s", feat.Description))) b.WriteString("\n") } + linesUsed += needed } b.WriteString("\n") @@ -1481,27 +1625,31 @@ func (m Model) viewRDSDetail() string { } b.WriteString("\n") + suffix := "" + if r.IsClusterMember() { + suffix = " Cluster" + } b.WriteString(titleStyle.Render("Actions")) b.WriteString("\n") if r.CanStart() { - b.WriteString(normalStyle.Render(" [s] Start")) + b.WriteString(normalStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) b.WriteString("\n") } else { - b.WriteString(dimStyle.Render(" [s] Start")) + b.WriteString(dimStyle.Render(fmt.Sprintf(" [s] Start%s", suffix))) b.WriteString("\n") } if r.CanStop() { - b.WriteString(normalStyle.Render(" [x] Stop")) + b.WriteString(normalStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) b.WriteString("\n") } else { - b.WriteString(dimStyle.Render(" [x] Stop")) + b.WriteString(dimStyle.Render(fmt.Sprintf(" [x] Stop%s", suffix))) b.WriteString("\n") } if r.CanFailover() { - b.WriteString(normalStyle.Render(" [f] Failover")) + b.WriteString(normalStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) b.WriteString("\n") } else { - b.WriteString(dimStyle.Render(" [f] Failover")) + b.WriteString(dimStyle.Render(fmt.Sprintf(" [f] Failover%s", suffix))) b.WriteString("\n") } b.WriteString(normalStyle.Render(" [r] Refresh")) @@ -1516,13 +1664,37 @@ func (m Model) viewRDSConfirm() string { if m.selectedRDS == nil { return "" } + r := m.selectedRDS + + // For Aurora cluster members, show cluster-level info + targetLabel := "instance" + targetID := r.DBInstanceID + if r.IsClusterMember() { + targetLabel = "cluster" + targetID = r.ClusterID + } + var b strings.Builder b.WriteString(errorStyle.Render("Confirm Action")) b.WriteString("\n\n") - b.WriteString(normalStyle.Render(fmt.Sprintf(" Are you sure you want to %s instance %s?", - m.rdsAction, m.selectedRDS.DBInstanceID))) - b.WriteString("\n\n") - b.WriteString(normalStyle.Render(" [y] Yes [n] No")) - b.WriteString("\n") + + if m.rdsAction == "start" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" Are you sure you want to start %s %s?", + targetLabel, targetID))) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(" [y] Yes [n] No")) + b.WriteString("\n") + } else { + b.WriteString(normalStyle.Render(fmt.Sprintf(" You are about to %s %s:", m.rdsAction, targetLabel))) + b.WriteString("\n") + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", targetID))) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" Type the %s identifier to confirm:", targetLabel))) + b.WriteString("\n") + b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.rdsConfirmInput))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + b.WriteString("\n") + } return b.String() } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 479054f..a3e1c30 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,6 +1,7 @@ package app import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -215,24 +216,28 @@ func TestRDSDetailFailoverGoesToConfirm(t *testing.T) { } } -func TestRDSDetailNoStopForClusterMember(t *testing.T) { +func TestRDSDetailStopClusterMember(t *testing.T) { m := New(testConfig(), "") m.screen = screenRDSDetail m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1", Status: "available", ClusterID: "my-cluster"} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) model := updated.(Model) - // Should stay on detail screen since CanStop() is false - if model.screen != screenRDSDetail { - t.Errorf("expected to stay on detail screen, got %d", model.screen) + // Aurora cluster members can be stopped (cluster-level stop) + if model.screen != screenRDSConfirm { + t.Errorf("expected confirm screen for cluster stop, got %d", model.screen) + } + if model.rdsAction != "stop" { + t.Errorf("expected action 'stop', got %q", model.rdsAction) } } func TestRDSConfirmNoGoesBack(t *testing.T) { + // For start action, 'n' cancels back to detail m := New(testConfig(), "") m.screen = screenRDSConfirm m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1"} - m.rdsAction = "stop" + m.rdsAction = "start" updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) model := updated.(Model) @@ -378,3 +383,208 @@ func TestRDSConfirmViewNotEmpty(t *testing.T) { t.Error("RDS confirm view should not be empty") } } + +func TestRDSConfirmStopRequiresTypedInput(t *testing.T) { + // Test with standalone instance (confirm target = instance ID) + m := New(testConfig(), "") + m.screen = screenRDSConfirm + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1", ClusterID: ""} + m.rdsAction = "stop" + m.rdsConfirmInput = "" + + // Enter without typing anything — should stay on confirm screen + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if model.screen != screenRDSConfirm { + t.Error("enter without input should stay on confirm screen") + } + if cmd != nil { + t.Error("should not execute action without correct input") + } + + // Type wrong text + enter — should stay on confirm screen + model.rdsConfirmInput = "wrong-name" + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if model.screen != screenRDSConfirm { + t.Error("enter with wrong input should stay on confirm screen") + } + if cmd != nil { + t.Error("should not execute action with wrong input") + } + + // Type correct instance ID + enter — should execute + model.rdsConfirmInput = "db-1" + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if model.screen != screenRDSDetail { + t.Errorf("expected detail screen after correct input, got %d", model.screen) + } + if cmd == nil { + t.Error("expected action command after correct input") + } +} + +func TestRDSConfirmStopClusterRequiresClusterID(t *testing.T) { + // Test with Aurora cluster member (confirm target = cluster ID) + m := New(testConfig(), "") + m.screen = screenRDSConfirm + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "inst-1", ClusterID: "my-cluster", Status: "available"} + m.rdsAction = "stop" + m.rdsConfirmInput = "" + + // Type instance ID (wrong target) — should stay + m.rdsConfirmInput = "inst-1" + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if model.screen != screenRDSConfirm { + t.Error("typing instance ID for cluster member should not confirm") + } + if cmd != nil { + t.Error("should not execute action with instance ID for cluster action") + } + + // Type cluster ID (correct target) — should execute + model.rdsConfirmInput = "my-cluster" + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if model.screen != screenRDSDetail { + t.Errorf("expected detail screen after typing cluster ID, got %d", model.screen) + } + if cmd == nil { + t.Error("expected action command after correct cluster ID input") + } +} + +func TestRDSConfirmFailoverRequiresTypedInput(t *testing.T) { + m := New(testConfig(), "") + m.screen = screenRDSConfirm + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "prod-db", MultiAZ: true} + m.rdsAction = "failover" + m.rdsConfirmInput = "" + + // Enter without typing — should stay + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if model.screen != screenRDSConfirm { + t.Error("enter without input should stay on confirm screen") + } + if cmd != nil { + t.Error("should not execute action without correct input") + } + + // Type correct instance ID + enter — should execute + model.rdsConfirmInput = "prod-db" + updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if model.screen != screenRDSDetail { + t.Errorf("expected detail screen after correct input, got %d", model.screen) + } + if cmd == nil { + t.Error("expected action command after correct input") + } +} + +func TestRDSConfirmStartUsesSimpleYN(t *testing.T) { + m := New(testConfig(), "") + m.screen = screenRDSConfirm + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1", Status: "stopped"} + m.rdsAction = "start" + + // Pressing 'y' should execute immediately (no typing required) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + model := updated.(Model) + if model.screen != screenRDSDetail { + t.Errorf("expected detail screen after 'y' on start, got %d", model.screen) + } + if cmd == nil { + t.Error("expected action command after 'y' on start") + } +} + +func TestRDSConfirmInputBackspace(t *testing.T) { + m := New(testConfig(), "") + m.screen = screenRDSConfirm + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1"} + m.rdsAction = "stop" + m.rdsConfirmInput = "" + + // Type "abc" + for _, ch := range "abc" { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + m = updated.(Model) + } + if m.rdsConfirmInput != "abc" { + t.Errorf("expected 'abc', got %q", m.rdsConfirmInput) + } + + // Backspace + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + m = updated.(Model) + if m.rdsConfirmInput != "ab" { + t.Errorf("expected 'ab' after backspace, got %q", m.rdsConfirmInput) + } +} + +func TestRDSConfirmInputResetOnEntry(t *testing.T) { + m := New(testConfig(), "") + m.screen = screenRDSDetail + m.selectedRDS = &awsservice.RDSInstance{DBInstanceID: "db-1", Status: "available", ClusterID: ""} + m.rdsConfirmInput = "leftover" + + // Press 'x' to go to confirm screen + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model := updated.(Model) + if model.screen != screenRDSConfirm { + t.Errorf("expected confirm screen, got %d", model.screen) + } + if model.rdsConfirmInput != "" { + t.Errorf("expected empty confirm input on entry, got %q", model.rdsConfirmInput) + } +} + +func TestFitToHeight(t *testing.T) { + m := New(testConfig(), "") + + // height=0 → no change + m.height = 0 + input := "line1\nline2\nline3" + if got := m.fitToHeight(input); got != input { + t.Errorf("height=0 should not change output, got %q", got) + } + + // Content fits → padded to exact height + m.height = 5 + input = "line1\nline2\nline3" + got := m.fitToHeight(input) + lines := strings.Split(got, "\n") + if len(lines) != 5 { + t.Errorf("expected 5 lines (padded), got %d", len(lines)) + } + + // Content exceeds → trimmed to height with footer preserved + m.height = 4 + input = "line1\nline2\nline3\nline4\nline5\nfooter" + got = m.fitToHeight(input) + lines = strings.Split(got, "\n") + if len(lines) != 4 { + t.Errorf("expected 4 lines, got %d", len(lines)) + } +} + +func TestViewFitsTerminalHeight(t *testing.T) { + m := New(testConfig(), "") + m.screen = screenRDSDetail + m.height = 10 + m.selectedRDS = &awsservice.RDSInstance{ + DBInstanceID: "db-1", Engine: "mysql", EngineVersion: "8.0", + Status: "available", InstanceClass: "db.t3.micro", MultiAZ: true, StorageGB: 20, + Endpoint: "db-1.abc.us-east-1.rds.amazonaws.com:3306", + } + + v := m.View() + lines := strings.Split(v, "\n") + if len(lines) > m.height { + t.Errorf("view output has %d lines, exceeds terminal height %d", len(lines), m.height) + } +} diff --git a/internal/services/aws/rds.go b/internal/services/aws/rds.go index 6c0c421..0f890e2 100644 --- a/internal/services/aws/rds.go +++ b/internal/services/aws/rds.go @@ -102,3 +102,36 @@ func (r *AwsRepository) RebootDBInstance(ctx context.Context, dbInstanceID strin } return nil } + +// StopDBCluster stops an Aurora DB cluster. +func (r *AwsRepository) StopDBCluster(ctx context.Context, clusterID string) error { + _, err := r.RDSClient.StopDBCluster(ctx, &rds.StopDBClusterInput{ + DBClusterIdentifier: awssdk.String(clusterID), + }) + if err != nil { + return fmt.Errorf("failed to stop DB cluster %s: %w", clusterID, err) + } + return nil +} + +// StartDBCluster starts a stopped Aurora DB cluster. +func (r *AwsRepository) StartDBCluster(ctx context.Context, clusterID string) error { + _, err := r.RDSClient.StartDBCluster(ctx, &rds.StartDBClusterInput{ + DBClusterIdentifier: awssdk.String(clusterID), + }) + if err != nil { + return fmt.Errorf("failed to start DB cluster %s: %w", clusterID, err) + } + return nil +} + +// FailoverDBCluster triggers a failover for an Aurora DB cluster. +func (r *AwsRepository) FailoverDBCluster(ctx context.Context, clusterID string) error { + _, err := r.RDSClient.FailoverDBCluster(ctx, &rds.FailoverDBClusterInput{ + DBClusterIdentifier: awssdk.String(clusterID), + }) + if err != nil { + return fmt.Errorf("failed to failover DB cluster %s: %w", clusterID, err) + } + return nil +} diff --git a/internal/services/aws/rds_model.go b/internal/services/aws/rds_model.go index be534a4..26acf6b 100644 --- a/internal/services/aws/rds_model.go +++ b/internal/services/aws/rds_model.go @@ -30,19 +30,30 @@ func (r RDSInstance) FilterText() string { r.DBInstanceID, r.Engine, r.EngineVersion, r.Status, r.InstanceClass, r.ClusterID)) } -// CanStop returns true if the instance can be stopped. -// Aurora cluster members cannot be stopped individually. +// IsClusterMember returns true if this instance belongs to an Aurora cluster. +func (r RDSInstance) IsClusterMember() bool { + return r.ClusterID != "" +} + +// CanStop returns true if the instance (or its cluster) can be stopped. func (r RDSInstance) CanStop() bool { - return r.Status == "available" && r.ClusterID == "" + if r.IsClusterMember() { + return r.Status == "available" + } + return r.Status == "available" } -// CanStart returns true if the instance can be started. +// CanStart returns true if the instance (or its cluster) can be started. func (r RDSInstance) CanStart() bool { return r.Status == "stopped" } -// CanFailover returns true if the instance supports Multi-AZ failover. +// CanFailover returns true if the instance supports failover. +// Aurora cluster members use cluster-level failover; standalone instances need Multi-AZ. func (r RDSInstance) CanFailover() bool { + if r.IsClusterMember() { + return r.Status == "available" + } return r.Status == "available" && r.MultiAZ } diff --git a/internal/services/aws/rds_test.go b/internal/services/aws/rds_test.go index 6d80bfd..386b5c0 100644 --- a/internal/services/aws/rds_test.go +++ b/internal/services/aws/rds_test.go @@ -17,6 +17,9 @@ type mockRDSClient struct { stopDBInstanceFunc func(ctx context.Context, params *rds.StopDBInstanceInput, optFns ...func(*rds.Options)) (*rds.StopDBInstanceOutput, error) startDBInstanceFunc func(ctx context.Context, params *rds.StartDBInstanceInput, optFns ...func(*rds.Options)) (*rds.StartDBInstanceOutput, error) rebootDBInstanceFunc func(ctx context.Context, params *rds.RebootDBInstanceInput, optFns ...func(*rds.Options)) (*rds.RebootDBInstanceOutput, error) + 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) } func (m *mockRDSClient) DescribeDBInstances(ctx context.Context, params *rds.DescribeDBInstancesInput, optFns ...func(*rds.Options)) (*rds.DescribeDBInstancesOutput, error) { @@ -44,6 +47,27 @@ func (m *mockRDSClient) RebootDBInstance(ctx context.Context, params *rds.Reboot return &rds.RebootDBInstanceOutput{}, nil } +func (m *mockRDSClient) StopDBCluster(ctx context.Context, params *rds.StopDBClusterInput, optFns ...func(*rds.Options)) (*rds.StopDBClusterOutput, error) { + if m.stopDBClusterFunc != nil { + return m.stopDBClusterFunc(ctx, params, optFns...) + } + return &rds.StopDBClusterOutput{}, nil +} + +func (m *mockRDSClient) StartDBCluster(ctx context.Context, params *rds.StartDBClusterInput, optFns ...func(*rds.Options)) (*rds.StartDBClusterOutput, error) { + if m.startDBClusterFunc != nil { + return m.startDBClusterFunc(ctx, params, optFns...) + } + return &rds.StartDBClusterOutput{}, nil +} + +func (m *mockRDSClient) FailoverDBCluster(ctx context.Context, params *rds.FailoverDBClusterInput, optFns ...func(*rds.Options)) (*rds.FailoverDBClusterOutput, error) { + if m.failoverDBClusterFunc != nil { + return m.failoverDBClusterFunc(ctx, params, optFns...) + } + return &rds.FailoverDBClusterOutput{}, nil +} + // --- ListDBInstances tests --- func TestListDBInstances_Success(t *testing.T) { @@ -384,8 +408,8 @@ func TestCanStop_RunningInstance(t *testing.T) { func TestCanStop_ClusterMember(t *testing.T) { inst := RDSInstance{Status: "available", ClusterID: "my-cluster"} - if inst.CanStop() { - t.Error("cluster member should not be stoppable individually") + if !inst.CanStop() { + t.Error("available cluster member should be stoppable (cluster-level stop)") } } @@ -404,9 +428,18 @@ func TestCanFailover_MultiAZ(t *testing.T) { } func TestCanFailover_SingleAZ(t *testing.T) { - inst := RDSInstance{Status: "available", MultiAZ: false} + // Standalone single-AZ instance cannot failover + inst := RDSInstance{Status: "available", MultiAZ: false, ClusterID: ""} if inst.CanFailover() { - t.Error("single-AZ instance should not support failover") + t.Error("standalone single-AZ instance should not support failover") + } +} + +func TestCanFailover_ClusterMember(t *testing.T) { + // Aurora cluster member can failover even without MultiAZ on the instance + inst := RDSInstance{Status: "available", MultiAZ: false, ClusterID: "my-cluster"} + if !inst.CanFailover() { + t.Error("Aurora cluster member should support failover") } } diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index eeb513a..5774969 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -38,6 +38,9 @@ type RDSClientAPI interface { 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) + StopDBCluster(ctx context.Context, params *rds.StopDBClusterInput, optFns ...func(*rds.Options)) (*rds.StopDBClusterOutput, error) + StartDBCluster(ctx context.Context, params *rds.StartDBClusterInput, optFns ...func(*rds.Options)) (*rds.StartDBClusterOutput, error) + FailoverDBCluster(ctx context.Context, params *rds.FailoverDBClusterInput, optFns ...func(*rds.Options)) (*rds.FailoverDBClusterOutput, error) } // EC2ClientAPI is the interface for EC2 operations used by AwsRepository.