feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers #70

Merged
keb merged 45 commits from feat/list into main 2026-10-14 21:30:44 +02:00
6 changed files with 190 additions and 74 deletions
Showing only changes of commit 7b377fd468 - Show all commits

No files matched your search

+36 -23
View File
@@ -9,6 +9,7 @@ import (
"io"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
@@ -32,26 +33,33 @@ import (
// ui.icons); the link never is.
//
// What's cached prints at once; what's missing or older than --max-age
// follows as it loads. Lists that can't be read are reported to errOut:
// stderr when the output is on the terminal, nothing when it goes into
// fzf, whose screen they'd draw over; --errors reports them to stderr
// either way.
func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out, errOut, stderr io.Writer) error {
// follows as it loads. A list that can't be read is a line too, as it
// fails: ✗, the vault or source, and why, in the theme's error colour.
// They're in the output rather than on stderr, which would draw over fzf.
func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out io.Writer) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: name and link, tab-separated; json: an object per line")
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
showErrors := fs.Bool("errors", false, "report lists that can't be read on stderr also when the output goes into a pipe")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
if err := fs.Parse(args); err != nil {
return err
}
if *showErrors {
errOut = stderr
}
if fs.NArg() > 0 {
return fmt.Errorf("usage: lazysecrets list [--cached] [--max-age 2m] [--format text|json]")
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
}
var only []string
for k := range strings.SplitSeq(*kinds, ",") {
if k = strings.TrimSpace(k); k == "" {
continue
}
if !slices.Contains(listing.Kinds, k) {
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
}
only = append(only, k)
}
if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color)
@@ -80,6 +88,9 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error
emit := func(e listing.Entry) error {
if e.Error != "" && !*showErrors {
return nil
}
if err := write(e); err != nil {
gone = err
return err
@@ -91,8 +102,8 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, errs := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Timeout: timeout,
f = listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
}, emit)
if cachePath != "" && !*cached {
byID := map[string]provider.Provider{}
@@ -101,23 +112,18 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
}
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
errs = append(errs, err)
return err
}
}
if gone != nil {
if errors.Is(gone, syscall.EPIPE) {
return nil // the reader has what it wanted
}
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
return gone
}
for _, err := range errs {
fmt.Fprintln(errOut, "lazysecrets: list:", err)
}
return nil
return nil // or the reader has what it wanted
}
// nameOf is an entry's name for the text format: the provider's icon,
// the path with the item's own name standing out, and the account.
// the path with the item's own name standing out, and the account. A
// list that couldn't be read has ✗ for the icon, and why after it.
func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool) string {
render := func(st lipgloss.Style, s string) string {
if !color || s == "" {
@@ -132,6 +138,10 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
}
}
icon := iconSet.Prefix(kind)
iconStyle := theme.Kind(kind)
if e.Error != "" {
icon, iconStyle = "✗ ", theme.Error
}
var name string
if n := len(e.Path); n > 0 {
if n > 1 {
@@ -142,5 +152,8 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
if e.Account != "" {
name += render(theme.Muted, " · "+e.Account)
}
return render(theme.Kind(kind), strings.TrimSuffix(icon, " ")) + strings.Repeat(" ", min(len(icon), 1)) + name
if e.Error != "" {
name += " " + render(theme.Error, e.Error)
}
return render(iconStyle, strings.TrimSuffix(icon, " ")) + strings.Repeat(" ", min(len(icon), 1)) + name
}
+11
View File
@@ -23,3 +23,14 @@ func TestListNames(t *testing.T) {
t.Errorf("name not set apart: %q", got)
}
}
func TestListErrorNames(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
if got := nameOf(e, styles.Default(), icons.NerdFont, false); got != "✗ platform-prod / kv-restricted 403 Forbidden" {
t.Errorf("plain: %q", got)
}
red := styles.Default().Error.Render("403 Forbidden")
if got := nameOf(e, styles.Default(), icons.NerdFont, true); !strings.HasSuffix(got, red) {
t.Errorf("why isn't in the error colour: %q", got)
}
}
+3 -9
View File
@@ -17,7 +17,6 @@ import (
"time"
tea "charm.land/bubbletea/v2"
"golang.org/x/term"
"git.kebcloud.no/keb/lazysecrets"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
@@ -122,8 +121,8 @@ func run() error {
{{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors]
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
@@ -358,12 +357,7 @@ Flags:
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
// Into a pipe (fzf), stderr would draw over what reads it.
errOut := io.Discard
if term.IsTerminal(int(os.Stdout.Fd())) {
errOut = os.Stderr
}
return listCmd(providers, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout, errOut, os.Stderr)
return listCmd(providers, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
+14 -5
View File
@@ -154,9 +154,18 @@ what's missing or older than `--max-age` (2m), and what's new in it follows
as it arrives: fzf shows it while you type. `--cached` loads nothing from
Azure or Databricks, for an answer at once. Repos are always read, from
disk. What it loads is cached for the TUI too. It stops when the reader
goes, after a pick in fzf. Lists it can't read are reported on stderr when
it prints to the terminal; into a pipe they'd draw over fzf, so only with
`--errors`.
goes, after a pick in fzf.
`--kind` lists only some kinds, comma-separated: `source`, `vault` (also
Databricks scopes and repo files), `secret`, `key`, `certificate`. Lists
only other kinds need aren't loaded: `--kind source,vault` asks for no
secrets. Keys and certificates load only when asked for this way; else,
like Go to, they're listed as far as you opened them in lazysecrets.
A vault or source it can't read is a line too, as it fails: `✗`, its
name and why, in red (`errorColor`), with its link, so opening it shows
more. They're in the list, not on stderr, which would draw over fzf.
`--errors=false` leaves them out.
The name is coloured from your [theme](config.md#theme), with the
provider's icon when [icons](config.md#theme) are on (`ui.icons`); fzf
@@ -164,8 +173,8 @@ shows the colours with `--ansi`. `--color never` leaves them out, for
scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins,
without colours or icons, with each entry's kind (`source`, `vault`,
`secret`, `key` or `certificate`):
without colours or icons, with each entry's kind, and `error` on a list
it couldn't read:
```json
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"alice"}
+81 -29
View File
@@ -8,7 +8,6 @@ package listing
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
@@ -21,6 +20,9 @@ import (
// Workers is how many lists load at once, as in Go to.
const Workers = 4
// Kinds are the kinds of entry, from sources down.
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
// Entry is a source, vault or item, by its lazysecrets:// link.
type Entry struct {
Link string `json:"link"`
@@ -33,6 +35,10 @@ type Entry struct {
Path []string `json:"path"`
// Account is the az account it's seen through, when there are several.
Account string `json:"account,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
Error string `json:"error,omitempty"`
}
type Options struct {
@@ -43,14 +49,19 @@ type Options struct {
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
Cached bool
// Kinds are the kinds of entry to list (Kinds' names); none is all.
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
Kinds []string
Timeout func(provider.Kind) time.Duration
Now func() time.Time
}
// Run lists everything, calling emit for each entry once, from this
// goroutine. An error from emit (the reader went away) stops it. It
// returns the cache with what was loaded, and the lists that couldn't be.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, []error) {
// goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache
// with what was loaded.
func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)}
@@ -75,13 +86,14 @@ func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File
cancel()
}
}
return l.o.Cache, l.errs
return l.o.Cache
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets
c *provider.Container // list its secrets, keys or certificates (t)
t provider.ObjectType
key string // where the list is cached
}
@@ -101,7 +113,6 @@ type lister struct {
queue []job
active int
results chan result
errs []error
stopped error // emit's error
}
@@ -131,36 +142,38 @@ func (l *lister) provider(p provider.Provider) {
// source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
if r, err := ref.ForSource(src); err == nil {
l.put(p, Entry{Link: r.String(), Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, Account: accountOf(src)})
}
l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok {
l.containers(p, src, e.Value)
}
if l.load(p, ok, e.At) {
if l.wants("vault", "secret", "key", "certificate") {
l.enqueue(job{p: p, src: &src, key: row})
}
}
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
c.Source = src
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
l.put(p, Entry{Link: r.String(), Kind: "vault", Provider: kindOf(src.Kind), Path: []string{src.Name, c.Name}, Account: accountOf(src)})
}
key := cachefile.ObjectsKey(c, provider.TypeSecret)
l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() {
key := cachefile.ObjectsKey(c, t)
e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) {
// Keys and certificates only as far as they were opened in
// the TUI: Go to doesn't load them either.
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
l.objects(p, c, l.o.Cache.Objects[cachefile.ObjectsKey(c, t)].Value)
l.objects(p, c, e.Value)
}
// Keys and certificates load only when asked for; else,
// like Go to, only as far as the TUI opened them.
asked := l.wants(strings.ToLower(string(t)))
if t != provider.TypeSecret {
asked = len(l.o.Kinds) > 0 && asked
}
if asked && l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, t: t, key: key})
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, key: key})
}
}
}
@@ -177,9 +190,29 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
}
}
// put emits e, unless it was already (or emitting has failed).
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, Account: accountOf(src)}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
return e
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, Account: accountOf(c.Source)}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
return e
}
// put emits e, unless it was already (or emitting has failed). Entries
// without a link (one couldn't be made) are left out, but not errors.
func (l *lister) put(p provider.Provider, e Entry) {
id := p.ID() + "\x00" + e.Link
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
}
@@ -187,6 +220,19 @@ func (l *lister) put(p provider.Provider, e Entry) {
l.stopped = l.emit(e)
}
// wants reports whether any of kinds is listed.
func (l *lister) wants(kinds ...string) bool {
if len(l.o.Kinds) == 0 {
return true
}
for _, k := range kinds {
if slices.Contains(l.o.Kinds, k) {
return true
}
}
return false
}
func (l *lister) enqueue(j job) {
if l.pending[j.key] {
return
@@ -211,7 +257,7 @@ func (l *lister) fetch(ctx context.Context, j job) result {
r := result{job: j}
switch {
case j.c != nil:
r.objects, r.err = j.p.Objects(ctx, *j.c, provider.TypeSecret)
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
case j.src != nil:
r.containers, r.err = j.p.Containers(ctx, *j.src)
default:
@@ -224,7 +270,7 @@ func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.errs = append(l.errs, fmt.Errorf("%s: %w", r.label(), r.err))
l.put(r.p, r.failed())
return
}
cache := l.cacheable(r.p)
@@ -253,15 +299,21 @@ func (l *lister) handle(r result) {
}
}
// label names a list that couldn't be loaded.
func (r result) label() string {
// failed is the entry for a list that couldn't be loaded.
func (r result) failed() Entry {
var e Entry
switch {
case r.c != nil:
return r.c.Source.Name + " / " + r.c.Name
e = containerEntry(*r.c)
case r.src != nil:
return r.src.Name
src := *r.src
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
e = sourceEntry(src)
default:
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
}
return r.p.Label()
e.Error = r.err.Error()
return e
}
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
+45 -8
View File
@@ -34,18 +34,23 @@ func (c *counting) Objects(ctx context.Context, ct provider.Container, t provide
return c.Provider.Objects(ctx, ct, t)
}
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []error) {
// run lists, returning the entries and, apart, the lists that failed.
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
t.Helper()
if o.Cache.Version == 0 {
o.Cache = cachefile.New()
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got []Entry
f, errs := Run(context.Background(), o, func(e Entry) error {
var got, failed []Entry
f := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
got = append(got, e)
}
return nil
})
return got, f, errs
return got, f, failed
}
func has(es []Entry, kind, path string) bool {
@@ -69,8 +74,8 @@ func TestListLoadsAndCaches(t *testing.T) {
t.Errorf("no %s %s", want[0], want[1])
}
}
if len(errs) != 1 || !strings.Contains(errs[0].Error(), "kv-restricted: 403") {
t.Errorf("errs = %v", errs)
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
t.Errorf("failed = %+v", errs)
}
seen := map[string]bool{}
for _, e := range got {
@@ -160,10 +165,14 @@ func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel()
gone := errors.New("broken pipe")
n := 0
_, errs := Run(context.Background(), Options{
var errs []Entry
Run(context.Background(), Options{
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(Entry) error {
}, func(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++
if n == 2 {
return gone
@@ -174,3 +183,31 @@ func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
}
}
func TestListKinds(t *testing.T) {
t.Parallel()
// Sources only: nothing under them is asked for.
az := &counting{Provider: fake.Azure(0)}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
if len(got) == 0 || az.calls.Load() != 1 {
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
}
for _, e := range got {
if e.Kind != "source" {
t.Errorf("listed a %s", e.Kind)
}
}
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
t.Errorf("no keys: %+v", got)
}
for _, e := range got {
if e.Kind != "key" {
t.Errorf("listed a %s", e.Kind)
}
}
if len(failed) == 0 {
t.Error("a list that failed wasn't listed")
}
}