feat(vault-tui): implement KV client and service for managing secrets

- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
package ui
import (
"fmt"
"strconv"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
func (m *Model) loadVersions(mount vault.Mount, path string) tea.Cmd {
m.loading = true
return func() tea.Msg {
vs, err := m.svc.KV.Versions(m.ctx, mount, path)
return versionsMsg{versions: vs, err: err}
}
}
func (m *Model) openVersions() (tea.Model, tea.Cmd) {
f := m.currentFrame()
if f.mount.Kind != vault.EngineKVv2 {
return m, m.notify(toastWarn, "version history is only available for KV v2")
}
m.versionsCursor = 0
m.push(frame{scr: scrVersions, mount: f.mount, path: f.path})
return m, m.loadVersions(f.mount, f.path)
}
func (m *Model) updateVersions(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case versionsMsg:
m.loading = false
if msg.err != nil {
return m, func() tea.Msg { return errMsg{err: msg.err} }
}
m.versions = msg.versions
if m.versionsCursor >= len(m.versions) {
m.versionsCursor = 0
}
return m, nil
case rollbackAckMsg:
m.loading = false
if msg.err != nil {
return m, func() tea.Msg { return errMsg{err: msg.err} }
}
f := m.currentFrame()
return m, tea.Batch(
m.notify(toastSuccess, "rolled back — created version %d", msg.ack.Version),
m.loadVersions(f.mount, f.path),
)
case undeleteAckMsg:
m.loading = false
if msg.err != nil {
return m, func() tea.Msg { return errMsg{err: msg.err} }
}
f := m.currentFrame()
return m, tea.Batch(m.notify(toastSuccess, "undeleted"), m.loadVersions(f.mount, f.path))
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Back):
m.pop()
return m, m.reloadSecretAfterVersions()
case key.Matches(msg, m.keys.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, m.keys.Up):
if m.versionsCursor > 0 {
m.versionsCursor--
}
case key.Matches(msg, m.keys.Down):
if m.versionsCursor < len(m.versions)-1 {
m.versionsCursor++
}
case key.Matches(msg, m.keys.Enter):
return m.viewSelectedVersion()
case key.Matches(msg, m.keys.Rollback):
if !m.a.Settings.ReadOnly {
return m.confirmRollback()
}
case key.Matches(msg, m.keys.Undelete):
if !m.a.Settings.ReadOnly {
return m.confirmUndelete()
}
}
}
return m, nil
}
func (m *Model) reloadSecretAfterVersions() tea.Cmd {
f := m.currentFrame()
if f.scr == scrSecret {
return m.loadSecret(f.mount, f.path, 0)
}
return nil
}
func (m *Model) viewSelectedVersion() (tea.Model, tea.Cmd) {
if len(m.versions) == 0 {
return m, nil
}
v := m.versions[m.versionsCursor]
f := m.currentFrame()
m.push(frame{scr: scrSecret, mount: f.mount, path: f.path})
return m, m.loadSecret(f.mount, f.path, v.Version)
}
func (m *Model) confirmRollback() (tea.Model, tea.Cmd) {
if len(m.versions) == 0 {
return m, nil
}
v := m.versions[m.versionsCursor]
f := m.currentFrame()
mount, path := f.mount, f.path
action := func() tea.Cmd {
m.loading = true
return func() tea.Msg {
ack, err := m.svc.KV.Rollback(m.ctx, mount, path, v.Version)
return rollbackAckMsg{ack: ack, err: err}
}
}
if !m.a.Settings.ConfirmDestructive {
return m, action()
}
m.confirm = &confirmSpec{
title: fmt.Sprintf("Roll back to version %d?", v.Version),
body: "Creates a new version with version " + strconv.Itoa(v.Version) + "'s data. No existing version is deleted.",
onConfirm: action,
}
return m, nil
}
func (m *Model) confirmUndelete() (tea.Model, tea.Cmd) {
if len(m.versions) == 0 {
return m, nil
}
v := m.versions[m.versionsCursor]
if v.DeletionTime == "" && !v.Destroyed {
return m, m.notify(toastInfo, "version %d is not deleted", v.Version)
}
if v.Destroyed {
return m, m.notify(toastWarn, "version %d was destroyed — it cannot be undeleted", v.Version)
}
f := m.currentFrame()
mount, path := f.mount, f.path
action := func() tea.Cmd {
m.loading = true
return func() tea.Msg {
ack, err := m.svc.KV.Delete(m.ctx, mount, path, vault.OpUndelete, []int{v.Version})
return undeleteAckMsg{ack: ack, err: err}
}
}
if !m.a.Settings.ConfirmDestructive {
return m, action()
}
m.confirm = &confirmSpec{
title: fmt.Sprintf("Undelete version %d?", v.Version),
body: "Restores this version so it can be read again.",
onConfirm: action,
}
return m, nil
}
func (m *Model) viewVersions() string {
f := m.currentFrame()
ow, oh := m.lay.bodyW, m.lay.bodyH
iw, ih := panelInner(ow, oh)
title := m.styles.PanelTitle.Render("Versions of " + f.mount.Path + f.path)
if m.loading {
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…"))
}
if len(m.versions) == 0 {
return renderPanel(m.styles.PanelActive, ow, oh,
lipgloss.JoinVertical(lipgloss.Left, title, "", m.styles.EmptyState.Render("no version history")))
}
header := m.styles.TableHeader.Render(padRight("", 3) + padRight("version", 10) + padRight("created", 22) + "status")
rowsH := ih - 2 // title + header
if rowsH < 1 {
rowsH = 1
}
top := m.versionsCursor - rowsH/2
if top < 0 {
top = 0
}
if top > len(m.versions)-rowsH {
top = len(m.versions) - rowsH
}
if top < 0 {
top = 0
}
bottom := min(top+rowsH, len(m.versions))
rows := make([]string, 0, bottom-top)
for i := top; i < bottom; i++ {
v := m.versions[i]
state := m.styles.SuccessText.Render(symOK + " active")
if v.Destroyed {
state = m.styles.ErrorText.Render(symDestroyed + " destroyed")
} else if v.DeletionTime != "" {
state = m.styles.WarnText.Render(symDeleted + " deleted")
}
marker := " "
if i == 0 {
marker = m.styles.SuccessText.Render(symCurrent) + " "
}
line := marker + padRight(fmt.Sprintf("v%d", v.Version), 9) + padRight(humanTime(v.CreatedTime), 22) + state
if i == m.versionsCursor {
line = m.styles.RowSelected.Render(padRight(line, iw))
}
rows = append(rows, line)
}
body := lipgloss.JoinVertical(lipgloss.Left, title, header, lipgloss.JoinVertical(lipgloss.Left, rows...))
return renderPanel(m.styles.PanelActive, ow, oh, body)
}