fix: lazysecrets expiring says each problem once, and exits like grep
CI / Checks (pull_request) Successful in 20s
PR title / Conventional title (pull_request) Successful in 1s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 13:57:46 +02:00
1 parent b64ca81dfe
commit bf374176df
4 files changed
+68 -26

No files matched your search

+46 -17
View File
@@ -1,11 +1,13 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"maps"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -21,8 +23,12 @@ import (
// dates come with the lists, so it's one request per vault and type, // 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. // a few at a time. Only Key Vault has expiry dates.
// errExpiring makes the exit status 1 when anything was listed. // The exit status is grep's: 1 when anything was listed (errExpiring),
var errExpiring = errors.New("expiring") // 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. // listLimit is how many lists load at once.
const listLimit = 8 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 { func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
var ( var (
mu sync.Mutex mu sync.Mutex
found []provider.Object found []provider.Object
seen = map[string]bool{} seen = map[string]bool{}
failed int failed int
vaults int vaults int
wg sync.WaitGroup wg sync.WaitGroup
limit = make(chan struct{}, listLimit) limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days) 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) { warnErr = func(what string, err error) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
failed++ 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)) { 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() { for _, t := range c.ObjectTypes() {
run(func(ctx context.Context) { run(func(ctx context.Context) {
objs, err := p.Objects(ctx, c, t) objs, err := p.Objects(ctx, c, t)
if err != nil {
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
return
}
mu.Lock() mu.Lock()
defer mu.Unlock() 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 { for _, o := range objs {
if !o.Expires.IsZero() && o.Expires.Before(within) { if !o.Expires.IsZero() && o.Expires.Before(within) {
found = append(found, o) 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. // Listing vaults queues the lists of their secrets, so wait for both.
vaultsDone.Wait() vaultsDone.Wait()
wg.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) }) slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
if len(found) == 0 { 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 { 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 return nil
} }
@@ -161,3 +180,13 @@ func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer
} }
return errExpiring 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") { !strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
t.Errorf("out:\n%s", out.String()) 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()) 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()) 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 { if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
t.Error("negative days accepted") t.Error("negative days accepted")
} }
+9 -4
View File
@@ -34,9 +34,12 @@ import (
) )
func main() { 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 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) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -123,7 +126,8 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] expiring [--days N] lazysecrets [flags] expiring [--days N]
list what has expired or will within N days 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 lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -310,7 +314,8 @@ Flags:
if expiringArgs != nil { if expiringArgs != nil {
days, err := parseExpiring(expiringArgs, expiryDays) days, err := parseExpiring(expiringArgs, expiryDays)
if err != nil { 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) 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. loads the rest.
For a pipeline or a cron job, `lazysecrets expiring` lists them in every 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 vault of every account, loading each vault's lists (a few at a time):
exits 1 when it lists anything:
```sh ```sh
$ lazysecrets expiring --days 30 $ 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 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 `--days` defaults to `expiryWarningDays`. Accounts that need a sign-in and
stderr. 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, 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 SKU, whether access is by Azure roles or access policies, soft delete and