From 513dff0db7353a57d7b0d61f015806c2ff30f9ff Mon Sep 17 00:00:00 2001 From: keb Date: Sun, 11 Oct 2026 23:27:12 +0200 Subject: [PATCH] refactor: no `expiring` command, and a file's last commit is its hash and age (#20) Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 +- cmd/lazysecrets/expiring.go | 192 ---------------------------- cmd/lazysecrets/expiring_test.go | 48 ------- cmd/lazysecrets/main.go | 24 +--- docs/azure.md | 15 --- internal/provider/repo/details.go | 2 +- internal/provider/repo/repo_test.go | 2 +- 7 files changed, 4 insertions(+), 282 deletions(-) delete mode 100644 cmd/lazysecrets/expiring.go delete mode 100644 cmd/lazysecrets/expiring_test.go diff --git a/README.md b/README.md index 87ab4b6..87132ff 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti - **Favourites**: star the secrets you use most and get back to them in one key. - **Expiry warnings**: secrets, keys and certificates that expire soon are - marked, listed together in the status pane, and `lazysecrets expiring` - checks every vault from a pipeline. + marked, and listed together in the status pane. - **Your way**: rebind any key, pick a theme, and add custom commands to run on a secret. - **Try it first**: `lazysecrets --demo` runs on built-in sample data. diff --git a/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go deleted file mode 100644 index 49dc5c2..0000000 --- a/cmd/lazysecrets/expiring.go +++ /dev/null @@ -1,192 +0,0 @@ -package main - -import ( - "cmp" - "context" - "errors" - "flag" - "fmt" - "io" - "maps" - "slices" - "strings" - "sync" - "text/tabwriter" - "time" - - "git.kebcloud.no/keb/lazysecrets/internal/provider" - "git.kebcloud.no/keb/lazysecrets/internal/ref" -) - -// lazysecrets expiring lists what has expired, or will within --days, -// in every vault of every account: for a pipeline or a cron job. Expiry -// 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. - -// 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 - -type expiringOpts struct { - days int - timeout time.Duration - now time.Time -} - -func parseExpiring(args []string, days int) (int, error) { - fs := flag.NewFlagSet("expiring", flag.ContinueOnError) - n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)") - if err := fs.Parse(args); err != nil { - return 0, err - } - if fs.NArg() > 0 || *n < 0 { - return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]") - } - return *n, nil -} - -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) - // 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: %s\n", what, briefly(err)) - } - ) - run := func(f func(ctx context.Context)) { - wg.Add(1) - go func() { - defer wg.Done() - limit <- struct{}{} - defer func() { <-limit }() - ctx, cancel := context.WithTimeout(context.Background(), o.timeout) - defer cancel() - f(ctx) - }() - } - var vaultsDone sync.WaitGroup - for _, p := range providers { - if p.Kind() != provider.KindAzure { - continue - } - vaultsDone.Add(1) - run(func(ctx context.Context) { - defer vaultsDone.Done() - srcs, err := p.Sources(ctx) - if err != nil { - warnErr(p.Label(), err) - return - } - for _, src := range srcs { - vaultsDone.Add(1) - run(func(ctx context.Context) { - defer vaultsDone.Done() - cs, err := p.Containers(ctx, src) - if err != nil { - warnErr(src.Name, err) - return - } - for _, c := range cs { - mu.Lock() - fresh := !seen[c.ID] - seen[c.ID] = true - if fresh { - vaults++ - } - mu.Unlock() - if !fresh { // the same vault through another account - continue - } - for _, t := range c.ObjectTypes() { - run(func(ctx context.Context) { - objs, err := p.Objects(ctx, c, t) - 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) - } - } - }) - } - } - }) - } - }) - } - // 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 { - 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 { - fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed) - return errIncomplete - } - return nil - } - tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK") - for _, x := range found { - left := "expired" - if d := x.Expires.Sub(o.now); d > 0 { - left = fmt.Sprintf("%dd", int(d.Hours()/24)) - } - link := "" - if r, err := ref.ForObject(x, ""); err == nil { - link = r.String() - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"), - strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link) - } - if err := tw.Flush(); err != nil { - return err - } - if failed > 0 { - fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed) - } - 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 deleted file mode 100644 index 924e5db..0000000 --- a/cmd/lazysecrets/expiring_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "strings" - "testing" - "time" - - "git.kebcloud.no/keb/lazysecrets/internal/provider" - "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" -) - -func TestExpiring(t *testing.T) { - providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)} - now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) - var out, errs bytes.Buffer - err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs) - if !errors.Is(err, errExpiring) { - t.Fatalf("err %v (want errExpiring: something listed)", err) - } - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") || - !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: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 { - t.Errorf("unreadable vaults not said:\n%s", errs.String()) - } - - // Further ahead, the soonest first. - out.Reset() - expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs) - if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") { - 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 79f5584..571de9e 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -4,7 +4,6 @@ package main import ( "context" - "errors" "flag" "fmt" "io" @@ -34,12 +33,7 @@ import ( ) func main() { - switch err := run(); { - case errors.Is(err, errExpiring): - os.Exit(1) // listed: a pipeline fails on it - case errors.Is(err, errIncomplete): - os.Exit(2) - case err != nil: + if err := run(); err != nil { fmt.Fprintln(os.Stderr, "lazysecrets:", err) os.Exit(1) } @@ -124,10 +118,6 @@ func run() error { @Microsoft.KeyVault(...) or {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, 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, - 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] @@ -150,7 +140,6 @@ Flags: } var open, getRef *ref.Ref - var expiringArgs []string printKeys := false switch args := flag.Args(); { case len(args) == 1 && args[0] == "keybindings": @@ -161,8 +150,6 @@ Flags: return err } getRef = &r - case len(args) > 0 && args[0] == "expiring": - expiringArgs = append([]string{}, args[1:]...) case len(args) > 0 && args[0] == "handler": return handler(*configPath, args[1:]) case len(args) > 0 && args[0] == "launch": @@ -311,15 +298,6 @@ Flags: // the standard logger, which would draw over the TUI. log.SetOutput(io.Discard) - if expiringArgs != nil { - days, err := parseExpiring(expiringArgs, expiryDays) - if err != nil { - 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) - } - if getRef != nil { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() diff --git a/docs/azure.md b/docs/azure.md index 6a20e0f..855034b 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -71,21 +71,6 @@ on one goes to it. That makes no requests of its own: expiry dates come 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): - -```sh -$ lazysecrets expiring --days 30 -LEFT EXPIRES TYPE VAULT NAME LINK -expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls -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`. 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 purge protection, which networks can reach it, private endpoints and tags. diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go index 44e066d..0ffc1e3 100644 --- a/internal/provider/repo/details.go +++ b/internal/provider/repo/details.go @@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide } return out, nil } - if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" { + if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" { out = append(out, provider.Attr{Label: "last commit", Value: last}) } return out, nil diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go index 5c599f7..55b11dd 100644 --- a/internal/provider/repo/repo_test.go +++ b/internal/provider/repo/repo_test.go @@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) { d := details("secrets/app.yaml") if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || - !strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" { + !strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" { t.Errorf("sops yaml: %+v", d) } if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {