Skip to content

Commit aeec6e4

Browse files
committed
feat: add Secrets Manager browser (M3.9)
- Add SecretsManagerClientAPI interface and client to AwsRepository - Implement ListSecrets() and GetSecretDetail() with JSON key/value parsing - Register ServiceSecretsManager and FeatureSecretsBrowser in domain catalog - Add screenSecretList and screenSecretDetail TUI screens with filter support - Add Secret and SecretDetail models with DisplayTitle() and FilterText() - Add mock-based tests for list, get, and model methods
1 parent 3eb8b06 commit aeec6e4

10 files changed

Lines changed: 595 additions & 19 deletions

File tree

PLAN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ unic/
161161
- Graph rendering: Bubbletea viewport with braille/block characters
162162
- Multiple metrics overlay on single chart
163163

164+
**M3.9 — Secrets Manager**
165+
- List secrets
166+
- Drill into secret detail: name, key/value pairs, encryption key (KMS key ID)
167+
164168
---
165169

166170
### M4 — Polish & Release

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
2525
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
2626
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
27+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 // indirect
2728
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
2829
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
2930
github.com/aws/smithy-go v1.24.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+
2020
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
2121
github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 h1:H/ZYZ6QR4EXJAYElI5xkIM/yCz+A4uHIvWpzl+IfJks=
2222
github.com/aws/aws-sdk-go-v2/service/rds v1.116.3/go.mod h1:QbXW4coAMakHQhf1qhE0eVVCen9gwB/Kvn+HHHKhpGY=
23+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 h1:9aZbO86sraeCIHHCpZhxwN9tnVy9POkSKzi4/TpT54A=
24+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4/go.mod h1:cxiXDhEzIq7Xx1BtmC4lGBK3SwAZ79+EUWiKawYHo14=
2325
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
2426
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
2527
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.3 h1:bBoWhx8lsFLTXintRX64ZBXcmFZbGqUmaPUrjXECqIc=

