feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers #70
No files matched your search
+36
-23
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)) }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user