diff --git a/README.md b/README.md index 60bbc54..4511ea3 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,11 @@ use most: ## Privacy -lazysecrets never stores, caches or sends your secret values. It talks only -to the services you use it with, and collects no usage data. The one call -of its own is a daily check for a new release on this server, which +lazysecrets never stores, caches or sends your secret values. It keeps the +names and metadata of what you've loaded in a cache file readable only by +you, to show it at once next time (`cache: false` turns it off). It talks +only to the services you use it with, and collects no usage data. The one +call of its own is a daily check for a new release on this server, which `updateCheck: false` turns off. diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 7958fab..3117843 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -334,6 +334,21 @@ Flags: onActive = srv.Touch } + // What was loaded last time, and where it was left in this folder. + var cacheFile, placeDir string + if !*demo { + if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) { + cacheFile = filepath.Join(d, "cache.json") + } + placeDir = *dir + if placeDir == "" { + placeDir, _ = os.Getwd() + } + if abs, err := filepath.Abs(placeDir); err == nil { + placeDir = abs + } + } + m := app.New(providers, app.Options{ Timeout: *timeout, Icons: *icons, @@ -353,11 +368,16 @@ Flags: Commands: cfg.CustomCommands, Version: versionString(), Store: st, + CacheFile: cacheFile, + Dir: placeDir, Open: open, Styles: &theme, DefaultFg: defaultFg, }) p = tea.NewProgram(m) - _, err = p.Run() + final, err := p.Run() + if fm, ok := final.(app.Model); ok { + _ = fm.Persist() // a cache it couldn't save is no reason to fail + } return err } diff --git a/docs/config.md b/docs/config.md index c5c7023..5fc5344 100644 --- a/docs/config.md +++ b/docs/config.md @@ -16,6 +16,7 @@ sourcesView: grouped # or flat; ` toggles it collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) history: true # count uses of items for Recent (R) +cache: true # keep what was loaded (never values) for the next start providers: azure: # applies to every az account enabled: true # default @@ -102,6 +103,25 @@ count as using it: its record keeps when it was last used and how often, for Recent (`R`). Records kept only for usage are dropped after 90 days unused, and beyond the 100 most recent. `history: false` stops counting. +It also keeps where you left lazysecrets in each folder (the selected +source, vault or secret), and goes back there the next time it starts in +that folder, unless it was started on a link. + +## Cache + +What lazysecrets has loaded is kept in +`~/.cache/lazysecrets/cache.json` (`$XDG_CACHE_HOME`), readable only by +you: the Sources lists, vaults and scopes, and secrets' names and metadata +(tags, dates, references), never values. On the next start they show at +once, and anything older than two minutes reloads in the background when +you go to it, or when Go to searches it. Revealing and copying always +fetch the value live. + +Lists not loaded for 30 days are dropped, and so is everything from an +account you're no longer signed in to. Repos aren't cached: their files +are read from disk anyway. Deleting the file is always safe, and +`cache: false` stops using it. + ## Updates Once a day (`updateCheck`), in the background, lazysecrets asks the diff --git a/internal/config/config.go b/internal/config/config.go index d770861..713bca1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,9 @@ type Config struct { // background; defaults to true. U and lazysecrets update work either // way. UpdateCheck *bool `yaml:"updateCheck"` + // Cache keeps what was loaded (never values) between runs, so lists + // show at once and refresh in the background; defaults to true. + Cache *bool `yaml:"cache"` // CollapseGroups starts the Sources pane with its groups folded; // defaults to true. CollapseGroups *bool `yaml:"collapseGroups"` @@ -119,6 +122,12 @@ func Dir() (string, error) { return xdgDir("XDG_CONFIG_HOME", ".config") } +// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to +// ~/.cache/lazysecrets. It holds what was loaded, to show it at once. +func CacheDir() (string, error) { + return xdgDir("XDG_CACHE_HOME", ".cache") +} + // StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to // ~/.local/state/lazysecrets. It holds data the app writes, like favourites. func StateDir() (string, error) { diff --git a/internal/store/store.go b/internal/store/store.go index b7596c7..faf9dd0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,6 +69,33 @@ func (s *Store) SetUpdateCheck(c UpdateCheck) error { return s.save(func(d *file) { d.Update = c }) } +// Place is where lazysecrets was left when it last ran in dir. +func (s *Store) Place(dir string) (Place, bool) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.data.Places[dir] + return p, ok +} + +// SetPlace remembers where lazysecrets was left in dir. +func (s *Store) SetPlace(dir string, p Place) error { + return s.save(func(d *file) { + if d.Places == nil { + d.Places = map[string]Place{} + } + d.Places[dir] = p + for len(d.Places) > maxPlaces { + oldest := "" + for k, v := range d.Places { + if oldest == "" || v.At.Before(d.Places[oldest].At) { + oldest = k + } + } + delete(d.Places, oldest) + } + }) +} + // Layout is the user's pane sizing, as offsets from the default layout so // it adapts to the terminal size. type Layout struct { @@ -84,8 +111,22 @@ type file struct { Flags map[string]bool `json:"flags,omitempty"` // Update is the last check for a newer release. Update UpdateCheck `json:"update,omitzero"` + // Places is where lazysecrets was left, by the folder it ran in. + Places map[string]Place `json:"places,omitempty"` } +// Place is what was selected when lazysecrets quit: an item's link, and +// the account and source it was read through. +type Place struct { + Link string `json:"link"` + Account string `json:"account"` + Source string `json:"source"` + At time.Time `json:"at"` +} + +// maxPlaces bounds Places: the folders used most recently are kept. +const maxPlaces = 100 + // UpdateCheck is the last look for a newer release: when, and what the // latest was (its tag, changelog and page), so the next start within a // day shows it without asking again. diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index e1c0e07..cb2f331 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -68,6 +68,12 @@ type Options struct { ClipboardClear time.Duration // OpenURL opens a web page; defaults to the system browser. OpenURL func(url string) error + // CacheFile keeps what was loaded between runs; "" keeps it for this + // run only. + CacheFile string + // Dir is the folder lazysecrets runs in: it goes back to where it was + // left there, unless it was started on a link. "" doesn't. + Dir string // FlatSources lists sources without provider groups. FlatSources bool // CollapseGroups starts with the Sources pane's groups folded, when @@ -156,7 +162,13 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error - now func() time.Time + // The cache file, whether the cache changed since it was saved, and + // when each provider's Sources list was loaded (persist.go). + cachePath string + cacheDirty bool + sourcesAt map[string]time.Time + placeDir string + now func() time.Time // A pending clear of a copied value, counted down in the footer. // clipToken identifies the latest copy so older ticks are ignored. @@ -410,6 +422,17 @@ func New(providers []provider.Provider, opts Options) Model { m.sourcesGen = 1 m.sourcesPending = len(providers) m.spinning = true + + m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} + if m.cachePath != "" { + m.loadCacheFile() + if len(m.sourceResults) > 0 { + m.rebuildSources() + } + } + if m.placeDir != "" && opts.Open == nil { + m.restorePlace() + } return m } @@ -433,6 +456,9 @@ func (m *Model) setVersions(items []provider.Object) { func (m Model) Init() tea.Cmd { cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()} + if m.cachePath != "" { + cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })) + } if m.fetchOnStart { cmds = append(cmds, m.fetchLatest(false)) } @@ -522,6 +548,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case commandDoneMsg: m.handleCommandDone(msg) + case persistTickMsg: + cmds = append(cmds, m.persistTick()) + + case persistedMsg: + m.handlePersisted(msg) + case crawlMsg: cmds = append(cmds, m.handleCrawl(msg)) @@ -552,6 +584,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.containers.SetError(msg.err) } else { m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} + m.cacheDirty = true m.containers.SetItems(m.starredContainersFirst(msg.items)) } @@ -572,6 +605,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg.key = m.rebind(*msg.bound) } m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} + m.cacheDirty = true if m.versionsOf != nil { m.setVersions(msg.items) } else { @@ -601,6 +635,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Errors and unsupported providers just leave the count out. if msg.err == nil { m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} + m.cacheDirty = true m.detail.SetVersions(msg.id, msg.items) } @@ -614,6 +649,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.detail.SetDescribeError(msg.err) } else { m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} + m.cacheDirty = true m.detail.SetDescribed(msg.obj) } @@ -1203,6 +1239,9 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd { } m.sourcesPending-- m.sourceResults[msg.provider] = msg + if msg.err == nil { + m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true + } var errs []string for _, p := range m.providers { diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go index b722a0c..3bfe3ab 100644 --- a/internal/ui/app/cache.go +++ b/internal/ui/app/cache.go @@ -10,8 +10,8 @@ import ( // entries are still shown immediately, then reloaded in the background. const cacheTTL = 2 * time.Minute -// cache holds metadata for this session only. It never holds secret values -// or errors, and is never written to disk. +// cache holds metadata: never secret values or errors. It's kept between +// runs in the cache file (persist.go). type cache struct { containers map[string]entry[[]provider.Container] // by source key objects map[string]entry[[]provider.Object] // by container key (ID|type) diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index e7ed9df..7d7bbbd 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -43,6 +43,9 @@ type nav struct { sourceRow string // Sources row to select stage navStage + // quiet gives up without a word: going back to where lazysecrets + // was left, which may be gone. + quiet bool } type resolvedMsg struct { @@ -460,7 +463,9 @@ func (m *Model) addLinked(c provider.Container) { } func (m *Model) navFail(format string, a ...any) { - m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true} + if !m.nav.quiet { + m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true} + } m.nav = nil } diff --git a/internal/ui/app/persist.go b/internal/ui/app/persist.go new file mode 100644 index 0000000..8363079 --- /dev/null +++ b/internal/ui/app/persist.go @@ -0,0 +1,243 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// What was loaded is kept between runs in a cache file (Options.CacheFile): +// the Sources lists, vaults, secrets' metadata and where the cursor was in +// each. Starting again shows it at once, and lists older than cacheTTL +// reload in the background as usual. Values are never in it; neither are +// repos, whose files are read from disk anyway. +// +// And where lazysecrets was left, per folder it ran in, goes in the store, +// to go back there on the next start. + +// cacheFileVersion is the file's format; a file of another version is +// ignored, and replaced on the next save. +const cacheFileVersion = 1 + +// cacheMaxAge drops what hasn't been loaded again for this long. +const cacheMaxAge = 30 * 24 * time.Hour + +// persistEvery is how often a changed cache is saved while running, so +// little is lost if lazysecrets is killed rather than quit. +const persistEvery = 30 * time.Second + +type cacheFile struct { + Version int `json:"version"` + Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID + Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"` + Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"` + Details map[string]stamped[provider.Object] `json:"details,omitempty"` + Cursor map[string]string `json:"cursor,omitempty"` +} + +type stamped[T any] struct { + At time.Time `json:"at"` + Value T `json:"value"` +} + +type persistTickMsg struct{} + +type persistedMsg struct{ err error } + +// keepCached reports whether a cache key's account still exists and +// isn't a repo: every key starts with the provider ID. +func (m *Model) keepCached(key string) bool { + id, _ := splitSourceRow(key) + p, ok := m.byID[id] + return ok && p.Kind() != provider.KindRepo +} + +// loadCacheFile fills the cache from the file, leaving out what's too +// old or belongs to accounts that aren't signed in any more. +func (m *Model) loadCacheFile() { + b, err := os.ReadFile(m.cachePath) + if err != nil { + return + } + var f cacheFile + if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion { + return + } + now := m.now() + recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge } + for id, e := range f.Sources { + if m.keepCached(id+"|") && recent(e.At) { + m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} + m.sourcesAt[id] = e.At + } + } + for k, e := range f.Containers { + if m.keepCached(k) && recent(e.At) { + m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} + } + } + for k, e := range f.Objects { + if m.keepCached(k) && recent(e.At) { + m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} + } + } + for k, e := range f.Details { + if m.keepCached(k) && recent(e.At) { + m.cache.details[k] = entry[provider.Object]{e.Value, e.At} + } + } + for k, v := range f.Cursor { + if m.keepCached(k) { + m.cache.cursor[k] = v + } + } +} + +// cacheSnapshot is the cache as the file holds it. +func (m *Model) cacheSnapshot() cacheFile { + f := cacheFile{ + Version: cacheFileVersion, + Sources: map[string]stamped[[]provider.Source]{}, + Containers: map[string]stamped[[]provider.Container]{}, + Objects: map[string]stamped[[]provider.Object]{}, + Details: map[string]stamped[provider.Object]{}, + Cursor: map[string]string{}, + } + for id, r := range m.sourceResults { + if r.err == nil && m.keepCached(id+"|") { + f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources} + } + } + for k, e := range m.cache.containers { + if m.keepCached(k) { + f.Containers[k] = stamped[[]provider.Container]{e.at, e.value} + } + } + for k, e := range m.cache.objects { + if m.keepCached(k) { + f.Objects[k] = stamped[[]provider.Object]{e.at, e.value} + } + } + for k, e := range m.cache.details { + if m.keepCached(k) { + f.Details[k] = stamped[provider.Object]{e.at, e.value} + } + } + for k, v := range m.cache.cursor { + if m.keepCached(k) { + f.Cursor[k] = v + } + } + return f +} + +// writeCacheFile writes f atomically, readable by the user only. +func writeCacheFile(path string, f cacheFile) error { + b, err := json.Marshal(f) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // after a successful rename, nothing there + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// persistTick saves the cache in the background when it changed. +func (m *Model) persistTick() tea.Cmd { + next := tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }) + if !m.cacheDirty { + return next + } + m.cacheDirty = false + f, path := m.cacheSnapshot(), m.cachePath + return tea.Batch(next, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} }) +} + +// Persist saves the cache and where lazysecrets was left; run it after +// the program ends. +func (m Model) Persist() error { + var errs []error + if m.cachePath != "" { + errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot())) + } + if m.placeDir != "" { + if p, ok := m.place(); ok { + errs = append(errs, m.store.SetPlace(m.placeDir, p)) + } + } + return errors.Join(errs...) +} + +// place is the selected item, as deep as the focus. +func (m *Model) place() (store.Place, bool) { + src, ok := m.selectedSource() + if !ok || m.nav != nil { + return store.Place{}, false + } + var r ref.Ref + var err error + switch c, cok := m.containers.Selected(); { + case m.focus == paneSources || !cok: + r, err = ref.ForSource(src) + case m.focus == paneContainers: + r, err = ref.ForContainer(c, m.objType) + default: + o, ook := m.detail.Secret() + if !ook { + r, err = ref.ForContainer(c, m.objType) + } else { + r, err = ref.ForObject(o, "") + } + } + if err != nil { + return store.Place{}, false + } + return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true +} + +// restorePlace goes back to where lazysecrets was left in dir, if that +// account is still signed in. +func (m *Model) restorePlace() { + p, ok := m.store.Place(m.placeDir) + if !ok { + return + } + if _, ok := m.byID[p.Account]; !ok { + return + } + r, err := ref.Parse(p.Link) + if err != nil { + return + } + m.startNav(r, actGo, sourceRowKey(p.Account, p.Source)) + m.nav.sourceOnly = r.IsSource() + m.nav.quiet = true +} + +func (m *Model) handlePersisted(msg persistedMsg) { + if msg.err != nil { + m.noteErr(fmt.Errorf("saving the cache: %w", msg.err)) + } +} diff --git a/internal/ui/app/persist_test.go b/internal/ui/app/persist_test.go new file mode 100644 index 0000000..26cc9dc --- /dev/null +++ b/internal/ui/app/persist_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +func TestCacheFileShowsLastRunAtOnce(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json") + d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0)) + d.keys("3", "space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Fatalf("value not revealed:\n%s", d.screen()) + } + src, vault, secret := d.selected() + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "correct-horse") { + t.Error("a value is in the cache file") + } + if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 { + t.Errorf("cache file mode %v", fi.Mode().Perm()) + } + + // Next run, with a backend too slow to answer during the test: the + // lists show from the file, and the cursor is where it was. + d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute)) + d2.keys("2") + if _, v, _ := d2.selected(); v != vault { + t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen()) + } + d2.keys("3") + if s, v, o := d2.selected(); s != src || v != vault || o != secret { + t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen()) + } +} + +func TestCacheFileDropsOldAndSignedOut(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "cache.json") + az := fake.Azure(0) + now := time.Now() + f := cacheFile{ + Version: cacheFileVersion, + Containers: map[string]stamped[[]provider.Container]{ + az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}}, + az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}}, + "Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}}, + }, + } + if err := writeCacheFile(file, f); err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{CacheFile: file}, az) + c := d.m.cache.containers + if _, ok := c[az.ID()+"|recent"]; !ok { + t.Error("dropped a recent entry") + } + if _, ok := c[az.ID()+"|old"]; ok { + t.Error("kept an entry older than cacheMaxAge") + } + if _, ok := c["Azure:gone@x/t|recent"]; ok { + t.Error("kept an entry of an account that isn't signed in") + } +} + +func TestBackWhereItWasLeft(t *testing.T) { + t.Parallel() + st, _ := store.Load("") + dir := "/work/project" + d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0)) + d.keys("3", "j", "j") + src, vault, secret := d.selected() + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) { + t.Fatalf("place %+v", p) + } + + d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0)) + if s, v, o := d2.selected(); s != src || v != vault || o != secret { + t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen()) + } + + // Another folder starts as usual; so does opening a link. + d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0)) + if _, _, o := d3.selected(); o == secret { + t.Errorf("another folder went to %s too", o) + } + r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password") + d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0)) + if d4.m.nav != nil && d4.m.nav.quiet { + t.Error("a link was overridden by where it was left") + } +} diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 708c4e5..bcf2c87 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -232,11 +232,12 @@ func (m *Model) crawlStart() tea.Cmd { return cmd } -// queueSource queues listing src's vaults or, with them listed and not -// force, loading their secrets. +// queueSource queues listing src's vaults or, with them listed lately +// and not force, loading their secrets. Lists from an earlier run (the +// cache file) are listed again, so searching brings them up to date. func (m *Model) queueSource(src provider.Source, force bool) { row := sourceRowKey(src.Provider, src.ID) - if e, ok := m.cache.containers[row]; ok && !force { + if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force { for _, c := range e.value { m.queueContainer(c, row, false) } @@ -245,10 +246,11 @@ func (m *Model) queueSource(src provider.Source, force bool) { m.enqueue(crawlJob{src: &src, row: row, force: force}, row) } -// queueContainer queues loading c's secrets, unless loaded and not force. +// queueContainer queues loading c's secrets, unless loaded lately and not +// force. func (m *Model) queueContainer(c provider.Container, row string, force bool) { key := containerKey(c) + "|" + string(provider.TypeSecret) - if _, ok := m.cache.objects[key]; ok && !force { + if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force { return } m.enqueue(crawlJob{container: &c, row: row, force: force}, key) @@ -319,12 +321,14 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd { case msg.job.src != nil: delete(m.crawl.failed, key) m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()} + m.cacheDirty = true for _, c := range msg.containers { m.queueContainer(c, key, msg.job.force) } default: delete(m.crawl.failed, key) m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()} + m.cacheDirty = true } cmd := m.crawlNext() m.refreshSearch()