feat: lazysecrets list prints every source, vault and secret, for fzf and editor pickers

This commit is contained in:
keb committed 2026-10-14 21:24:01 +02:00
1 parent dbb0eb2bbd
commit b1455451e6
13 files changed
+885 -90

No files matched your search

+3 -2
View File
@@ -20,7 +20,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes. pipes, and `lazysecrets list` everything it can find, for fzf and editor
pickers.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
@@ -86,7 +87,7 @@ use most:
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting - [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts, - [Links](docs/links.md): sharing, opening, `lazysecrets get` and `list` in scripts,
and opening links from the browser and chat and opening links from the browser and chat
- [Custom commands](docs/custom-commands.md): run your own commands on a - [Custom commands](docs/custom-commands.md): run your own commands on a
secret secret
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts:
//
// lazysecrets list | fzf --delimiter='\t' --with-nth=3 | cut -f1
//
// 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 on stderr.
func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, args []string, out, errOut io.Writer) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: link, kind and name, tab-separated; json: an object per line")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("usage: lazysecrets list [--cached] [--max-age 2m] [--format text|json]")
}
var write func(listing.Entry) error
switch *format {
case "text":
write = func(e listing.Entry) error {
name := strings.Join(e.Path, " / ")
if e.Account != "" {
name += " · " + e.Account
}
_, err := fmt.Fprintf(out, "%s\t%s\t%s\n", e.Link, e.Kind, name)
return err
}
case "json":
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
write = func(e listing.Entry) error { return enc.Encode(e) }
default:
return fmt.Errorf("--format: %q is not text or json", *format)
}
// When the reader goes away (a pick in fzf), writing fails instead of
// killing the process, so what loaded is still saved.
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
var gone error
emit := func(e listing.Entry) error {
if err := write(e); err != nil {
gone = err
return err
}
return nil
}
f := cachefile.New()
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, errs := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Timeout: timeout,
}, emit)
if cachePath != "" && !*cached {
byID := map[string]provider.Provider{}
for _, p := range providers {
byID[p.ID()] = p
}
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
errs = append(errs, err)
}
}
if gone != nil {
if errors.Is(gone, syscall.EPIPE) {
return nil // the reader has what it wanted
}
return gone
}
for _, err := range errs {
fmt.Fprintln(errOut, "lazysecrets: list:", err)
}
return nil
}
+14 -1
View File
@@ -121,6 +121,10 @@ 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]
print every source, vault and secret it can
find, one per line, for fzf and pickers:
what's cached at once, then what loads
lazysecrets [flags] share [--key <secret>] <env file>... lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted print a link to env files of a repo, encrypted
with a secret's value (default: sharing.key) with a secret's value (default: sharing.key)
@@ -146,7 +150,7 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var shareArgs []string var shareArgs, listArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share": case len(args) > 0 && args[0] == "share":
@@ -154,6 +158,8 @@ Flags:
if len(shareArgs) == 0 { if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how shareArgs = []string{} // still sharing: says how
} }
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
printKeys = true printKeys = true
case len(args) == 2 && args[0] == "get": case len(args) == 2 && args[0] == "get":
@@ -345,6 +351,13 @@ Flags:
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) return get(ctx, providers, st, *getRef)
} }
if listArgs != nil {
cachePath := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, cachePath, timeoutFor, listArgs, os.Stdout, 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
// in: the longest timeout. // in: the longest timeout.
+2 -1
View File
@@ -140,7 +140,8 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
once, and anything older than two minutes reloads in the background when once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. While it does, the pane's you go to it, or when Go to searches it. While it does, the pane's
bottom border says how old what it shows is (`cached 3h ago`). Revealing bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live. and copying always fetch the value live. [`lazysecrets list`](links.md#listing-for-fzf-and-pickers)
reads it and adds to it too.
Lists not loaded for 30 days are dropped, and so is everything from an 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 account you're no longer signed in to. Repos aren't cached: their files
+29
View File
@@ -136,6 +136,35 @@ it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. The value ends with of the repo; a version in the link reads that version. The value ends with
a newline only on a terminal. a newline only on a terminal.
### Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line:
its link, its kind (`source`, `vault`, `secret`, `key`, `certificate`)
and its name, tab-separated. It's what Go to searches, so fzf, or an
editor's picker, can search it instead:
```sh
lazysecrets list | fzf --delimiter='\t' --with-nth=3 \
--bind 'enter:become(lazysecrets open {1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get {1} | pbcopy)'
```
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
what's missing or older than `--max-age` (2m), and what's new in it follows
as it arrives: fzf shows it while you type. `--cached` loads nothing from
Azure or Databricks, for an answer at once. Repos are always read, from
disk. What it loads is cached for the TUI too. Lists it can't read are
reported on stderr; it stops when the reader goes, after a pick in fzf.
`--format json` prints an object per line instead, for editor plugins:
```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"}
```
`account` is there when several az accounts are signed in. Values are never
listed.
## From the browser and chat ## From the browser and chat
```sh ```sh
+146
View File
@@ -0,0 +1,146 @@
// Package cachefile is the cache of what lazysecrets has loaded
// (cache.json): the Sources lists, vaults, secrets' metadata and where
// the cursor was in each, never values. The TUI and lazysecrets list
// both read it and save to it; a save merges with what's on disk, so
// neither undoes what the other loaded.
package cachefile
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Version is the file's format; a file of another version is ignored,
// and replaced on the next save.
const Version = 1
// MaxAge drops what hasn't been loaded again for this long.
const MaxAge = 30 * 24 * time.Hour
// File is the cache. Every key starts with the provider ID: Sources are
// keyed by it, the rest by Row, Objects' keys and the like.
type File 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"`
}
// Stamped is a list or detail with when it was loaded.
type Stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
// New is an empty cache.
func New() File {
return File{
Version: Version,
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{},
}
}
// Row keys a source's vaults or scopes.
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
// ContainerKey keys what's cached about a vault or scope.
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// ObjectsKey keys a vault's secrets, keys or certificates.
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
return ContainerKey(c) + "|" + string(t)
}
// ProviderOf is the provider ID a key starts with.
func ProviderOf(key string) string {
id, _, _ := strings.Cut(key, "|")
return id
}
// Read reads path; a missing, unreadable or other version's file is an
// empty cache.
func Read(path string) File {
f := New()
b, err := os.ReadFile(path)
if err != nil {
return f
}
var d File
if json.Unmarshal(b, &d) != nil || d.Version != Version {
return f
}
maps.Copy(f.Sources, d.Sources)
maps.Copy(f.Containers, d.Containers)
maps.Copy(f.Objects, d.Objects)
maps.Copy(f.Details, d.Details)
maps.Copy(f.Cursor, d.Cursor)
return f
}
// Save writes f to path, merged with the file there: of a list in both,
// the one loaded last wins, and f's cursors win. Entries older than
// MaxAge, and those of providers keep says no to (accounts signed out
// of), are dropped, from the file's side as well.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok)
merge(out.Containers, disk.Containers, f.Containers, ok)
merge(out.Objects, disk.Objects, f.Objects, ok)
merge(out.Details, disk.Details, f.Details, ok)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m {
if keep(ProviderOf(k)) {
out.Cursor[k] = v
}
}
}
return write(path, out)
}
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) {
for _, m := range []map[string]Stamped[T]{disk, ours} {
for k, e := range m {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
}
}
}
}
// write writes f atomically, readable by the user only.
func write(path string, f File) 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)
}
+91
View File
@@ -0,0 +1,91 @@
package cachefile
import (
"path/filepath"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
var cs []provider.Container
for _, n := range names {
cs = append(cs, provider.Container{Name: n})
}
return Stamped[[]provider.Container]{At: at, Value: cs}
}
func TestSaveMerges(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
// The TUI saved two lists.
tui := New()
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
tui.Cursor["az|b"] = "kv-b"
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
// lazysecrets list loads a again, and c.
listed := Read(path)
listed.Containers["az|a"] = list(now, "kv-a-new")
listed.Containers["az|c"] = list(now, "kv-c")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
// The TUI saves what it has, older: list's lists stay.
if err := Save(path, tui, all, now); err != nil {
t.Fatal(err)
}
f := Read(path)
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
t.Errorf("%s = %v, want %s", key, got, want)
}
}
if f.Cursor["az|b"] != "kv-b" {
t.Error("cursor lost")
}
}
func TestSaveDrops(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
f := New()
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
f.Containers["gone|x"] = list(now, "kv-gone")
f.Containers["az|x"] = list(now, "kv-x")
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
t.Fatal(err)
}
// Signed out of "gone" since: dropped from the file too.
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
t.Fatal(err)
}
got := Read(path).Containers
if _, ok := got["az|old"]; ok {
t.Error("kept a list older than MaxAge")
}
if _, ok := got["gone|x"]; ok {
t.Error("kept a signed-out account's list")
}
if _, ok := got["az|x"]; !ok {
t.Error("dropped a list on disk the save didn't have")
}
}
func TestReadOtherVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
f := New()
f.Version = Version + 1
f.Containers["az|x"] = list(time.Now(), "kv-x")
if err := write(path, f); err != nil {
t.Fatal(err)
}
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
t.Errorf("read another version's file: %+v", got)
}
}
+285
View File
@@ -0,0 +1,285 @@
// 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"
"fmt"
"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
// 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 az account it's seen through, when there are several.
Account string `json:"account,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
Timeout func(provider.Kind) time.Duration
Now func() time.Time
}
// Run lists everything, calling emit for each entry once, from this
// goroutine. An error from emit (the reader went away) stops it. It
// returns the cache with what was loaded, and the lists that couldn't be.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, []error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
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.errs
}
type job struct {
p provider.Provider
src *provider.Source // list its vaults
c *provider.Container // list its secrets
key string // where the list is cached
}
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
queue []job
active int
results chan result
errs []error
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()
if r, err := ref.ForSource(src); err == nil {
l.put(p, Entry{Link: r.String(), Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, Account: accountOf(src)})
}
row := cachefile.Row(p.ID(), src.ID)
e, ok := l.o.Cache.Containers[row]
if l.cacheable(p) && ok {
l.containers(p, src, e.Value)
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, src: &src, key: row})
}
}
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
c.Source = src
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
l.put(p, Entry{Link: r.String(), Kind: "vault", Provider: kindOf(src.Kind), Path: []string{src.Name, c.Name}, Account: accountOf(src)})
}
key := cachefile.ObjectsKey(c, provider.TypeSecret)
e, ok := l.o.Cache.Objects[key]
if l.cacheable(p) {
// Keys and certificates only as far as they were opened in
// the TUI: Go to doesn't load them either.
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
l.objects(p, c, l.o.Cache.Objects[cachefile.ObjectsKey(c, t)].Value)
}
}
if l.load(p, ok, e.At) {
l.enqueue(job{p: p, c: &c, 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}, Account: accountOf(c.Source)})
}
}
// put emits e, unless it was already (or emitting has failed).
func (l *lister) put(p provider.Provider, e Entry) {
id := p.ID() + "\x00" + e.Link
if l.stopped != nil || l.seen[id] {
return
}
l.seen[id] = true
l.stopped = l.emit(e)
}
func (l *lister) enqueue(j job) {
if l.pending[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, provider.TypeSecret)
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.errs = append(l.errs, fmt.Errorf("%s: %w", r.label(), r.err))
return
}
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)
}
}
}
// label names a list that couldn't be loaded.
func (r result) label() string {
switch {
case r.c != nil:
return r.c.Source.Name + " / " + r.c.Name
case r.src != nil:
return r.src.Name
}
return r.p.Label()
}
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
}
+176
View File
@@ -0,0 +1,176 @@
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)
}
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []error) {
t.Helper()
if o.Cache.Version == 0 {
o.Cache = cachefile.New()
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got []Entry
f, errs := Run(context.Background(), o, func(e Entry) error {
got = append(got, e)
return nil
})
return got, f, errs
}
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 || !strings.Contains(errs[0].Error(), "kv-restricted: 403") {
t.Errorf("errs = %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)
}
}
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
_, errs := Run(context.Background(), Options{
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
Timeout: func(provider.Kind) time.Duration { return time.Second },
}, func(Entry) error {
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)
}
}
+27 -76
View File
@@ -1,14 +1,12 @@
package app package app
import ( import (
"encoding/json"
"errors" "errors"
"os"
"path/filepath"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -23,58 +21,35 @@ import (
// And where lazysecrets was left, per folder it ran in, goes in the store, // And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start. // 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 // persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit. // little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second 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 persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and // keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID. // isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
id, _ := splitSourceRow(key)
p, ok := m.byID[id] func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) }
func keepIn(byID map[string]provider.Provider) func(id string) bool {
return func(id string) bool {
p, ok := byID[id]
return ok && p.Kind() != provider.KindRepo return ok && p.Kind() != provider.KindRepo
} }
}
// loadCacheFile fills the cache from the file, leaving out what's too // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() { func (m *Model) loadCacheFile() {
b, err := os.ReadFile(m.cachePath) f := cachefile.Read(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now() now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge } recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources { for id, e := range f.Sources {
if m.keepCached(id+"|") && recent(e.At) { if m.keepProvider(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At m.sourcesAt[id] = e.At
} }
@@ -102,33 +77,26 @@ func (m *Model) loadCacheFile() {
} }
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cacheFile { func (m *Model) cacheSnapshot() cachefile.File {
f := cacheFile{ f := cachefile.New()
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 { for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") { if r.err == nil && m.keepProvider(id) {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
} }
} }
for k, e := range m.cache.containers { for k, e := range m.cache.containers {
if m.keepCached(k) { if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value} f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.objects { for k, e := range m.cache.objects {
if m.keepCached(k) { if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value} f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.details { for k, e := range m.cache.details {
if m.keepCached(k) { if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value} f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, v := range m.cache.cursor { for k, v := range m.cache.cursor {
@@ -139,28 +107,11 @@ func (m *Model) cacheSnapshot() cacheFile {
return f return f
} }
// writeCacheFile writes f atomically, readable by the user only. // saveCache saves f, merged with the file (lazysecrets list may have
func writeCacheFile(path string, f cacheFile) error { // loaded more since), from any goroutine.
b, err := json.Marshal(f) func (m *Model) saveCache(f cachefile.File) func() error {
if err != nil { path, keep, now := m.cachePath, keepIn(m.byID), m.now()
return err return func() error { return cachefile.Save(path, f, keep, now) }
}
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, and where lazysecrets is, in the // persistTick saves the cache, and where lazysecrets is, in the
@@ -169,8 +120,8 @@ func (m *Model) persistTick() tea.Cmd {
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })} cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
if m.cacheDirty && m.cachePath != "" { if m.cacheDirty && m.cachePath != "" {
m.cacheDirty = false m.cacheDirty = false
f, path := m.cacheSnapshot(), m.cachePath save := m.saveCache(m.cacheSnapshot())
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} }) cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
} }
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace { if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
m.savedPlace = p.Link m.savedPlace = p.Link
@@ -185,7 +136,7 @@ func (m *Model) persistTick() tea.Cmd {
func (m Model) Persist() error { func (m Model) Persist() error {
var errs []error var errs []error
if m.cachePath != "" { if m.cachePath != "" {
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot())) errs = append(errs, m.saveCache(m.cacheSnapshot())())
} }
if m.placeDir != "" { if m.placeDir != "" {
if p, ok := m.place(); ok { if p, ok := m.place(); ok {
+11 -8
View File
@@ -1,12 +1,14 @@
package app package app
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -80,15 +82,16 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
file := filepath.Join(t.TempDir(), "cache.json") file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0) az := fake.Azure(0)
now := time.Now() now := time.Now()
f := cacheFile{ f := cachefile.File{
Version: cacheFileVersion, Version: cachefile.Version,
Containers: map[string]stamped[[]provider.Container]{ Containers: map[string]cachefile.Stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}}, az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}}, az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}}, "Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
}, },
} }
if err := writeCacheFile(file, f); err != nil { b, _ := json.Marshal(f) // as an older lazysecrets left it
if err := os.WriteFile(file, b, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := newDriverOpts(t, Options{CacheFile: file}, az) d := newDriverOpts(t, Options{CacheFile: file}, az)
@@ -97,7 +100,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Error("dropped a recent entry") t.Error("dropped a recent entry")
} }
if _, ok := c[az.ID()+"|old"]; ok { if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge") t.Error("kept an entry older than cachefile.MaxAge")
} }
if _, ok := c["Azure:gone@x/t|recent"]; ok { if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in") t.Error("kept an entry of an account that isn't signed in")
+2 -1
View File
@@ -5,13 +5,14 @@ import (
"fmt" "fmt"
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
// containerKey identifies a container in the UI and caches. The same vault // containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each // seen through two accounts must not share cached lists, since each
// account may see different things. // account may see different things.
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
// providerFor finds the provider instance a source came from. Sources // providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that // remembered from an earlier run (favourites) may name an account that
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
@@ -39,7 +40,7 @@ type sourceRow struct {
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in // sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant). // either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID } func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
func splitSourceRow(row string) (providerID, sourceID string) { func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|") providerID, sourceID, _ = strings.Cut(row, "|")