The scope list follows the Sources pane: All, then each account (or Databricks, or Repos) with its sources under it, in place of All Azure, All Databricks and All repos. An account scope searches and loads only that account. ctrl+s on a group header narrows to its group. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
)
|
|
|
|
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
|
|
// and scopes, and the secrets, keys and certificates of every vault opened
|
|
// this session. Azure has no search across vaults, so ctrl+a in the popup
|
|
// loads the rest in the background, a few requests at a time; results
|
|
// appear as lists arrive, and the panes use them too.
|
|
|
|
const crawlWorkers = 4
|
|
|
|
// crawl loads every vault's secrets for search.
|
|
type crawl struct {
|
|
running bool
|
|
queue []crawlJob
|
|
active int
|
|
pending map[string]bool // cache keys of lists queued or loading
|
|
failed map[string]bool // cache keys of lists that couldn't be read
|
|
}
|
|
|
|
type crawlJob struct {
|
|
src *provider.Source // list its vaults or scopes
|
|
container *provider.Container // list its secrets
|
|
row string // the source's row key (containers cache key)
|
|
force bool // reload what's loaded already
|
|
}
|
|
|
|
type crawlMsg struct {
|
|
job crawlJob
|
|
containers []provider.Container
|
|
objects []provider.Object
|
|
err error
|
|
}
|
|
|
|
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), d)
|
|
}
|
|
|
|
// searchEntries lists everything loaded: the sources (with how much of
|
|
// each is loaded) and the vaults, scopes and secrets in them, each with
|
|
// its source's row key as group.
|
|
func (m *Model) searchEntries() (sources, items []option) {
|
|
type entry struct {
|
|
depth int
|
|
sort string
|
|
opt option
|
|
}
|
|
var es []entry
|
|
// key is the store key, so ctrl+f stars the entry; mark shows how
|
|
// much is loaded, reload (ctrl+r) loads it again.
|
|
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
|
|
n := m.store.Node(key)
|
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
|
es = append(es, entry{depth, sort,
|
|
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
|
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
|
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
|
}
|
|
for _, p := range m.providers {
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
|
row := sourceRowKey(p.ID(), src.ID)
|
|
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
|
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
|
return nil
|
|
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
|
for _, c := range m.cache.containers[row].value {
|
|
r, err := ref.ForContainer(c, provider.TypeSecret)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
|
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
|
m.startNav(r, actGo, row)
|
|
return nil
|
|
}, reload)
|
|
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
|
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
|
r, err := ref.ForObject(o, "")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
suffix := accountOf(src)
|
|
if t != provider.TypeSecret {
|
|
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
|
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
|
}
|
|
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
|
m.startNav(r, actGo, row)
|
|
return nil
|
|
}, reload)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
|
|
for _, e := range es {
|
|
if e.depth == 0 {
|
|
sources = append(sources, e.opt)
|
|
} else {
|
|
items = append(items, e.opt)
|
|
}
|
|
}
|
|
return sources, items
|
|
}
|
|
|
|
// coverage is how much of everything search can see: sources whose
|
|
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
|
|
// that couldn't be read count as done.
|
|
type coverage struct {
|
|
sources, sourcesListed int
|
|
vaults, vaultsLoaded int
|
|
secrets, unreadable int
|
|
}
|
|
|
|
func (c coverage) complete() bool {
|
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
|
}
|
|
|
|
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
|
|
|
// providerFilter picks the providers a search or a load covers; nil is
|
|
// all of them.
|
|
type providerFilter func(provider.Provider) bool
|
|
|
|
func ofKind(k provider.Kind) providerFilter {
|
|
return func(p provider.Provider) bool { return p.Kind() == k }
|
|
}
|
|
|
|
func ofProvider(id string) providerFilter {
|
|
return func(p provider.Provider) bool { return p.ID() == id }
|
|
}
|
|
|
|
// coverageOf is searchCoverage for the providers in.
|
|
func (m *Model) coverageOf(in providerFilter) coverage {
|
|
var c coverage
|
|
for _, p := range m.providers {
|
|
if in != nil && !in(p) {
|
|
continue
|
|
}
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
row := sourceRowKey(p.ID(), src.ID)
|
|
c.sources++
|
|
e, ok := m.cache.containers[row]
|
|
if ok || m.crawl.failed[row] {
|
|
c.sourcesListed++
|
|
}
|
|
if m.crawl.failed[row] {
|
|
c.unreadable++
|
|
}
|
|
for _, v := range e.value {
|
|
c.vaults++
|
|
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
|
if objs, ok := m.cache.objects[key]; ok {
|
|
c.vaultsLoaded++
|
|
c.secrets += len(objs.value)
|
|
} else if m.crawl.failed[key] {
|
|
c.vaultsLoaded++
|
|
c.unreadable++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// searchLines says what the search covers.
|
|
func (m *Model) searchLines() []string {
|
|
c := m.searchCoverage()
|
|
switch {
|
|
case m.crawl.running:
|
|
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
|
len(m.crawl.queue)+m.crawl.active))}
|
|
case c.complete():
|
|
s := fmt.Sprintf("Searching everything: %s, %s, %s",
|
|
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
|
|
if c.unreadable > 0 {
|
|
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
|
|
}
|
|
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
|
|
}
|
|
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
|
|
if c.sourcesListed < c.sources {
|
|
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
|
|
}
|
|
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
|
|
}
|
|
|
|
func plural(n int, word string) string {
|
|
if n == 1 {
|
|
return "1 " + word
|
|
}
|
|
return fmt.Sprintf("%d %ss", n, word)
|
|
}
|
|
|
|
// startCrawl loads every unlisted subscription's vaults and every vault's
|
|
// secrets not loaded yet.
|
|
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
|
|
|
// startCrawlOf is startCrawl for the providers in.
|
|
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
|
for _, p := range m.providers {
|
|
if in != nil && !in(p) {
|
|
continue
|
|
}
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
|
m.queueSource(src, false)
|
|
}
|
|
}
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// loadSource loads (again) a subscription's vaults and their secrets.
|
|
func (m *Model) loadSource(src provider.Source) tea.Cmd {
|
|
m.queueSource(src, true)
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// loadContainer loads (again) a vault's secrets.
|
|
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
|
|
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// crawlStart runs what's queued, and shows it in the popup.
|
|
func (m *Model) crawlStart() tea.Cmd {
|
|
cmd := m.crawlNext()
|
|
m.refreshSearch()
|
|
return cmd
|
|
}
|
|
|
|
// queueSource queues listing src's vaults or, with them listed lately
|
|
// and not force, loading their secrets. Lists from an earlier run (the
|
|
// cache file) are listed again, so searching brings them up to date.
|
|
func (m *Model) queueSource(src provider.Source, force bool) {
|
|
row := sourceRowKey(src.Provider, src.ID)
|
|
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
|
|
for _, c := range e.value {
|
|
m.queueContainer(c, row, false)
|
|
}
|
|
return
|
|
}
|
|
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
|
|
}
|
|
|
|
// queueContainer queues loading c's secrets, unless loaded lately and not
|
|
// force.
|
|
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
|
return
|
|
}
|
|
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
|
|
}
|
|
|
|
func (m *Model) enqueue(job crawlJob, key string) {
|
|
if m.crawl.pending == nil {
|
|
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
|
|
}
|
|
if m.crawl.pending[key] {
|
|
return // queued or loading already
|
|
}
|
|
m.crawl.pending[key] = true
|
|
m.crawl.queue = append(m.crawl.queue, job)
|
|
}
|
|
|
|
// crawlNext starts jobs up to crawlWorkers at a time.
|
|
func (m *Model) crawlNext() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
|
|
job := m.crawl.queue[0]
|
|
m.crawl.queue = m.crawl.queue[1:]
|
|
m.crawl.active++
|
|
cmds = append(cmds, m.crawlCmd(job))
|
|
}
|
|
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|
timeout := m.timeout
|
|
if job.src != nil {
|
|
p := m.providerFor(*job.src)
|
|
src := *job.src
|
|
return func() tea.Msg {
|
|
ctx, cancel := contextWithTimeout(timeout)
|
|
defer cancel()
|
|
items, err := p.Containers(ctx, src)
|
|
for i := range items {
|
|
items[i].Source = src
|
|
}
|
|
return crawlMsg{job: job, containers: items, err: err}
|
|
}
|
|
}
|
|
c := *job.container
|
|
p := m.providerFor(c.Source)
|
|
return func() tea.Msg {
|
|
ctx, cancel := contextWithTimeout(timeout)
|
|
defer cancel()
|
|
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
|
for i := range items {
|
|
items[i].Container = c
|
|
}
|
|
return crawlMsg{job: job, objects: items, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
|
m.crawl.active--
|
|
key := msg.job.row
|
|
if msg.job.container != nil {
|
|
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
|
}
|
|
delete(m.crawl.pending, key)
|
|
switch {
|
|
case msg.err != nil:
|
|
m.crawl.failed[key] = true
|
|
case msg.job.src != nil:
|
|
delete(m.crawl.failed, key)
|
|
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
|
|
m.cacheDirty = true
|
|
for _, c := range msg.containers {
|
|
m.queueContainer(c, key, msg.job.force)
|
|
}
|
|
default:
|
|
delete(m.crawl.failed, key)
|
|
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
|
|
m.cacheDirty = true
|
|
}
|
|
cmd := m.crawlNext()
|
|
m.refreshSearch()
|
|
return cmd
|
|
}
|
|
|
|
// sourceMark shows how much of a subscription or workspace is loaded:
|
|
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
|
|
func (m *Model) sourceMark(row string) string {
|
|
if m.crawl.pending[row] {
|
|
return m.st.Muted.Render("◌")
|
|
}
|
|
if m.crawl.failed[row] {
|
|
return m.st.Error.Render("✗")
|
|
}
|
|
e, ok := m.cache.containers[row]
|
|
if !ok {
|
|
return m.st.Muted.Render("○")
|
|
}
|
|
loaded, loading := 0, false
|
|
for _, c := range e.value {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
loading = loading || m.crawl.pending[key]
|
|
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
|
loaded++
|
|
}
|
|
}
|
|
switch {
|
|
case loading:
|
|
return m.st.Muted.Render("◌")
|
|
case loaded == len(e.value):
|
|
return m.st.Success.Render("●")
|
|
case loaded > 0:
|
|
return m.st.Warning.Render("◐")
|
|
}
|
|
return m.st.Muted.Render("○")
|
|
}
|
|
|
|
// vaultMark marks a vault in search only when its secrets can't be read
|
|
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
|
// its vaults are searchable.
|
|
func (m *Model) vaultMark(c provider.Container) string {
|
|
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
|
switch {
|
|
case m.crawl.pending[key]:
|
|
return m.st.Muted.Render("◌")
|
|
case m.crawl.failed[key]:
|
|
return m.st.Error.Render("✗")
|
|
}
|
|
return " "
|
|
}
|
|
|
|
// providerOf is the provider ID in a source row key.
|
|
func providerOf(row string) string {
|
|
p, _ := splitSourceRow(row)
|
|
return p
|
|
}
|