internal/app/app.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const (
2828
screenRDSList
2929
screenRDSDetail
3030
screenRDSConfirm
31+
screenSecretList
32+
screenSecretDetail
3133
screenContextPicker
3234
screenContextAdd
3335
screenLoading
@@ -96,6 +98,14 @@ type rdsTickMsg struct {
9698
instanceID string
9799
}
98100

101+
type secretsLoadedMsg struct {
102+
secrets []awsservice.Secret
103+
}
104+
105+
type secretDetailLoadedMsg struct {
106+
detail *awsservice.SecretDetail
107+
}
108+
99109
// Model is the root Bubbletea model.
100110
type Model struct {
101111
cfg *config.Config
@@ -146,6 +156,14 @@ type Model struct {
146156
rdsConfirmInput string // typed input for destructive action confirmation
147157
rdsPolling bool
148158

159+
// Secrets Manager browser state
160+
secrets []awsservice.Secret
161+
filteredSecrets []awsservice.Secret
162+
secretIdx int
163+
secretFilter string
164+
secretFilterActive bool
165+
selectedSecret *awsservice.SecretDetail
166+
149167
// Context picker
150168
configPath string
151169
ctxList []config.ContextInfo
@@ -251,6 +269,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
251269
m.screen = screenRDSList
252270
return m, nil
253271

272+
case secretsLoadedMsg:
273+
m.secrets = msg.secrets
274+
m.filteredSecrets = msg.secrets
275+
m.secretIdx = 0
276+
m.screen = screenSecretList
277+
return m, nil
278+
279+
case secretDetailLoadedMsg:
280+
m.selectedSecret = msg.detail
281+
m.screen = screenSecretDetail
282+
return m, nil
283+
254284
case rdsActionDoneMsg:
255285
if msg.err != nil {
256286
m.errMsg = msg.err.Error()
@@ -366,6 +396,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
366396
return m.updateRDSDetail(msg)
367397
case screenRDSConfirm:
368398
return m.updateRDSConfirm(msg)
399+
case screenSecretList:
400+
return m.updateSecretList(msg)
401+
case screenSecretDetail:
402+
return m.updateSecretDetail(msg)
369403
case screenContextPicker:
370404
return m.updateContextPicker(msg)
371405
case screenContextAdd:
@@ -429,6 +463,9 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
429463
case domain.FeatureRDSBrowser:
430464
m.screen = screenLoading
431465
return m, m.loadRDSInstances()
466+
case domain.FeatureSecretsBrowser:
467+
m.screen = screenLoading
468+
return m, m.loadSecrets()
432469
}
433470
}
434471
}
@@ -1021,6 +1058,10 @@ func (m Model) View() string {
10211058
v = m.viewRDSDetail()
10221059
case screenRDSConfirm:
10231060
v = m.viewRDSConfirm()
1061+
case screenSecretList:
1062+
v = m.viewSecretList()
1063+
case screenSecretDetail:
1064+
v = m.viewSecretDetail()
10241065
case screenContextPicker:
10251066
v = m.viewContextPicker()
10261067
case screenContextAdd:
@@ -1698,3 +1739,209 @@ func (m Model) viewRDSConfirm() string {
16981739
}
16991740
return b.String()
17001741
}
1742+
1743+
func (m Model) loadSecrets() tea.Cmd {
1744+
return func() tea.Msg {
1745+
ctx := context.Background()
1746+
repo, err := awsservice.NewAwsRepository(ctx, m.cfg)
1747+
if err != nil {
1748+
return errMsg{err: err}
1749+
}
1750+
secrets, err := repo.ListSecrets(ctx)
1751+
if err != nil {
1752+
return errMsg{err: err}
1753+
}
1754+
if len(secrets) == 0 {
1755+
return errMsg{err: fmt.Errorf("no secrets found")}
1756+
}
1757+
return secretsLoadedMsg{secrets: secrets}
1758+
}
1759+
}
1760+
1761+
func (m Model) loadSecretDetail(name string) tea.Cmd {
1762+
return func() tea.Msg {
1763+
ctx := context.Background()
1764+
repo := m.awsRepo
1765+
if repo == nil {
1766+
var err error
1767+
repo, err = awsservice.NewAwsRepository(ctx, m.cfg)
1768+
if err != nil {
1769+
return errMsg{err: err}
1770+
}
1771+
}
1772+
detail, err := repo.GetSecretDetail(ctx, name)
1773+
if err != nil {
1774+
return errMsg{err: err}
1775+
}
1776+
return secretDetailLoadedMsg{detail: detail}
1777+
}
1778+
}
1779+
1780+
func (m Model) updateSecretList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1781+
key := msg.String()
1782+
1783+
if m.secretFilterActive {
1784+
switch key {
1785+
case "esc":
1786+
m.secretFilterActive = false
1787+
case "enter":
1788+
m.secretFilterActive = false
1789+
case "backspace":
1790+
if len(m.secretFilter) > 0 {
1791+
m.secretFilter = m.secretFilter[:len(m.secretFilter)-1]
1792+
m.applySecretFilter()
1793+
}
1794+
default:
1795+
if len(key) == 1 {
1796+
m.secretFilter += key
1797+
m.applySecretFilter()
1798+
}
1799+
}
1800+
return m, nil
1801+
}
1802+
1803+
switch key {
1804+
case "q", "esc":
1805+
m.screen = screenFeatureList
1806+
m.secretFilter = ""
1807+
m.filteredSecrets = m.secrets
1808+
m.secretIdx = 0
1809+
case "up", "k":
1810+
if m.secretIdx > 0 {
1811+
m.secretIdx--
1812+
}
1813+
case "down", "j":
1814+
if m.secretIdx < len(m.filteredSecrets)-1 {
1815+
m.secretIdx++
1816+
}
1817+
case "/":
1818+
m.secretFilterActive = true
1819+
case "enter":
1820+
if len(m.filteredSecrets) > 0 && m.secretIdx < len(m.filteredSecrets) {
1821+
selected := m.filteredSecrets[m.secretIdx]
1822+
m.screen = screenLoading
1823+
return m, m.loadSecretDetail(selected.Name)
1824+
}
1825+
}
1826+
return m, nil
1827+
}
1828+
1829+
func (m Model) updateSecretDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1830+
switch msg.String() {
1831+
case "q", "esc":
1832+
m.selectedSecret = nil
1833+
m.screen = screenSecretList
1834+
}
1835+
return m, nil
1836+
}
1837+
1838+
func (m *Model) applySecretFilter() {
1839+
if m.secretFilter == "" {
1840+
m.filteredSecrets = m.secrets
1841+
} else {
1842+
query := strings.ToLower(m.secretFilter)
1843+
var result []awsservice.Secret
1844+
for _, s := range m.secrets {
1845+
if strings.Contains(s.FilterText(), query) {
1846+
result = append(result, s)
1847+
}
1848+
}
1849+
m.filteredSecrets = result
1850+
}
1851+
m.secretIdx = 0
1852+
}
1853+
1854+
func (m Model) viewSecretList() string {
1855+
var b strings.Builder
1856+
b.WriteString(m.renderStatusBar())
1857+
b.WriteString(titleStyle.Render("Secrets Manager"))
1858+
b.WriteString("\n")
1859+
1860+
if m.secretFilterActive {
1861+
b.WriteString(filterStyle.Render(fmt.Sprintf("Filter: %s▏", m.secretFilter)))
1862+
} else if m.secretFilter != "" {
1863+
b.WriteString(dimStyle.Render(fmt.Sprintf("Filter: %s", m.secretFilter)))
1864+
}
1865+
b.WriteString("\n\n")
1866+
1867+
if len(m.filteredSecrets) == 0 {
1868+
b.WriteString(dimStyle.Render(" No matching secrets"))
1869+
b.WriteString("\n")
1870+
} else {
1871+
visibleLines := max(m.height-8, 5)
1872+
start := 0
1873+
if m.secretIdx >= visibleLines {
1874+
start = m.secretIdx - visibleLines + 1
1875+
}
1876+
end := min(start+visibleLines, len(m.filteredSecrets))
1877+
1878+
for i := start; i < end; i++ {
1879+
s := m.filteredSecrets[i]
1880+
cursor := " "
1881+
style := normalStyle
1882+
if i == m.secretIdx {
1883+
cursor = "> "
1884+
style = selectedStyle
1885+
}
1886+
b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, s.DisplayTitle())))
1887+
b.WriteString("\n")
1888+
}
1889+
1890+
b.WriteString("\n")
1891+
b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d secrets", len(m.filteredSecrets), len(m.secrets))))
1892+
}
1893+
1894+
b.WriteString("\n")
1895+
b.WriteString(dimStyle.Render("↑/↓: navigate • /: filter • enter: detail • esc: back • H: home"))
1896+
return b.String()
1897+
}
1898+
1899+
func (m Model) viewSecretDetail() string {
1900+
if m.selectedSecret == nil {
1901+
return ""
1902+
}
1903+
d := m.selectedSecret
1904+
var b strings.Builder
1905+
b.WriteString(m.renderStatusBar())
1906+
b.WriteString(titleStyle.Render("Secret Detail"))
1907+
b.WriteString("\n\n")
1908+
1909+
labelStyle := lipgloss.NewStyle().Width(14)
1910+
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Name"), d.Name)))
1911+
b.WriteString("\n")
1912+
1913+
kmsKey := d.KMSKeyID
1914+
if kmsKey == "" {
1915+
kmsKey = dimStyle.Render("(aws/secretsmanager)")
1916+
}
1917+
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Encryption Key"), kmsKey)))
1918+
b.WriteString("\n\n")
1919+
1920+
if len(d.Values) > 0 {
1921+
b.WriteString(titleStyle.Render("Key / Value"))
1922+
b.WriteString("\n\n")
1923+
1924+
keys := make([]string, 0, len(d.Values))
1925+
for k := range d.Values {
1926+
keys = append(keys, k)
1927+
}
1928+
for i := 1; i < len(keys); i++ {
1929+
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
1930+
keys[j], keys[j-1] = keys[j-1], keys[j]
1931+
}
1932+
}
1933+
1934+
for _, k := range keys {
1935+
b.WriteString(fmt.Sprintf(" %s %s\n", dimStyle.Render(k), normalStyle.Render(d.Values[k])))
1936+
}
1937+
} else if d.Raw != "" {
1938+
b.WriteString(titleStyle.Render("Value"))
1939+
b.WriteString("\n\n")
1940+
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s", d.Raw)))
1941+
b.WriteString("\n")
1942+
}
1943+
1944+
b.WriteString("\n")
1945+
b.WriteString(dimStyle.Render("esc: back • H: home"))
1946+
return b.String()
1947+
}

