feat: what's expiring, in the status pane and lazysecrets expiring
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9b0329e9da
commit
c9bc4c1431
10 files changed
+364
-3
No files matched your search
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"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.
|
||||
|
||||
// errExpiring makes the exit status 1 when anything was listed.
|
||||
var errExpiring = errors.New("expiring")
|
||||
|
||||
// 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)
|
||||
warnErr = func(what string, err error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
failed++
|
||||
fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, 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)
|
||||
if err != nil {
|
||||
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
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()
|
||||
|
||||
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)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d lists couldn't be read", failed)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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") {
|
||||
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())
|
||||
}
|
||||
|
||||
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
|
||||
t.Error("negative days accepted")
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -33,7 +34,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
if err := run(); errors.Is(err, errExpiring) {
|
||||
os.Exit(1) // listed: a pipeline fails on it
|
||||
} else if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -118,6 +121,9 @@ 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
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -140,6 +146,7 @@ Flags:
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
var expiringArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
@@ -150,6 +157,8 @@ 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":
|
||||
@@ -298,6 +307,14 @@ 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 {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user