diff --git a/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go index 7975ee0..49dc5c2 100644 --- a/cmd/lazysecrets/expiring.go +++ b/cmd/lazysecrets/expiring.go @@ -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 +} diff --git a/cmd/lazysecrets/expiring_test.go b/cmd/lazysecrets/expiring_test.go index 24faa2b..924e5db 100644 --- a/cmd/lazysecrets/expiring_test.go +++ b/cmd/lazysecrets/expiring_test.go @@ -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") } diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 110066f..79f5584 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -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 ) 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) } diff --git a/docs/azure.md b/docs/azure.md index 94df010..753654d 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -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