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
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.
-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 (
"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 <ref> print the value a lazysecrets:// link points at,
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
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()
-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
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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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" {