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

`lazysecrets list` prints every source, vault and secret lazysecrets can find, one per line, for fzf, editor pickers and scripts: what Go to searches, without the TUI. What's cached prints at once; what's missing or older than `--max-age` loads in the background and streams in. It stops when the reader goes (a pick in fzf), saving what it loaded.

- Text: kind, mark (✗/⚠, ★, ◷ and the provider icon), name, status (why it couldn't be read, or expired / expires in 12d), note, account and link, tab-separated; kind first and account and link last, so new fields go between. Coloured from the theme; `--color never`, `--icons=false`, `--stars=false`, `--recent`, `--notes=false`, `--expiry=false`.
- `--format json`: an object per line, with starred, recent, lastUsed and uses, expires, enabled, note, reference and url too.
- `--kind` lists only some kinds, and doesn't load lists only other kinds need; `--cached` loads nothing.
- `lazysecrets open --action go|copy|show` skips the "what to do" menu and ends in the list, like Go to; `open` and `get` take `--account`, the account list printed, rather than asking which.
- The cache file moved to `internal/cachefile`: saves merge with the file under a lock, so list and the TUI don't undo each other.
- `ui.recentIcon` marks items used lately with ◷ in the TUI's lists too; Recent now means the last 90 days.
- --demo has stars, notes, recent use, and expired, expiring and disabled secrets.
- docs/integration.md documents get, list and an fzf picker; docs/links.md is docs/sharing.md.

Reviewed-on: #70
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #70.
This commit is contained in:
keb authored and Karl-Einar Bergstrøm committed 2026-10-14 21:30:44 +02:00
1 parent dbb0eb2bbd
commit 39454d47a8
45 files changed
+2107 -222

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
// Package listing lists what lazysecrets can find, for lazysecrets list:
// what Go to searches, without the TUI. What's cached comes first, at
// once; then lists that are missing or older than MaxAge load in the
// background, a few at a time, and what's new in them follows as they
// arrive. Repos are always read, from disk. Values are never listed.
package listing
import (
"cmp"
"context"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// 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"`
// Kind is source (a subscription, workspace or repo), vault (a Key
// Vault, secret scope or repo file), secret, key or certificate.
Kind string `json:"kind"`
// Provider is azure, databricks or repo, as in links.
Provider string `json:"provider"`
// Path names it: its source's, vault's and own names.
Path []string `json:"path"`
// Account is the provider it's listed through: an az account
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
// get take it (--account) to use that account when several could.
Account string `json:"account"`
// AccountLabel names the az account, when there are several.
AccountLabel string `json:"accountLabel,omitempty"`
// Starred is starred for this account; Recent is used lately (Recent,
// R, in the TUI): LastUsed is when, Uses how often.
Starred bool `json:"starred"`
Recent bool `json:"recent"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
// Expires is when a secret, key or certificate expires, if it does;
// Enabled whether it's enabled, where the provider says (Azure).
Expires time.Time `json:"expires,omitzero"`
Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any.
Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,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 {
Providers []provider.Provider
// Cache is what was loaded before; Run adds what it loads.
Cache cachefile.File
// 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
// 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
// Mark tells what's remembered about the entry with link, seen
// through account; nil marks nothing.
Mark func(link, account string) Marks
Timeout func(provider.Kind) time.Duration // nil: 30s
Now func() time.Time
}
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this
// 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, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers {
if l.stopped != nil {
break
}
l.provider(p)
}
l.start(ctx)
for l.active > 0 {
r := <-l.results
l.active--
if l.stopped == nil {
l.handle(r)
l.start(ctx)
}
if l.stopped != nil {
cancel()
}
}
return l.o.Cache, l.loaded
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets, keys or certificates (t)
t provider.ObjectType
key string // where the list is cached
}
type result struct {
job
sources []provider.Source
containers []provider.Container
objects []provider.Object
err error
}
type lister struct {
o Options
emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job
active int
results chan result
stopped error // emit's error
}
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
// load reports whether to load a list: a repo's always (from disk), else
// unless Cached, when it isn't cached or is older than MaxAge.
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
if !l.cacheable(p) {
return true
}
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
}
func (l *lister) provider(p provider.Provider) {
e, ok := l.o.Cache.Sources[p.ID()]
if l.cacheable(p) && ok {
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
l.source(p, src)
}
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, key: p.ID()})
}
}
// 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()
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
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})
}
}
}
}
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil {
continue
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
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}, AccountLabel: accountOf(c.Source), URL: c.URL}
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) {
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
}
l.seen[id] = true
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] || l.failed[j.key] {
return
}
l.pending[j.key] = true
l.queue = append(l.queue, j)
}
func (l *lister) start(ctx context.Context) {
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
j := l.queue[0]
l.queue = l.queue[1:]
l.active++
go func() { l.results <- l.fetch(ctx, j) }()
}
}
func (l *lister) fetch(ctx context.Context, j job) result {
kind := j.p.Kind()
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
defer cancel()
r := result{job: j}
switch {
case j.c != nil:
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:
r.sources, r.err = j.p.Sources(ctx)
}
return r
}
func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed())
return
}
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p)
switch {
case r.c != nil:
if cache {
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
}
l.objects(r.p, *r.c, r.objects)
case r.src != nil:
r.containers = slices.Clone(r.containers) // the provider's may be shared
for i := range r.containers {
r.containers[i].Source = *r.src
}
if cache {
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
}
l.containers(r.p, *r.src, r.containers)
default:
if cache {
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
}
for _, src := range r.sources {
l.source(r.p, src)
}
}
}
// 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:
e = containerEntry(*r.c)
case r.src != nil:
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()}}
}
e.Error = firstLine(r.err.Error())
return e
}
// firstLine is an error's first line, the summary (Azure's go on with
// the caller's IDs and more): an entry is one line. Tabs, which would
// split it into columns, are spaces.
func firstLine(s string) string {
for line := range strings.Lines(s) {
if line = strings.TrimSpace(line); line != "" {
return strings.ReplaceAll(line, "\t", " ")
}
}
return "unreadable"
}
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
func sorted[T any](s []T, name func(T) string) []T {
s = slices.Clone(s)
slices.SortStableFunc(s, func(a, b T) int {
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
})
return s
}
+297
View File
@@ -0,0 +1,297 @@
package listing
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting counts a provider's requests.
type counting struct {
provider.Provider
calls atomic.Int32
}
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
c.calls.Add(1)
return c.Provider.Sources(ctx)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.calls.Add(1)
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.calls.Add(1)
return c.Provider.Objects(ctx, ct, t)
}
// 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, 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, failed
}
func has(es []Entry, kind, path string) bool {
for _, e := range es {
if e.Kind == kind && strings.Join(e.Path, "/") == path {
return true
}
}
return false
}
func TestListLoadsAndCaches(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
for _, want := range [][2]string{
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
} {
if !has(got, want[0], want[1]) {
t.Errorf("no %s %s", want[0], want[1])
}
}
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 {
if seen[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
seen[e.Link] = true
if e.Provider != "azure" && e.Provider != "repo" {
t.Errorf("provider %q", e.Provider)
}
}
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
}
for k := range f.Containers {
if cachefile.ProviderOf(k) != az.ID() {
t.Errorf("cached a repo: %s", k)
}
}
// Again with that cache: the same, without asking Azure.
before := az.calls.Load()
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
if len(again) != len(got) {
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
}
// The vault that couldn't be read wasn't cached: it's asked again.
if n := az.calls.Load() - before; n != 1 {
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
}
// Stale: loaded again, but each entry is still listed once.
before = az.calls.Load()
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if az.calls.Load()-before < 3 {
t.Error("stale lists weren't loaded again")
}
links := map[string]bool{}
for _, e := range stale {
if links[e.Link] {
t.Errorf("%s listed twice", e.Link)
}
links[e.Link] = true
}
}
func TestListCachedOnly(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
before := az.calls.Load()
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
if az.calls.Load() != before || len(errs) != 0 {
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
}
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
t.Error("cached secrets or repos missing")
}
}
func TestListCachedFirst(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Forget one vault's secrets: they load, after everything cached.
var dropped string
for k := range f.Objects {
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
dropped = k
delete(f.Objects, k)
break
}
}
if dropped == "" {
t.Fatalf("no kv-app-prod in %v", f.Objects)
}
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
last := got[len(got)-1]
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
}
}
func TestListStopsWhenTheReaderGoes(t *testing.T) {
t.Parallel()
gone := errors.New("broken pipe")
n := 0
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(e Entry) error {
if e.Error != "" {
errs = append(errs, e)
}
n++
if n == 2 {
return gone
}
return nil
})
if n != 2 || len(errs) != 0 {
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")
}
}
func TestErrorsAreOneLine(t *testing.T) {
for in, want := range map[string]string{
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
"\n timeout\tafter 30s\n": "timeout after 30s",
"": "unreadable",
} {
if got := firstLine(in); got != want {
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
}
}
}
func TestListMarks(t *testing.T) {
t.Parallel()
az := fake.Azure(0)
var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks {
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link
}
if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
}
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
}})
var starred, recent int
for _, e := range got {
if e.Starred {
starred++
if e.Link != starredLink {
t.Errorf("%s starred", e.Link)
}
}
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
t.Errorf("%s: note %q", e.Link, e.Note)
}
if e.Recent {
recent++
if e.Uses != 3 || e.LastUsed.IsZero() {
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
}
}
if starred != 1 || recent == 0 {
t.Errorf("%d starred, %d recent", starred, recent)
}
}
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}