internal/domain/catalog.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,14 @@ func Catalog() []Service {
3030
},
3131
},
3232
},
33+
{
34+
Name: ServiceSecretsManager,
35+
Features: []Feature{
36+
{
37+
Kind: FeatureSecretsBrowser,
38+
Description: "Browse secrets and view key/value pairs",
39+
},
40+
},
41+
},
3342
}
3443
}

internal/domain/model.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@ package domain
44
type AwsService string
55

66
const (
7-
ServiceEC2 AwsService = "EC2"
8-
ServiceVPC AwsService = "VPC"
9-
ServiceRDS AwsService = "RDS"
7+
ServiceEC2 AwsService = "EC2"
8+
ServiceVPC AwsService = "VPC"
9+
ServiceRDS AwsService = "RDS"
10+
ServiceSecretsManager AwsService = "Secrets Manager"
1011
)
1112

1213
// FeatureKind represents a specific feature within a service.
1314
type FeatureKind string
1415

1516
const (
16-
FeatureSSMSession FeatureKind = "SSM Sessions Manager"
17-
FeatureVPCBrowser FeatureKind = "VPC Browser"
18-
FeatureRDSBrowser FeatureKind = "RDS Browser"
17+
FeatureSSMSession FeatureKind = "SSM Sessions Manager"
18+
FeatureVPCBrowser FeatureKind = "VPC Browser"
19+
FeatureRDSBrowser FeatureKind = "RDS Browser"
20+
FeatureSecretsBrowser FeatureKind = "Secrets Manager Browser"
1921
)
2022

2123
// Feature describes a selectable feature under an AWS service.

0 commit comments

Comments
 (0)