feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists #17

Merged
keb merged 17 commits from feat/l-for-details into main 2026-10-11 14:24:09 +02:00
10 changed files with 364 additions and 3 deletions
Showing only changes of commit b64ca81dfe - Show all commits

No files matched your search

+2 -1
View File
@@ -23,7 +23,8 @@ 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. marked, listed together in the status pane, and `lazysecrets expiring`
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.
+163
View File
@@ -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
}
+41
View File
@@ -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
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -33,7 +34,9 @@ import (
) )
func main() { 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) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -118,6 +121,9 @@ 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
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]
@@ -140,6 +146,7 @@ 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":
@@ -150,6 +157,8 @@ 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":
@@ -298,6 +307,14 @@ 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 {
return err
}
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()
+20
View File
@@ -65,6 +65,26 @@ key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within fetched. `v` lists an item's versions. Items expiring within
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. `expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
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), and
exits 1 when it lists anything:
```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`. Lists it can't read are named on
stderr.
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.
+14
View File
@@ -858,6 +858,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneDetail // its rows are on the right m.focus = paneDetail // its rows are on the right
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup(): case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group // folded or unfolded a provider group
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
r, err := ref.Parse(m.fieldLink())
if err == nil {
f, _ := m.fields.Selected()
m.startNav(r, actGo, f.Row)
}
case key.Matches(msg, k.Enter): case key.Matches(msg, k.Enter):
if m.focus < paneDetail { if m.focus < paneDetail {
m.focus++ m.focus++
@@ -1778,6 +1784,14 @@ func (m Model) problemText(providerID, err string) string {
return err return err
} }
// fieldLink is the link of the selected Fields row, if it has one.
func (m Model) fieldLink() string {
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
return f.Link
}
return ""
}
// loginFor is the sign-in L runs: the selected account's, if it needs // loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for. // one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError { func (m Model) loginFor() *provider.AuthError {
+22
View File
@@ -1290,3 +1290,25 @@ func TestContainerDetails(t *testing.T) {
t.Errorf("state %+v", st) t.Errorf("state %+v", st)
} }
} }
func TestExpiringInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
scr := d.screen()
for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" })
d.keys("enter")
if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
}
}
+75
View File
@@ -0,0 +1,75 @@
package app
import (
"cmp"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists what has expired or will soon, across every vault
// loaded (opened, searched, or from the cache): expiry dates come with
// the lists, so this makes no requests. enter on one goes to it.
type expiringItem struct {
o provider.Object
row string // its source's row key, to go to it
}
// expiringItems are the loaded objects that have expired or will within
// the warning period, soonest first.
func (m Model) expiringItems() []expiringItem {
var out []expiringItem
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
}
}
}
}
slices.SortFunc(out, func(a, b expiringItem) int {
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
})
return out
}
// expiringFields are the status pane's rows for them, and how much of
// everything they cover.
func (m Model) expiringFields() []panes.Field {
items := m.expiringItems()
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
out = append(out, f)
}
cov := m.coverageOf(provider.KindAzure)
switch {
case cov.vaults == 0 && len(items) == 0:
return nil
case !cov.complete():
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
case len(items) == 0:
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
}
out[0].Label = "expiring"
return out
}
+5
View File
@@ -38,6 +38,9 @@ func (m Model) statusLine() string {
failing++ failing++
} }
} }
if n := len(m.expiringItems()); n > 0 {
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
}
if accounts > 0 { if accounts > 0 {
s := plural(accounts, "account") s := plural(accounts, "account")
if failing > 0 { if failing > 0 {
@@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field {
add(f) add(f)
} }
out = append(out, m.expiringFields()...)
for i, path := range m.configFiles { for i, path := range m.configFiles {
label := "" label := ""
if i == 0 { if i == 0 {
+4 -1
View File
@@ -20,12 +20,15 @@ type Fields struct {
// Field is a row: a value under a label. Info rows (loading, errors) // Field is a row: a value under a label. Info rows (loading, errors)
// can't be copied; Err ones are errors. A row can also be opened: URL in // can't be copied; Err ones are errors. A row can also be opened: URL in
// the browser (o), Path in the editor (e), or Provider signed in to (L). // the browser (o), Path in the editor (e), Provider signed in to (L), or
// Link gone to (enter).
type Field struct { type Field struct {
Label, Value string Label, Value string
Info, Err bool Info, Err bool
URL, Path, Provider string URL, Path, Provider string
// Link is a lazysecrets link enter goes to, in the source of row Row.
Link, Row string
} }
func NewFields() Fields { func NewFields() Fields {