refactor: no expiring command, and a file's last commit is its hash and age #20

Merged
keb merged 2 commits from refactor/trim-expiring-commit into main 2026-10-11 23:27:12 +02:00
7 changed files with 4 additions and 282 deletions

No files matched your search

+1 -2
View File
@@ -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 - **Favourites**: star the secrets you use most and get back to them in one
key. key.
- **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, and `lazysecrets expiring` marked, and listed together in the status pane.
checks every vault from a pipeline.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
-192
View File
@@ -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
}
-48
View File
@@ -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")
}
}
+1 -23
View File
@@ -4,7 +4,6 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -34,12 +33,7 @@ import (
) )
func main() { func main() {
switch err := run(); { if err := run(); err != nil {
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:
fmt.Fprintln(os.Stderr, "lazysecrets:", err) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -124,10 +118,6 @@ func run() error {
@Microsoft.KeyVault(...) or {{secrets/scope/key}} @Microsoft.KeyVault(...) or {{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] 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 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]
@@ -150,7 +140,6 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var expiringArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
@@ -161,8 +150,6 @@ Flags:
return err return err
} }
getRef = &r getRef = &r
case len(args) > 0 && args[0] == "expiring":
expiringArgs = append([]string{}, args[1:]...)
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -311,15 +298,6 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) 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 { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
-15
View File
@@ -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 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
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, 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
purge protection, which networks can reach it, private endpoints and tags. purge protection, which networks can reach it, private endpoints and tags.
+1 -1
View File
@@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil 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}) out = append(out, provider.Attr{Label: "last commit", Value: last})
} }
return out, nil return out, nil
+1 -1
View File
@@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) {
d := details("secrets/app.yaml") d := details("secrets/app.yaml")
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || 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) t.Errorf("sops yaml: %+v", d)
} }
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {