feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists #17

Merged
keb merged 17 commits from feat/l-for-details into main 2026-10-11 14:24:09 +02:00
4 changed files with 68 additions and 26 deletions
Showing only changes of commit bf374176df - Show all commits

No files matched your search

+46 -17
View File
@@ -1,11 +1,13 @@
package main
import (
"cmp"
"context"
"errors"
"flag"
"fmt"
"io"
"maps"
"slices"
"strings"
"sync"
@@ -21,8 +23,12 @@ import (
// dates come with the lists, so it's one request per vault and type,
// a few at a time. Only Key Vault has expiry dates.
// errExpiring makes the exit status 1 when anything was listed.
var errExpiring = errors.New("expiring")
// The exit status is grep's: 1 when anything was listed (errExpiring),
// else 2 when some lists couldn't be read (errIncomplete), else 0.
var (
errExpiring = errors.New("expiring")
errIncomplete = errors.New("incomplete")
)
// listLimit is how many lists load at once.
const listLimit = 8
@@ -47,19 +53,22 @@ func parseExpiring(args []string, days int) (int, error) {
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
var (
mu sync.Mutex
found []provider.Object
seen = map[string]bool{}
failed int
vaults int
wg sync.WaitGroup
limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days)
mu sync.Mutex
found []provider.Object
seen = map[string]bool{}
failed int
vaults int
wg sync.WaitGroup
limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days)
// A vault's lists that couldn't be read, said once per vault.
denied = map[string][]string{}
why = map[string]string{}
warnErr = func(what string, err error) {
mu.Lock()
defer mu.Unlock()
failed++
fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err)
fmt.Fprintf(errs, "lazysecrets: %s: %s\n", what, briefly(err))
}
)
run := func(f func(ctx context.Context)) {
@@ -109,12 +118,14 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer
for _, t := range c.ObjectTypes() {
run(func(ctx context.Context) {
objs, err := p.Objects(ctx, c, t)
if err != nil {
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
return
}
mu.Lock()
defer mu.Unlock()
if err != nil {
failed++
denied[c.Name] = append(denied[c.Name], strings.ToLower(string(t))+"s")
why[c.Name] = cmp.Or(why[c.Name], briefly(err))
return
}
for _, o := range objs {
if !o.Expires.IsZero() && o.Expires.Before(within) {
found = append(found, o)
@@ -130,12 +141,20 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer
// Listing vaults queues the lists of their secrets, so wait for both.
vaultsDone.Wait()
wg.Wait()
for _, name := range slices.Sorted(maps.Keys(denied)) {
fmt.Fprintf(errs, "lazysecrets: %s: can't list %s: %s\n", name, strings.Join(denied[name], ", "), why[name])
}
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
if len(found) == 0 {
fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults)
vault := "vaults"
if vaults == 1 {
vault = "vault"
}
fmt.Fprintf(errs, "nothing expires within %d days (checked %d of %d %s)\n", o.days, vaults-len(denied), vaults, vault)
if failed > 0 {
return fmt.Errorf("%d lists couldn't be read", failed)
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed)
return errIncomplete
}
return nil
}
@@ -161,3 +180,13 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer
}
return errExpiring
}
// briefly is err on one line: for a sign-in, the command that signs in.
func briefly(err error) string {
var auth *provider.AuthError
if errors.As(err, &auth) && len(auth.Login) > 0 {
return "sign-in required: " + strings.Join(auth.Login, " ")
}
line, _, _ := strings.Cut(err.Error(), "\n")
return line
}
+8 -1
View File
@@ -24,7 +24,7 @@ func TestExpiring(t *testing.T) {
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
t.Errorf("out:\n%s", out.String())
}
if !strings.Contains(errs.String(), "kv-restricted") {
if !strings.Contains(errs.String(), "kv-restricted: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 {
t.Errorf("unreadable vaults not said:\n%s", errs.String())
}
@@ -35,6 +35,13 @@ func TestExpiring(t *testing.T) {
t.Errorf("400 days:\n%s", out.String())
}
// Nothing listed, but not everything checked.
errs.Reset()
err = expiring(providers, expiringOpts{days: 0, timeout: time.Minute, now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, &out, &errs)
if !errors.Is(err, errIncomplete) {
t.Errorf("err %v, want errIncomplete", err)
}
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
t.Error("negative days accepted")
}
+9 -4
View File
@@ -34,9 +34,12 @@ import (
)
func main() {
if err := run(); errors.Is(err, errExpiring) {
switch err := run(); {
case errors.Is(err, errExpiring):
os.Exit(1) // listed: a pipeline fails on it
} else if err != nil {
case errors.Is(err, errIncomplete):
os.Exit(2)
case err != nil:
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1)
}
@@ -123,7 +126,8 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] expiring [--days N]
list what has expired or will within N days
(default 30) in every vault; exits 1 if any
(default 30) in every vault; exits 1 if any,
2 if it couldn't check everything
lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME]
@@ -310,7 +314,8 @@ Flags:
if expiringArgs != nil {
days, err := parseExpiring(expiringArgs, expiryDays)
if err != nil {
return err
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
return errIncomplete // 2, not 1: nothing was checked
}
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
}
+5 -4
View File
@@ -72,8 +72,7 @@ with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest.
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
vault of every account, loading each vault's lists (a few at a time), and
exits 1 when it lists anything:
vault of every account, loading each vault's lists (a few at a time):
```sh
$ lazysecrets expiring --days 30
@@ -82,8 +81,10 @@ expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-a
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
```
`--days` defaults to `expiryWarningDays`. Lists it can't read are named on
stderr.
`--days` defaults to `expiryWarningDays`. Accounts that need a sign-in and
lists it can't read are named on stderr, one line each. Like grep, it
exits 0 when nothing expires, 1 when it lists something, and 2 when it
couldn't check everything.
On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and