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" "io"
"os" "os"
"os/signal" "os/signal"
"slices"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -32,26 +33,33 @@ import (
// ui.icons); the link never is. // ui.icons); the link never is.
// //
// What's cached prints at once; what's missing or older than --max-age // 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: // follows as it loads. A list that can't be read is a line too, as it
// stderr when the output is on the terminal, nothing when it goes into // fails: ✗, the vault or source, and why, in the theme's error colour.
// fzf, whose screen they'd draw over; --errors reports them to stderr // They're in the output rather than on stderr, which would draw over fzf.
// either way. func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out io.Writer) error {
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 {
fs := flag.NewFlagSet("list", flag.ContinueOnError) fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks") 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") 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") 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") 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)") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *showErrors {
errOut = stderr
}
if fs.NArg() > 0 { 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" { if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color) 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) signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error var gone error
emit := func(e listing.Entry) error { emit := func(e listing.Entry) error {
if e.Error != "" && !*showErrors {
return nil
}
if err := write(e); err != nil { if err := write(e); err != nil {
gone = err gone = err
return err return err
@@ -91,8 +102,8 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
if cachePath != "" { if cachePath != "" {
f = cachefile.Read(cachePath) f = cachefile.Read(cachePath)
} }
f, errs := listing.Run(context.Background(), listing.Options{ f = listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Timeout: timeout, Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
}, emit) }, emit)
if cachePath != "" && !*cached { if cachePath != "" && !*cached {
byID := map[string]provider.Provider{} 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 } 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 { if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
errs = append(errs, err) return err
} }
} }
if gone != nil { if gone != nil && !errors.Is(gone, syscall.EPIPE) {
if errors.Is(gone, syscall.EPIPE) {
return nil // the reader has what it wanted
}
return gone return gone
} }
for _, err := range errs { return nil // or the reader has what it wanted
fmt.Fprintln(errOut, "lazysecrets: list:", err)
}
return nil
} }
// nameOf is an entry's name for the text format: the provider's icon, // 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 { func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool) string {
render := func(st lipgloss.Style, s string) string { render := func(st lipgloss.Style, s string) string {
if !color || s == "" { if !color || s == "" {
@@ -132,6 +138,10 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
} }
} }
icon := iconSet.Prefix(kind) icon := iconSet.Prefix(kind)
iconStyle := theme.Kind(kind)
if e.Error != "" {
icon, iconStyle = "✗ ", theme.Error
}
var name string var name string
if n := len(e.Path); n > 0 { if n := len(e.Path); n > 0 {
if n > 1 { if n > 1 {
@@ -142,5 +152,8 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
if e.Account != "" { if e.Account != "" {
name += render(theme.Muted, " · "+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) 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" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"golang.org/x/term"
"git.kebcloud.no/keb/lazysecrets" "git.kebcloud.no/keb/lazysecrets"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
@@ -122,8 +121,8 @@ func run() error {
{{secrets/scope/key}} {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--cached] [--max-age 2m] [--format text|json] lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors] [--color always|never] [--icons=false] [--errors=false]
print every source, vault and secret it can print every source, vault and secret it can
find, one per line, for fzf and pickers: find, one per line, for fzf and pickers:
what's cached at once, then what loads 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) { if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json") cachePath = filepath.Join(d, "cache.json")
} }
// Into a pipe (fzf), stderr would draw over what reads it. return listCmd(providers, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout)
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)
} }
if shareArgs != nil { if shareArgs != nil {
// The files from a repo, the key from whichever provider it's // 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 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 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 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 goes, after a pick in fzf.
it prints to the terminal; into a pipe they'd draw over fzf, so only with
`--errors`. `--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 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 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. scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins, `--format json` prints an object per line instead, for editor plugins,
without colours or icons, with each entry's kind (`source`, `vault`, without colours or icons, with each entry's kind, and `error` on a list
`secret`, `key` or `certificate`): it couldn't read:
```json ```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"} {"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 ( import (
"cmp" "cmp"
"context" "context"
"fmt"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -21,6 +20,9 @@ import (
// Workers is how many lists load at once, as in Go to. // Workers is how many lists load at once, as in Go to.
const Workers = 4 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. // Entry is a source, vault or item, by its lazysecrets:// link.
type Entry struct { type Entry struct {
Link string `json:"link"` Link string `json:"link"`
@@ -33,6 +35,10 @@ type Entry struct {
Path []string `json:"path"` Path []string `json:"path"`
// Account is the az account it's seen through, when there are several. // Account is the az account it's seen through, when there are several.
Account string `json:"account,omitempty"` 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 { type Options struct {
@@ -43,14 +49,19 @@ type Options struct {
MaxAge time.Duration MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded. // 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 Timeout func(provider.Kind) time.Duration
Now func() time.Time Now func() time.Time
} }
// Run lists everything, calling emit for each entry once, from this // Run lists everything, calling emit for each entry once, from this
// goroutine. An error from emit (the reader went away) stops it. It // goroutine, and for each list that can't be loaded, as it fails. An
// returns the cache with what was loaded, and the lists that couldn't be. // error from emit (the reader went away) stops it. It returns the cache
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, []error) { // with what was loaded.
func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)} 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() cancel()
} }
} }
return l.o.Cache, l.errs return l.o.Cache
} }
type job struct { type job struct {
p provider.Provider p provider.Provider
src *provider.Source // list its vaults 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 key string // where the list is cached
} }
@@ -101,7 +113,6 @@ type lister struct {
queue []job queue []job
active int active int
results chan result results chan result
errs []error
stopped error // emit's 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. // source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) { func (l *lister) source(p provider.Provider, src provider.Source) {
src.Provider, src.ProviderLabel = p.ID(), p.Label() src.Provider, src.ProviderLabel = p.ID(), p.Label()
if r, err := ref.ForSource(src); err == nil { l.put(p, sourceEntry(src))
l.put(p, Entry{Link: r.String(), Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, Account: accountOf(src)})
}
row := cachefile.Row(p.ID(), src.ID) row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row] e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok { if l.cacheable(p) && ok {
l.containers(p, src, e.Value) l.containers(p, src, e.Value)
} }
if l.load(p, ok, e.At) { if l.load(p, ok, e.At) {
if l.wants("vault", "secret", "key", "certificate") {
l.enqueue(job{p: p, src: &src, key: row}) l.enqueue(job{p: p, src: &src, key: row})
} }
} }
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) { 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 }) { for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
c.Source = src c.Source = src
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { l.put(p, containerEntry(c))
l.put(p, Entry{Link: r.String(), Kind: "vault", Provider: kindOf(src.Kind), Path: []string{src.Name, c.Name}, Account: accountOf(src)}) for _, t := range c.ObjectTypes() {
} key := cachefile.ObjectsKey(c, t)
key := cachefile.ObjectsKey(c, provider.TypeSecret)
e, ok := l.o.Cache.Objects[key] e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) { if l.cacheable(p) {
// Keys and certificates only as far as they were opened in l.objects(p, c, e.Value)
// 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)
} }
// 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) { 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] { if l.stopped != nil || l.seen[id] {
return return
} }
@@ -187,6 +220,19 @@ func (l *lister) put(p provider.Provider, e Entry) {
l.stopped = l.emit(e) 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) { func (l *lister) enqueue(j job) {
if l.pending[j.key] { if l.pending[j.key] {
return return
@@ -211,7 +257,7 @@ func (l *lister) fetch(ctx context.Context, j job) result {
r := result{job: j} r := result{job: j}
switch { switch {
case j.c != nil: 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: case j.src != nil:
r.containers, r.err = j.p.Containers(ctx, *j.src) r.containers, r.err = j.p.Containers(ctx, *j.src)
default: default:
@@ -224,7 +270,7 @@ func (l *lister) handle(r result) {
delete(l.pending, r.key) delete(l.pending, r.key)
now := l.o.Now() now := l.o.Now()
if r.err != nil { if r.err != nil {
l.errs = append(l.errs, fmt.Errorf("%s: %w", r.label(), r.err)) l.put(r.p, r.failed())
return return
} }
cache := l.cacheable(r.p) cache := l.cacheable(r.p)
@@ -253,15 +299,21 @@ func (l *lister) handle(r result) {
} }
} }
// label names a list that couldn't be loaded. // failed is the entry for a list that couldn't be loaded.
func (r result) label() string { func (r result) failed() Entry {
var e Entry
switch { switch {
case r.c != nil: case r.c != nil:
return r.c.Source.Name + " / " + r.c.Name e = containerEntry(*r.c)
case r.src != nil: 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)) } 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) 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() t.Helper()
if o.Cache.Version == 0 { if o.Cache.Version == 0 {
o.Cache = cachefile.New() o.Cache = cachefile.New()
} }
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second } o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got []Entry var got, failed []Entry
f, errs := Run(context.Background(), o, func(e Entry) error { f := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
got = append(got, e) got = append(got, e)
}
return nil return nil
}) })
return got, f, errs return got, f, failed
} }
func has(es []Entry, kind, path string) bool { 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]) t.Errorf("no %s %s", want[0], want[1])
} }
} }
if len(errs) != 1 || !strings.Contains(errs[0].Error(), "kv-restricted: 403") { 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("errs = %v", errs) t.Errorf("failed = %+v", errs)
} }
seen := map[string]bool{} seen := map[string]bool{}
for _, e := range got { for _, e := range got {
@@ -160,10 +165,14 @@ func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel() t.Parallel()
gone := errors.New("broken pipe") gone := errors.New("broken pipe")
n := 0 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, Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second }, Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(Entry) error { }, func(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++ n++
if n == 2 { if n == 2 {
return gone return gone
@@ -174,3 +183,31 @@ func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Errorf("emitted %d after the reader went, errs %v", n, errs) 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")
}
}