From 7b377fd468edeeef1f4eb605c9f19e6af9244b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Wed, 14 Oct 2026 13:02:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lazysecrets=20list=20shows=20unreadable?= =?UTF-8?q?=20lists=20as=20red=20=E2=9C=97=20lines,=20and=20filters=20by?= =?UTF-8?q?=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/lazysecrets/list.go | 59 +++++++++------ cmd/lazysecrets/list_test.go | 11 +++ cmd/lazysecrets/main.go | 12 +-- docs/links.md | 19 +++-- internal/listing/listing.go | 122 ++++++++++++++++++++++--------- internal/listing/listing_test.go | 55 +++++++++++--- 6 files changed, 197 insertions(+), 81 deletions(-) diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index d305b15..9550fbc 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -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 } diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index dd5e6e2..b823313 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -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) + } +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 0ab683c..edad365 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -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 print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) - 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 diff --git a/docs/links.md b/docs/links.md index d14f235..7edbcdc 100644 --- a/docs/links.md +++ b/docs/links.md @@ -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"} diff --git a/internal/listing/listing.go b/internal/listing/listing.go index 2516d79..7012ce1 100644 --- a/internal/listing/listing.go +++ b/internal/listing/listing.go @@ -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 { @@ -42,15 +48,20 @@ type Options struct { // MaxAge: cached lists loaded longer ago are loaded again. MaxAge time.Duration // Cached lists only what's cached (and repos): nothing is loaded. - Cached bool + 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,14 +86,15 @@ 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 - key string // where the list is cached + c *provider.Container // list its secrets, keys or certificates (t) + t provider.ObjectType + key string // where the list is cached } type result struct { @@ -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) { - l.enqueue(job{p: p, src: &src, key: row}) + 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) - 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.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) { + 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)) } diff --git a/internal/listing/listing_test.go b/internal/listing/listing_test.go index ebac6dc..be61c74 100644 --- a/internal/listing/listing_test.go +++ b/internal/listing/listing_test.go @@ -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 { - got = append(got, e) + 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") + } +}