Compare commits
5
Commits
f1e0e904c6
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1eda53d5d
|
||
|
|
39454d47a8 | ||
|
|
dbb0eb2bbd | ||
|
|
e825a51b30 | ||
|
|
4480e8fed0 |
No files matched your search
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
Every release of lazysecrets, newest first.
|
Every release of lazysecrets, newest first.
|
||||||
|
|
||||||
|
## v0.2.0 – 2026-10-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
|
||||||
|
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
|
||||||
|
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
|
||||||
|
|
||||||
## v0.1.2 – 2026-10-13
|
## v0.1.2 – 2026-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
|||||||
CLI, `sops`, `git`). No config file needed.
|
CLI, `sops`, `git`). No config file needed.
|
||||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||||
fzf-style matching.
|
fzf-style matching.
|
||||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
- **Share links**: Share `lazysecrets://` links with colleagues.
|
||||||
with their own access, from the terminal or with a click in the browser
|
|
||||||
or chat. `.env` files can be shared too, as a link encrypted with a
|
|
||||||
secret your team can read.
|
|
||||||
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||||
- **Careful with values**: values stay hidden until you reveal them, and
|
- **Careful with values**: values stay hidden until you reveal them, and
|
||||||
copied values are cleared from the clipboard after 30 seconds.
|
copied values are cleared from the clipboard after 30 seconds.
|
||||||
@@ -80,14 +77,32 @@ use most:
|
|||||||
| `?` | every key, searchable |
|
| `?` | every key, searchable |
|
||||||
| `q` | quit |
|
| `q` | quit |
|
||||||
|
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
|
||||||
|
|
||||||
|
Here is an example of implementing a picker in `fzf`:
|
||||||
|
```sh
|
||||||
|
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||||
|
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||||
|
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
||||||
|
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||||
|
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
More defails in the [Integration](docs/integration.md) docs
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||||
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
|
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||||
and opening links from the browser and chat
|
opening links, also from the browser and chat
|
||||||
|
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||||
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
||||||
secret
|
secret
|
||||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ func value(ctx context.Context, providers []provider.Provider, st *store.Store,
|
|||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// readKey is value, also returning the version read.
|
// readKey is value, also returning the version read. The account asked
|
||||||
|
// for (--account) goes first, else the one that last read it.
|
||||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||||
key := r
|
key := r
|
||||||
key.Version = ""
|
key.Version = ""
|
||||||
node := st.Node(key.String())
|
node := st.Node(key.String())
|
||||||
return ref.Read(ctx, providers, r, node.Account, node.Source)
|
account, source := node.Account, node.Source
|
||||||
|
if r.Account != "" && r.Account != account {
|
||||||
|
account, source = r.Account, ""
|
||||||
|
}
|
||||||
|
return ref.Read(ctx, providers, r, account, source)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listCmd prints every source, vault and secret lazysecrets can find, one
|
||||||
|
// per line, for fzf, editor pickers and scripts:
|
||||||
|
//
|
||||||
|
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
|
||||||
|
//
|
||||||
|
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
|
||||||
|
// the name, a status (why it couldn't be read, or that it has expired or
|
||||||
|
// expires soon; else empty), its note (muted, or empty), the account and
|
||||||
|
// the link, tab-separated. The mark has a field of its own so a search can
|
||||||
|
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
|
||||||
|
// name is coloured from the theme,
|
||||||
|
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
|
||||||
|
// ui.icons); the link never is.
|
||||||
|
//
|
||||||
|
// What's cached prints at once; what's missing or older than --max-age
|
||||||
|
// follows as it loads. A list that can't be read is a line too, as it
|
||||||
|
// fails: the vault or source, then ✗ and why in the theme's error colour.
|
||||||
|
// They're in the output rather than on stderr, which would draw over fzf.
|
||||||
|
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
|
||||||
|
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||||
|
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
|
||||||
|
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
|
||||||
|
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
|
||||||
|
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
|
||||||
|
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
|
||||||
|
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
|
||||||
|
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
|
||||||
|
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
|
||||||
|
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
||||||
|
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
||||||
|
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
|
||||||
|
}
|
||||||
|
var only []string
|
||||||
|
for k := range strings.SplitSeq(*kinds, ",") {
|
||||||
|
if k = strings.TrimSpace(k); k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(listing.Kinds, k) {
|
||||||
|
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
|
||||||
|
}
|
||||||
|
only = append(only, k)
|
||||||
|
}
|
||||||
|
if *color != "always" && *color != "never" {
|
||||||
|
return fmt.Errorf("--color: %q is not always or never", *color)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
|
||||||
|
color: *color == "always", warn: expiryWarn, now: now}
|
||||||
|
if *iconsOn {
|
||||||
|
m.icons = icons.NerdFont
|
||||||
|
}
|
||||||
|
var write func(listing.Entry) error
|
||||||
|
switch *format {
|
||||||
|
case "text":
|
||||||
|
write = func(e listing.Entry) error {
|
||||||
|
colored := *color == "always"
|
||||||
|
mark := m.of(e)
|
||||||
|
name := nameOf(e, theme, colored)
|
||||||
|
// Why it couldn't be read (vaults, sources), or when it
|
||||||
|
// expires (secrets, keys, certificates): never both.
|
||||||
|
status := errorOf(e, theme, colored)
|
||||||
|
if status == "" && *expiry {
|
||||||
|
status = expiryOf(e, theme, expiryWarn, now, colored)
|
||||||
|
}
|
||||||
|
note := noteOf(e, theme, m.icons, *notes, colored)
|
||||||
|
// A tab or line break in a name would break the line up.
|
||||||
|
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
|
||||||
|
oneLine.Replace(note), e.Account, e.Link)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
enc := json.NewEncoder(out)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
write = func(e listing.Entry) error { return enc.Encode(e) }
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("--format: %q is not text or json", *format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the reader goes away (a pick in fzf), writing fails instead of
|
||||||
|
// killing the process, so what loaded is still saved.
|
||||||
|
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
|
||||||
|
var gone error
|
||||||
|
emit := func(e listing.Entry) error {
|
||||||
|
if e.Error != "" && !*showErrors {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := write(e); err != nil {
|
||||||
|
gone = err
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f := cachefile.New()
|
||||||
|
if cachePath != "" {
|
||||||
|
f = cachefile.Read(cachePath)
|
||||||
|
}
|
||||||
|
f, loaded := listing.Run(context.Background(), listing.Options{
|
||||||
|
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
||||||
|
Mark: func(link, account string) listing.Marks {
|
||||||
|
n := st.Node(link)
|
||||||
|
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
||||||
|
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
|
||||||
|
},
|
||||||
|
}, emit)
|
||||||
|
// Only what was loaded: list never moves a cursor, so the TUI's stay.
|
||||||
|
if cachePath != "" && loaded {
|
||||||
|
f.Cursor = nil
|
||||||
|
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
|
||||||
|
return gone
|
||||||
|
}
|
||||||
|
return nil // or the reader has what it wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
|
||||||
|
|
||||||
|
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
|
||||||
|
// Font icons) with icon; or "".
|
||||||
|
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
|
||||||
|
if e.Note == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
note := e.Note
|
||||||
|
if icon {
|
||||||
|
note = cmp.Or(iconSet.Note, "✎") + " " + note
|
||||||
|
}
|
||||||
|
return renderer(color)(theme.Muted, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryOf says that a secret, key or certificate has expired, in red,
|
||||||
|
// or when it will if that's within warn (expires in 12d), in yellow, or
|
||||||
|
// is "". Its mark (✗, ⚠) is before the name.
|
||||||
|
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
|
||||||
|
render := renderer(color)
|
||||||
|
switch state, left := expiry.Of(e.Expires, now, warn); state {
|
||||||
|
case expiry.Expired:
|
||||||
|
return render(theme.Error, "expired")
|
||||||
|
case expiry.Soon:
|
||||||
|
return render(theme.Warning, "expires in "+expiry.Left(left))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
|
||||||
|
// is before the name.
|
||||||
|
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||||
|
return renderer(color)(theme.Error, e.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameOf is an entry's name for the text format: the path with the
|
||||||
|
// item's own name standing out, and the account.
|
||||||
|
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||||
|
render := renderer(color)
|
||||||
|
var name string
|
||||||
|
if n := len(e.Path); n > 0 {
|
||||||
|
if n > 1 {
|
||||||
|
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
|
||||||
|
}
|
||||||
|
name += e.Path[n-1]
|
||||||
|
}
|
||||||
|
if e.AccountLabel != "" {
|
||||||
|
name += render(theme.Muted, " · "+e.AccountLabel)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// marks says what goes before the name, in a field of its own so a
|
||||||
|
// search leaves it out: ✗ in red when something's wrong (it couldn't be
|
||||||
|
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
|
||||||
|
// entry and ◷ for one used lately, as the TUI marks them; then the
|
||||||
|
// provider's icon. The marks are right-aligned in room for every kind
|
||||||
|
// turned on, so they sit by the icon and names line up.
|
||||||
|
type marks struct {
|
||||||
|
theme styles.Styles
|
||||||
|
icons icons.Set
|
||||||
|
stars, recent, expiry, color bool
|
||||||
|
warn time.Duration // ui.expiryWarningDays
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m marks) of(e listing.Entry) string {
|
||||||
|
render := renderer(m.color)
|
||||||
|
var parts []string
|
||||||
|
room := 0 // marks turned on
|
||||||
|
mark := func(on bool, st lipgloss.Style, s string) {
|
||||||
|
room++
|
||||||
|
if on {
|
||||||
|
parts = append(parts, render(st, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What's wrong, then what to watch: room for one of them.
|
||||||
|
state := expiry.None
|
||||||
|
if m.expiry {
|
||||||
|
state, _ = expiry.Of(e.Expires, m.now, m.warn)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case e.Error != "" || state == expiry.Expired:
|
||||||
|
mark(true, m.theme.Error, "✗")
|
||||||
|
case state == expiry.Soon:
|
||||||
|
mark(true, m.theme.Warning, "⚠")
|
||||||
|
default:
|
||||||
|
mark(false, m.theme.Error, "")
|
||||||
|
}
|
||||||
|
if m.stars {
|
||||||
|
mark(e.Starred, m.theme.Favourite, "★")
|
||||||
|
}
|
||||||
|
if m.recent {
|
||||||
|
mark(e.Recent, m.theme.Muted, "◷")
|
||||||
|
}
|
||||||
|
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
|
||||||
|
kind := provider.Kind("")
|
||||||
|
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
|
||||||
|
if strings.EqualFold(string(k), e.Provider) {
|
||||||
|
kind = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
|
||||||
|
parts = append(parts, render(m.theme.Kind(kind), icon))
|
||||||
|
} else if len(parts) == 0 {
|
||||||
|
pad = strings.TrimSuffix(pad, " ") // no mark after the room
|
||||||
|
}
|
||||||
|
return pad + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderer(color bool) func(lipgloss.Style, string) string {
|
||||||
|
return func(st lipgloss.Style, s string) string {
|
||||||
|
if !color || s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return st.Render(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListNames(t *testing.T) {
|
||||||
|
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
|
||||||
|
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
|
||||||
|
t.Errorf("plain: %q", got)
|
||||||
|
}
|
||||||
|
// The item's own name isn't muted with its path.
|
||||||
|
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
|
||||||
|
t.Errorf("name not set apart: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMarks(t *testing.T) {
|
||||||
|
az := icons.NerdFont.Azure
|
||||||
|
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
|
||||||
|
noIcons := plain
|
||||||
|
noIcons.icons = icons.None
|
||||||
|
colored := noIcons
|
||||||
|
colored.color = true
|
||||||
|
for _, c := range []struct {
|
||||||
|
m marks
|
||||||
|
e listing.Entry
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Room for the status mark and the star, so names line up.
|
||||||
|
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
|
||||||
|
{plain, listing.Entry{Provider: "azure"}, " " + az},
|
||||||
|
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
|
||||||
|
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
|
||||||
|
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
|
||||||
|
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
|
||||||
|
} {
|
||||||
|
if got := c.m.of(c.e); got != c.want {
|
||||||
|
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRecentMarks(t *testing.T) {
|
||||||
|
az := icons.NerdFont.Azure
|
||||||
|
m := marks{icons: icons.NerdFont, stars: true, recent: true}
|
||||||
|
for _, c := range []struct {
|
||||||
|
e listing.Entry
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Right-aligned in room for three: by the icon, names lined up.
|
||||||
|
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
|
||||||
|
{listing.Entry{Provider: "azure"}, " " + az},
|
||||||
|
} {
|
||||||
|
if got := m.of(c.e); got != c.want {
|
||||||
|
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListExpiry(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
warn := 30 * 24 * time.Hour
|
||||||
|
for _, c := range []struct {
|
||||||
|
expires time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{time.Time{}, ""},
|
||||||
|
{now.Add(-time.Hour), "expired"},
|
||||||
|
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
|
||||||
|
{now.Add(5 * time.Hour), "expires in 5h"},
|
||||||
|
{now.Add(60 * 24 * time.Hour), ""},
|
||||||
|
} {
|
||||||
|
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
|
||||||
|
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
|
||||||
|
t.Errorf("expired isn't red: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListNotes(t *testing.T) {
|
||||||
|
e := listing.Entry{Note: "rotate in May"}
|
||||||
|
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
|
||||||
|
t.Errorf("icons: %q", got)
|
||||||
|
}
|
||||||
|
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
|
||||||
|
t.Errorf("no icons: %q", got)
|
||||||
|
}
|
||||||
|
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
|
||||||
|
t.Errorf("--notes=false: %q", got)
|
||||||
|
}
|
||||||
|
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
|
||||||
|
t.Errorf("no note: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStatusMark(t *testing.T) {
|
||||||
|
az := icons.NerdFont.Azure
|
||||||
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
|
||||||
|
for _, c := range []struct {
|
||||||
|
e listing.Entry
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Right-aligned in room for two, as with ★ and ◷.
|
||||||
|
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
|
||||||
|
} {
|
||||||
|
if got := m.of(c.e); got != c.want {
|
||||||
|
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
|
||||||
|
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
|
||||||
|
t.Errorf("--expiry=false: %q", got)
|
||||||
|
}
|
||||||
|
m.expiry, m.color, m.theme = true, true, styles.Default()
|
||||||
|
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
|
||||||
|
t.Errorf("not a red ✗: %q", got)
|
||||||
|
}
|
||||||
|
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
|
||||||
|
t.Errorf("not a yellow ⚠: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListErrors(t *testing.T) {
|
||||||
|
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
|
||||||
|
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
|
||||||
|
t.Errorf("the name has more: %q", got)
|
||||||
|
}
|
||||||
|
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
|
||||||
|
t.Errorf("plain: %q", got)
|
||||||
|
}
|
||||||
|
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
|
||||||
|
t.Errorf("why isn't red: %q", got)
|
||||||
|
}
|
||||||
|
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
|
||||||
|
t.Errorf("no error: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-10
@@ -89,6 +89,32 @@ func cmpOr(a, b string) string {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refArg parses get's and open's arguments: a reference, and optionally
|
||||||
|
// the account to use when several could (lazysecrets list's column) and,
|
||||||
|
// for open, what to do with it rather than asking (--action).
|
||||||
|
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
|
||||||
|
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||||
|
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
|
||||||
|
usage := "usage: lazysecrets get [--account <account>] <ref>"
|
||||||
|
act := new(string)
|
||||||
|
if cmd == "open" {
|
||||||
|
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
|
||||||
|
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return r, "", err
|
||||||
|
}
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
return r, "", errors.New(usage)
|
||||||
|
}
|
||||||
|
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
|
||||||
|
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
|
||||||
|
}
|
||||||
|
r, err = ref.Parse(fs.Arg(0))
|
||||||
|
r.Account = *account
|
||||||
|
return r, *act, err
|
||||||
|
}
|
||||||
|
|
||||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||||
// config directory.
|
// config directory.
|
||||||
func defaultConfigPath() string {
|
func defaultConfigPath() string {
|
||||||
@@ -115,12 +141,18 @@ func run() error {
|
|||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||||
lazysecrets [flags] browse secrets
|
lazysecrets [flags] browse secrets
|
||||||
lazysecrets [flags] [open] <ref>
|
lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
|
||||||
open a lazysecrets:// link (also a share link),
|
open a lazysecrets:// link (also a share link),
|
||||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||||
{{secrets/scope/key}}
|
{{secrets/scope/key}}
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get [--account <account>] <ref>
|
||||||
|
print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
|
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
||||||
|
[--color always|never] [--icons=false] [--errors=false]
|
||||||
|
print every source, vault and secret it can
|
||||||
|
find, one per line, for fzf and pickers:
|
||||||
|
what's cached at once, then what loads
|
||||||
lazysecrets [flags] share [--key <secret>] <env file>...
|
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||||
print a link to env files of a repo, encrypted
|
print a link to env files of a repo, encrypted
|
||||||
with a secret's value (default: sharing.key)
|
with a secret's value (default: sharing.key)
|
||||||
@@ -146,7 +178,8 @@ Flags:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var open, getRef *ref.Ref
|
var open, getRef *ref.Ref
|
||||||
var shareArgs []string
|
var openAction string
|
||||||
|
var shareArgs, listArgs []string
|
||||||
printKeys := false
|
printKeys := false
|
||||||
switch args := flag.Args(); {
|
switch args := flag.Args(); {
|
||||||
case len(args) > 0 && args[0] == "share":
|
case len(args) > 0 && args[0] == "share":
|
||||||
@@ -154,10 +187,12 @@ Flags:
|
|||||||
if len(shareArgs) == 0 {
|
if len(shareArgs) == 0 {
|
||||||
shareArgs = []string{} // still sharing: says how
|
shareArgs = []string{} // still sharing: says how
|
||||||
}
|
}
|
||||||
|
case len(args) > 0 && args[0] == "list":
|
||||||
|
listArgs = append([]string{}, args[1:]...) // not nil: listing
|
||||||
case len(args) == 1 && args[0] == "keybindings":
|
case len(args) == 1 && args[0] == "keybindings":
|
||||||
printKeys = true
|
printKeys = true
|
||||||
case len(args) == 2 && args[0] == "get":
|
case len(args) > 0 && args[0] == "get":
|
||||||
r, err := ref.Parse(args[1])
|
r, _, err := refArg("get", args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -169,12 +204,12 @@ Flags:
|
|||||||
case len(args) > 0 && args[0] == "update":
|
case len(args) > 0 && args[0] == "update":
|
||||||
return updateCmd(*configPath, args[1:])
|
return updateCmd(*configPath, args[1:])
|
||||||
case len(args) == 0:
|
case len(args) == 0:
|
||||||
case args[0] == "open" && len(args) == 2:
|
case args[0] == "open" && len(args) >= 2:
|
||||||
r, err := ref.Parse(args[1])
|
r, act, err := refArg("open", args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
open = &r
|
open, openAction = &r, act
|
||||||
case len(args) == 1 && looksLikeRef(args[0]):
|
case len(args) == 1 && looksLikeRef(args[0]):
|
||||||
// lazysecrets <link>, like lazysecrets open <link>
|
// lazysecrets <link>, like lazysecrets open <link>
|
||||||
r, err := ref.Parse(args[0])
|
r, err := ref.Parse(args[0])
|
||||||
@@ -263,6 +298,8 @@ Flags:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
|
||||||
|
|
||||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||||
// sample vaults never end up in the real file.
|
// sample vaults never end up in the real file.
|
||||||
stateDir, err := config.StateDir()
|
stateDir, err := config.StateDir()
|
||||||
@@ -283,9 +320,14 @@ Flags:
|
|||||||
var providers []provider.Provider
|
var providers []provider.Provider
|
||||||
if *demo {
|
if *demo {
|
||||||
// Two Azure accounts, like a personal and an admin login in az.
|
// Two Azure accounts, like a personal and an admin login in az.
|
||||||
|
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
|
||||||
|
fake.Seed(st, alice, admin)
|
||||||
|
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
|
||||||
|
// Due soon whenever the demo runs (the tests' dates are fixed).
|
||||||
|
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
|
||||||
providers = []provider.Provider{
|
providers = []provider.Provider{
|
||||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
az,
|
||||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
|
||||||
fake.Databricks(600 * time.Millisecond),
|
fake.Databricks(600 * time.Millisecond),
|
||||||
fake.Repos(),
|
fake.Repos(),
|
||||||
}
|
}
|
||||||
@@ -345,6 +387,13 @@ Flags:
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
return get(ctx, providers, st, *getRef)
|
return get(ctx, providers, st, *getRef)
|
||||||
}
|
}
|
||||||
|
if listArgs != nil {
|
||||||
|
cachePath := ""
|
||||||
|
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||||
|
cachePath = filepath.Join(d, "cache.json")
|
||||||
|
}
|
||||||
|
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
|
||||||
|
}
|
||||||
if shareArgs != nil {
|
if shareArgs != nil {
|
||||||
// The files from a repo, the key from whichever provider it's
|
// The files from a repo, the key from whichever provider it's
|
||||||
// in: the longest timeout.
|
// in: the longest timeout.
|
||||||
@@ -433,9 +482,12 @@ Flags:
|
|||||||
ShareKey: cfg.Sharing.Key,
|
ShareKey: cfg.Sharing.Key,
|
||||||
ShareRules: shareRules(cfg),
|
ShareRules: shareRules(cfg),
|
||||||
Open: open,
|
Open: open,
|
||||||
|
OpenAction: openAction,
|
||||||
|
RecentIcon: recentIcon,
|
||||||
Styles: &theme,
|
Styles: &theme,
|
||||||
DefaultFg: defaultFg,
|
DefaultFg: defaultFg,
|
||||||
})
|
})
|
||||||
|
blockingStdin()
|
||||||
p = tea.NewProgram(m)
|
p = tea.NewProgram(m)
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if fm, ok := final.(app.Model); ok {
|
if fm, ok := final.(app.Model); ok {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func blockingStdin() {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockingStdin makes a terminal on stdin blocking again. fzf's become
|
||||||
|
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
|
||||||
|
// so it reads it directly, and the TUI's first read would fail with
|
||||||
|
// EAGAIN ("resource temporarily unavailable").
|
||||||
|
func blockingStdin() {
|
||||||
|
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
|
||||||
|
_ = syscall.SetNonblock(fd, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "runtime/debug"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// version is set by release builds:
|
// version is set by release builds:
|
||||||
//
|
//
|
||||||
@@ -8,7 +12,12 @@ import "runtime/debug"
|
|||||||
var version string
|
var version string
|
||||||
|
|
||||||
// versionString is the release version, or for other builds what Go
|
// versionString is the release version, or for other builds what Go
|
||||||
// recorded: the module version (go install …@v1.2.3), else the commit.
|
// recorded: the module version (go install …@v1.2.3), else the commit
|
||||||
|
// (dev-c1a4f90, -dirty with changes not committed).
|
||||||
|
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
|
||||||
|
// maybe +dirty): its commit.
|
||||||
|
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
|
||||||
|
|
||||||
func versionString() string {
|
func versionString() string {
|
||||||
if version != "" {
|
if version != "" {
|
||||||
return version
|
return version
|
||||||
@@ -18,6 +27,15 @@ func versionString() string {
|
|||||||
return "dev"
|
return "dev"
|
||||||
}
|
}
|
||||||
if v := bi.Main.Version; v != "" && v != "(devel)" {
|
if v := bi.Main.Version; v != "" && v != "(devel)" {
|
||||||
|
// A commit, not a release: a local build (Go stamps those too) or
|
||||||
|
// go install …@<commit>.
|
||||||
|
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||||
|
dirty := ""
|
||||||
|
if strings.HasSuffix(v, "+dirty") {
|
||||||
|
dirty = "-dirty"
|
||||||
|
}
|
||||||
|
return "dev-" + m[1][:7] + dirty
|
||||||
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
var rev, dirty string
|
var rev, dirty string
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPseudoVersions(t *testing.T) {
|
||||||
|
for v, want := range map[string]string{
|
||||||
|
"v0.1.3-0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||||
|
"v0.1.3-0.20261014174204-1c31a75d0507+dirty": "1c31a75d0507",
|
||||||
|
"v0.0.0-20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||||
|
"v0.2.0-rc.1.0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||||
|
"v0.1.2": "",
|
||||||
|
"v0.2.0-rc.1": "",
|
||||||
|
} {
|
||||||
|
got := ""
|
||||||
|
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||||
|
got = m[1]
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s: %q, want %q", v, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 258 KiB |
+6
-4
@@ -9,10 +9,11 @@ it isn't there yet); changes apply when lazysecrets starts again.
|
|||||||
```yaml
|
```yaml
|
||||||
ui:
|
ui:
|
||||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||||
|
recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
|
||||||
sourcesView: grouped # or flat; ` toggles it
|
sourcesView: grouped # or flat; ` toggles it
|
||||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
|
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
|
||||||
values:
|
values:
|
||||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||||
@@ -53,8 +54,8 @@ sharing: # sharing env files as encrypted links
|
|||||||
```
|
```
|
||||||
|
|
||||||
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
||||||
[sharing env files](links.md#sharing-env-files) (`sharing`), the
|
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
|
||||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
||||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||||
[custom commands](custom-commands.md) (`customCommands`).
|
[custom commands](custom-commands.md) (`customCommands`).
|
||||||
|
|
||||||
@@ -140,7 +141,8 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
|||||||
once, and anything older than two minutes reloads in the background when
|
once, and anything older than two minutes reloads in the background when
|
||||||
you go to it, or when Go to searches it. While it does, the pane's
|
you go to it, or when Go to searches it. While it does, the pane's
|
||||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
||||||
and copying always fetch the value live.
|
and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
|
||||||
|
reads it and adds to it too.
|
||||||
|
|
||||||
Lists not loaded for 30 days are dropped, and so is everything from an
|
Lists not loaded for 30 days are dropped, and so is everything from an
|
||||||
account you're no longer signed in to. Repos aren't cached: their files
|
account you're no longer signed in to. Repos aren't cached: their files
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Integration
|
||||||
|
|
||||||
|
lazysecrets works outside its own window too: `lazysecrets get` reads a
|
||||||
|
value for scripts, and `lazysecrets list` lists everything for fzf, editor
|
||||||
|
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
|
||||||
|
copies and shares. For Neovim, there's
|
||||||
|
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
|
||||||
|
|
||||||
|
## Reading a value
|
||||||
|
|
||||||
|
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
||||||
|
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads the value directly, with your az accounts (the one that last read
|
||||||
|
it first), the Databricks profile for the workspace, or sops in your clone
|
||||||
|
of the repo; a version in the link reads that version. `--account` tries
|
||||||
|
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
|
||||||
|
The value ends with a newline only on a terminal. When it can't read the
|
||||||
|
value, it says why on stderr and exits with status 1.
|
||||||
|
|
||||||
|
## Listing, for fzf and pickers
|
||||||
|
|
||||||
|
`lazysecrets list` prints every source, vault and secret, one per line,
|
||||||
|
tab-separated:
|
||||||
|
|
||||||
|
| Field | |
|
||||||
|
|---|---|
|
||||||
|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
|
||||||
|
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
|
||||||
|
| name | `platform-prod / kv-app-prod / db-password` |
|
||||||
|
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
|
||||||
|
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
|
||||||
|
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
|
||||||
|
| link | its `lazysecrets://` link |
|
||||||
|
|
||||||
|
What's cached prints at once, then what loads follows as it arrives: read
|
||||||
|
it as a stream. Values are never listed. It stops quietly when the reader
|
||||||
|
goes, like fzf after a pick. Kind stays the first field, and account and
|
||||||
|
link the last two; new fields only ever go between. The mark has a field of
|
||||||
|
its own so a search can leave it out: in fzf, `--nth` searches the name,
|
||||||
|
so `^prod` finds names starting with prod.
|
||||||
|
|
||||||
|
| Flag | |
|
||||||
|
|---|---|
|
||||||
|
| `--format json` | an object per line instead, for editor plugins (below) |
|
||||||
|
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
|
||||||
|
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
|
||||||
|
| `--max-age 2m` | load cached lists older than this again |
|
||||||
|
| `--errors=false` | leave out the vaults and sources it couldn't read |
|
||||||
|
| `--color never` | no colours, for scripts |
|
||||||
|
| `--icons=false` | no provider icons |
|
||||||
|
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
|
||||||
|
| `--notes=false` | no note icon before a note |
|
||||||
|
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
|
||||||
|
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
|
||||||
|
|
||||||
|
With `--format json`, each line is an object with these fields; new ones
|
||||||
|
may be added:
|
||||||
|
|
||||||
|
| Field | |
|
||||||
|
|---|---|
|
||||||
|
| `link`, `kind`, `account` | as above |
|
||||||
|
| `error` | why it couldn't be read, only then |
|
||||||
|
| `provider` | `azure`, `databricks` or `repo` |
|
||||||
|
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
|
||||||
|
| `accountLabel` | the account's short name, when several az accounts are signed in |
|
||||||
|
| `starred` | starred for this account |
|
||||||
|
| `note` | your note on it, if any |
|
||||||
|
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
|
||||||
|
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
|
||||||
|
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
|
||||||
|
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
|
||||||
|
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
|
||||||
|
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
|
||||||
|
|
||||||
|
`lazysecrets open --action go --account <account> <link>` opens one in
|
||||||
|
lazysecrets without asking what to do or which account, ending in the
|
||||||
|
secrets list, like Go to.
|
||||||
|
|
||||||
|
|
||||||
|
### Example usage with fzf
|
||||||
|
```sh
|
||||||
|
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||||
|
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||||
|
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
||||||
|
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||||
|
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
+1
-1
@@ -54,7 +54,7 @@ match case exactly.
|
|||||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
|
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
||||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||||
| `x` | [custom commands](custom-commands.md) |
|
| `x` | [custom commands](custom-commands.md) |
|
||||||
| `L` | sign in again (shown when a login has expired) |
|
| `L` | sign in again (shown when a login has expired) |
|
||||||
|
|||||||
+3
-3
@@ -58,7 +58,7 @@ providers:
|
|||||||
```
|
```
|
||||||
|
|
||||||
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
||||||
[shared](links.md#sharing-env-files) with:
|
[shared](sharing.md#sharing-env-files) with:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sharing:
|
sharing:
|
||||||
@@ -100,7 +100,7 @@ sops 3.8 or later.
|
|||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
|
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
|
||||||
|
|
||||||
Links name the repo by its git remote, so they open in a colleague's clone
|
Links name the repo by its git remote, so they open in a colleague's clone
|
||||||
wherever it is:
|
wherever it is:
|
||||||
@@ -109,4 +109,4 @@ wherever it is:
|
|||||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||||
```
|
```
|
||||||
|
|
||||||
See [links](links.md).
|
See [sharing](sharing.md).
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Links
|
# Sharing
|
||||||
|
|
||||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
|
||||||
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||||
it lands on that item, reading it with their own account and
|
it lands on that item, reading it with their own account and
|
||||||
permissions: the link names the item, never its value. Env files can
|
permissions: the link names the item, never its value. Env files can
|
||||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
also be [shared encrypted](#sharing-env-files), in the link itself.
|
||||||
@@ -15,7 +15,7 @@ lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage
|
|||||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sharing
|
## Sharing a link
|
||||||
|
|
||||||
`s` copies a link to the selected item (from the versions view, pinned to
|
`s` copies a link to the selected item (from the versions view, pinned to
|
||||||
that version). `S` opens the share menu:
|
that version). `S` opens the share menu:
|
||||||
@@ -99,7 +99,7 @@ the value breaks every old link. Whoever has read the secret's value
|
|||||||
before can decrypt links made with it without asking the vault, so rotate
|
before can decrypt links made with it without asking the vault, so rotate
|
||||||
it now and then: new links use the newest version.
|
it now and then: new links use the newest version.
|
||||||
|
|
||||||
## Opening
|
## Opening a link
|
||||||
|
|
||||||
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
||||||
and fills in any reference it finds there. Key Vault URLs,
|
and fills in any reference it finds there. Key Vault URLs,
|
||||||
@@ -121,20 +121,10 @@ account) and jumps to it, asking which account to use when several can see
|
|||||||
it, ending on a secret's details as `enter` on it would. Links say what,
|
it, ending on a secret's details as `enter` on it would. Links say what,
|
||||||
not what to do: opening a link to a secret asks whether to just go to it,
|
not what to do: opening a link to a secret asks whether to just go to it,
|
||||||
also copy or show its value, or (`y`) copy its value without going there.
|
also copy or show its value, or (`y`) copy its value without going there.
|
||||||
|
`lazysecrets open --action go|copy|show <link>` does one without asking
|
||||||
## In scripts
|
and ends in the list, like Go to,
|
||||||
|
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
|
||||||
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
prints it) when several can see it.
|
||||||
|
|
||||||
```sh
|
|
||||||
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
|
||||||
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
|
||||||
```
|
|
||||||
|
|
||||||
It reads the value directly, with your az accounts (the one that last read
|
|
||||||
it first), the Databricks profile for the workspace, or sops in your clone
|
|
||||||
of the repo; a version in the link reads that version. The value ends with
|
|
||||||
a newline only on a terminal.
|
|
||||||
|
|
||||||
## From the browser and chat
|
## From the browser and chat
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Package cachefile is the cache of what lazysecrets has loaded
|
||||||
|
// (cache.json): the Sources lists, vaults, secrets' metadata and where
|
||||||
|
// the cursor was in each, never values. The TUI and lazysecrets list
|
||||||
|
// both read it and save to it; a save merges with what's on disk, so
|
||||||
|
// neither undoes what the other loaded.
|
||||||
|
package cachefile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the file's format; a file of another version is ignored,
|
||||||
|
// and replaced on the next save.
|
||||||
|
const Version = 1
|
||||||
|
|
||||||
|
// MaxAge drops what hasn't been loaded again for this long.
|
||||||
|
const MaxAge = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// File is the cache. Every key starts with the provider ID: Sources are
|
||||||
|
// keyed by it, the rest by Row, Objects' keys and the like.
|
||||||
|
type File struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||||
|
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||||
|
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||||
|
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
|
||||||
|
Cursor map[string]string `json:"cursor,omitempty"`
|
||||||
|
// Dropped are keys deleted since the file was read (a list that
|
||||||
|
// failed to load): Save deletes them from the file too, rather than
|
||||||
|
// merging the old entry back. Not saved.
|
||||||
|
Dropped map[string]bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamped is a list or detail with when it was loaded.
|
||||||
|
type Stamped[T any] struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Value T `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New is an empty cache.
|
||||||
|
func New() File {
|
||||||
|
return File{
|
||||||
|
Version: Version,
|
||||||
|
Sources: map[string]Stamped[[]provider.Source]{},
|
||||||
|
Containers: map[string]Stamped[[]provider.Container]{},
|
||||||
|
Objects: map[string]Stamped[[]provider.Object]{},
|
||||||
|
Details: map[string]Stamped[provider.Object]{},
|
||||||
|
Cursor: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row keys a source's vaults or scopes.
|
||||||
|
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||||
|
|
||||||
|
// ContainerKey keys what's cached about a vault or scope.
|
||||||
|
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||||
|
|
||||||
|
// ObjectsKey keys a vault's secrets, keys or certificates.
|
||||||
|
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
|
||||||
|
return ContainerKey(c) + "|" + string(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderOf is the provider ID a key starts with.
|
||||||
|
func ProviderOf(key string) string {
|
||||||
|
id, _, _ := strings.Cut(key, "|")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep is Save's keep for these providers: their lists are kept, but not
|
||||||
|
// repos', whose files are read from disk anyway, nor other accounts'.
|
||||||
|
func Keep(providers []provider.Provider) func(providerID string) bool {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, p := range providers {
|
||||||
|
keep[p.ID()] = p.Kind() != provider.KindRepo
|
||||||
|
}
|
||||||
|
return func(id string) bool { return keep[id] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads path; a missing, unreadable or other version's file is an
|
||||||
|
// empty cache.
|
||||||
|
func Read(path string) File {
|
||||||
|
f := New()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
var d File
|
||||||
|
if json.Unmarshal(b, &d) != nil || d.Version != Version {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
maps.Copy(f.Sources, d.Sources)
|
||||||
|
maps.Copy(f.Containers, d.Containers)
|
||||||
|
maps.Copy(f.Objects, d.Objects)
|
||||||
|
maps.Copy(f.Details, d.Details)
|
||||||
|
maps.Copy(f.Cursor, d.Cursor)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes f to path, merged with the file there: of a list in both,
|
||||||
|
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
|
||||||
|
// file's). Entries older than MaxAge, those of providers keep says no to
|
||||||
|
// (accounts signed out of), and f.Dropped are dropped, from the file's
|
||||||
|
// side as well. Saves from several lazysecrets take turns (a lock file),
|
||||||
|
// so none undoes another's.
|
||||||
|
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unlock, err := lock(path + ".lock")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
out := New()
|
||||||
|
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
|
||||||
|
disk := Read(path)
|
||||||
|
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
|
||||||
|
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
|
||||||
|
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
|
||||||
|
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
|
||||||
|
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
|
||||||
|
for k, v := range m {
|
||||||
|
if keep(ProviderOf(k)) {
|
||||||
|
out.Cursor[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return write(path, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge puts disk's entries in out, but not those dropped, then ours
|
||||||
|
// where they're newer.
|
||||||
|
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
|
||||||
|
for k, e := range disk {
|
||||||
|
if ok(k, e.At) && !dropped[k] {
|
||||||
|
out[k] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, e := range ours {
|
||||||
|
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
|
||||||
|
out[k] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write writes f atomically, readable by the user only.
|
||||||
|
func write(path string, f File) error {
|
||||||
|
b, err := json.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||||
|
if _, err := tmp.Write(b); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp.Name(), path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package cachefile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
|
||||||
|
var cs []provider.Container
|
||||||
|
for _, n := range names {
|
||||||
|
cs = append(cs, provider.Container{Name: n})
|
||||||
|
}
|
||||||
|
return Stamped[[]provider.Container]{At: at, Value: cs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveMerges(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
now := time.Now()
|
||||||
|
all := func(string) bool { return true }
|
||||||
|
|
||||||
|
// The TUI saved two lists.
|
||||||
|
tui := New()
|
||||||
|
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
|
||||||
|
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
|
||||||
|
tui.Cursor["az|b"] = "kv-b"
|
||||||
|
if err := Save(path, tui, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// lazysecrets list loads a again, and c.
|
||||||
|
listed := Read(path)
|
||||||
|
listed.Containers["az|a"] = list(now, "kv-a-new")
|
||||||
|
listed.Containers["az|c"] = list(now, "kv-c")
|
||||||
|
if err := Save(path, listed, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The TUI saves what it has, older: list's lists stay.
|
||||||
|
if err := Save(path, tui, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := Read(path)
|
||||||
|
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
|
||||||
|
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
|
||||||
|
t.Errorf("%s = %v, want %s", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Cursor["az|b"] != "kv-b" {
|
||||||
|
t.Error("cursor lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDrops(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
now := time.Now()
|
||||||
|
f := New()
|
||||||
|
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
|
||||||
|
f.Containers["gone|x"] = list(now, "kv-gone")
|
||||||
|
f.Containers["az|x"] = list(now, "kv-x")
|
||||||
|
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Signed out of "gone" since: dropped from the file too.
|
||||||
|
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := Read(path).Containers
|
||||||
|
if _, ok := got["az|old"]; ok {
|
||||||
|
t.Error("kept a list older than MaxAge")
|
||||||
|
}
|
||||||
|
if _, ok := got["gone|x"]; ok {
|
||||||
|
t.Error("kept a signed-out account's list")
|
||||||
|
}
|
||||||
|
if _, ok := got["az|x"]; !ok {
|
||||||
|
t.Error("dropped a list on disk the save didn't have")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadOtherVersion(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
f := New()
|
||||||
|
f.Version = Version + 1
|
||||||
|
f.Containers["az|x"] = list(time.Now(), "kv-x")
|
||||||
|
if err := write(path, f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
|
||||||
|
t.Errorf("read another version's file: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDroppedAndCursors(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "cache.json")
|
||||||
|
now := time.Now()
|
||||||
|
all := func(string) bool { return true }
|
||||||
|
f := New()
|
||||||
|
f.Containers["az|bad"] = list(now, "kv-old")
|
||||||
|
f.Cursor["az|x"] = "kv-tui"
|
||||||
|
if err := Save(path, f, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The TUI fails to load it again and drops it: it stays gone.
|
||||||
|
f = New()
|
||||||
|
f.Dropped = map[string]bool{"az|bad": true}
|
||||||
|
if err := Save(path, f, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := Read(path).Containers["az|bad"]; ok {
|
||||||
|
t.Error("a dropped list came back")
|
||||||
|
}
|
||||||
|
// lazysecrets list saves without cursors: the TUI's stay.
|
||||||
|
listed := New()
|
||||||
|
listed.Cursor = nil
|
||||||
|
listed.Containers["az|y"] = list(now, "kv-y")
|
||||||
|
if err := Save(path, listed, all, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if Read(path).Cursor["az|x"] != "kv-tui" {
|
||||||
|
t.Error("the TUI's cursor was lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package cachefile
|
||||||
|
|
||||||
|
// lock does nothing here: saves can then race, losing lists that load
|
||||||
|
// again.
|
||||||
|
func lock(string) (func(), error) { return func() {}, nil }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package cachefile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lock takes path as a lock file, waiting while another lazysecrets has
|
||||||
|
// it, and returns how to let go.
|
||||||
|
func lock(path string) (unlock func(), err error) {
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||||
|
f.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ type Config struct {
|
|||||||
|
|
||||||
type UI struct {
|
type UI struct {
|
||||||
Icons *bool `yaml:"icons"`
|
Icons *bool `yaml:"icons"`
|
||||||
|
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
||||||
|
// and in lazysecrets list. Defaults to false.
|
||||||
|
RecentIcon *bool `yaml:"recentIcon"`
|
||||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
||||||
SourcesView string `yaml:"sourcesView"`
|
SourcesView string `yaml:"sourcesView"`
|
||||||
// CollapseGroups starts the Sources pane with its groups folded;
|
// CollapseGroups starts the Sources pane with its groups folded;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Package expiry says whether a secret, key or certificate has expired
|
||||||
|
// or will soon, and how long it has left, the same way everywhere: the
|
||||||
|
// TUI's lists and status pane, and lazysecrets list.
|
||||||
|
package expiry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
None State = iota // no expiry, or not within the warning period
|
||||||
|
Soon // within the warning period
|
||||||
|
Expired // already
|
||||||
|
)
|
||||||
|
|
||||||
|
// Of tells whether something expiring at expires (zero: never) has
|
||||||
|
// expired by now or will within warn, and how long it has left.
|
||||||
|
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
|
||||||
|
if expires.IsZero() {
|
||||||
|
return None, 0
|
||||||
|
}
|
||||||
|
left := expires.Sub(now)
|
||||||
|
switch {
|
||||||
|
case left <= 0:
|
||||||
|
return Expired, 0
|
||||||
|
case left <= warn:
|
||||||
|
return Soon, left
|
||||||
|
}
|
||||||
|
return None, left
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left is a short "12d", or "5h" on the last day.
|
||||||
|
func Left(d time.Duration) string {
|
||||||
|
if d < 24*time.Hour {
|
||||||
|
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package expiry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOf(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
warn := 30 * 24 * time.Hour
|
||||||
|
for _, c := range []struct {
|
||||||
|
expires time.Time
|
||||||
|
want State
|
||||||
|
left string
|
||||||
|
}{
|
||||||
|
{time.Time{}, None, ""},
|
||||||
|
{now.Add(-time.Hour), Expired, ""},
|
||||||
|
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
|
||||||
|
{now.Add(5 * time.Hour), Soon, "5h"},
|
||||||
|
{now.Add(60 * 24 * time.Hour), None, ""},
|
||||||
|
} {
|
||||||
|
state, left := Of(c.expires, now, warn)
|
||||||
|
if state != c.want || (c.left != "" && Left(left) != c.left) {
|
||||||
|
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Package listing lists what lazysecrets can find, for lazysecrets list:
|
||||||
|
// what Go to searches, without the TUI. What's cached comes first, at
|
||||||
|
// once; then lists that are missing or older than MaxAge load in the
|
||||||
|
// background, a few at a time, and what's new in them follows as they
|
||||||
|
// arrive. Repos are always read, from disk. Values are never listed.
|
||||||
|
package listing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workers is how many lists load at once, as in Go to.
|
||||||
|
const Workers = 4
|
||||||
|
|
||||||
|
// Kinds are the kinds of entry, from sources down.
|
||||||
|
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
|
||||||
|
|
||||||
|
// Entry is a source, vault or item, by its lazysecrets:// link.
|
||||||
|
type Entry struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
// Kind is source (a subscription, workspace or repo), vault (a Key
|
||||||
|
// Vault, secret scope or repo file), secret, key or certificate.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// Provider is azure, databricks or repo, as in links.
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
// Path names it: its source's, vault's and own names.
|
||||||
|
Path []string `json:"path"`
|
||||||
|
// Account is the provider it's listed through: an az account
|
||||||
|
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
|
||||||
|
// get take it (--account) to use that account when several could.
|
||||||
|
Account string `json:"account"`
|
||||||
|
// AccountLabel names the az account, when there are several.
|
||||||
|
AccountLabel string `json:"accountLabel,omitempty"`
|
||||||
|
// Starred is starred for this account; Recent is used lately (Recent,
|
||||||
|
// R, in the TUI): LastUsed is when, Uses how often.
|
||||||
|
Starred bool `json:"starred"`
|
||||||
|
Recent bool `json:"recent"`
|
||||||
|
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||||
|
Uses int `json:"uses,omitempty"`
|
||||||
|
// Expires is when a secret, key or certificate expires, if it does;
|
||||||
|
// Enabled whether it's enabled, where the provider says (Azure).
|
||||||
|
Expires time.Time `json:"expires,omitzero"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
// Note is the user's note on it, if any.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
// Reference is how config refers to a secret, where the provider has
|
||||||
|
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
|
||||||
|
// web page (portal, workspace), if any.
|
||||||
|
Reference string `json:"reference,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
// Error says why a source's vaults or a vault's secrets couldn't be
|
||||||
|
// listed. The entry is then that source or vault, again, or with an
|
||||||
|
// account's sources unreadable, the account without a link.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Providers []provider.Provider
|
||||||
|
// Cache is what was loaded before; Run adds what it loads.
|
||||||
|
Cache cachefile.File
|
||||||
|
// MaxAge: cached lists loaded longer ago are loaded again.
|
||||||
|
MaxAge time.Duration
|
||||||
|
// Cached lists only what's cached (and repos): nothing is loaded.
|
||||||
|
Cached bool
|
||||||
|
// Kinds are the kinds of entry to list (Kinds' names); none is all.
|
||||||
|
// Lists only needed for other kinds aren't loaded: sources and vaults
|
||||||
|
// alone don't load any secrets. Lists that fail are always listed.
|
||||||
|
Kinds []string
|
||||||
|
// Mark tells what's remembered about the entry with link, seen
|
||||||
|
// through account; nil marks nothing.
|
||||||
|
Mark func(link, account string) Marks
|
||||||
|
Timeout func(provider.Kind) time.Duration // nil: 30s
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks are what's remembered about an entry: starred for the account,
|
||||||
|
// used lately (Recent) and when and how often, and its note.
|
||||||
|
type Marks struct {
|
||||||
|
Starred, Recent bool
|
||||||
|
LastUsed time.Time
|
||||||
|
Uses int
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run lists everything, calling emit for each entry once, from this
|
||||||
|
// goroutine, and for each list that can't be loaded, as it fails. An
|
||||||
|
// error from emit (the reader went away) stops it. It returns the cache
|
||||||
|
// with what was loaded, and whether anything was.
|
||||||
|
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
|
||||||
|
results: make(chan result)}
|
||||||
|
if l.o.Now == nil {
|
||||||
|
l.o.Now = time.Now
|
||||||
|
}
|
||||||
|
if l.o.Timeout == nil {
|
||||||
|
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
|
||||||
|
}
|
||||||
|
for _, p := range o.Providers {
|
||||||
|
if l.stopped != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
l.provider(p)
|
||||||
|
}
|
||||||
|
l.start(ctx)
|
||||||
|
for l.active > 0 {
|
||||||
|
r := <-l.results
|
||||||
|
l.active--
|
||||||
|
if l.stopped == nil {
|
||||||
|
l.handle(r)
|
||||||
|
l.start(ctx)
|
||||||
|
}
|
||||||
|
if l.stopped != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.o.Cache, l.loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
type job struct {
|
||||||
|
p provider.Provider
|
||||||
|
src *provider.Source // list its vaults
|
||||||
|
c *provider.Container // list its secrets, keys or certificates (t)
|
||||||
|
t provider.ObjectType
|
||||||
|
key string // where the list is cached
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
job
|
||||||
|
sources []provider.Source
|
||||||
|
containers []provider.Container
|
||||||
|
objects []provider.Object
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type lister struct {
|
||||||
|
o Options
|
||||||
|
emit func(Entry) error
|
||||||
|
seen map[string]bool // provider ID + link of entries emitted
|
||||||
|
pending map[string]bool // cache keys queued or loading
|
||||||
|
failed map[string]bool // cache keys that couldn't be loaded: not again
|
||||||
|
loaded bool // something was cached
|
||||||
|
queue []job
|
||||||
|
active int
|
||||||
|
results chan result
|
||||||
|
stopped error // emit's error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
|
||||||
|
|
||||||
|
// load reports whether to load a list: a repo's always (from disk), else
|
||||||
|
// unless Cached, when it isn't cached or is older than MaxAge.
|
||||||
|
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
|
||||||
|
if !l.cacheable(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) provider(p provider.Provider) {
|
||||||
|
e, ok := l.o.Cache.Sources[p.ID()]
|
||||||
|
if l.cacheable(p) && ok {
|
||||||
|
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
|
||||||
|
l.source(p, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.load(p, ok, e.At) {
|
||||||
|
l.enqueue(job{p: p, key: p.ID()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// source emits src, and what's cached under it, and queues what's not.
|
||||||
|
func (l *lister) source(p provider.Provider, src provider.Source) {
|
||||||
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||||
|
l.put(p, sourceEntry(src))
|
||||||
|
row := cachefile.Row(p.ID(), src.ID)
|
||||||
|
e, ok := l.o.Cache.Containers[row]
|
||||||
|
if l.cacheable(p) && ok {
|
||||||
|
l.containers(p, src, e.Value)
|
||||||
|
}
|
||||||
|
if l.load(p, ok, e.At) {
|
||||||
|
if l.wants("vault", "secret", "key", "certificate") {
|
||||||
|
l.enqueue(job{p: p, src: &src, key: row})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
|
||||||
|
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
|
||||||
|
c.Source = src
|
||||||
|
l.put(p, containerEntry(c))
|
||||||
|
for _, t := range c.ObjectTypes() {
|
||||||
|
key := cachefile.ObjectsKey(c, t)
|
||||||
|
e, ok := l.o.Cache.Objects[key]
|
||||||
|
if l.cacheable(p) {
|
||||||
|
l.objects(p, c, e.Value)
|
||||||
|
}
|
||||||
|
// Keys and certificates load only when asked for; else,
|
||||||
|
// like Go to, only as far as the TUI opened them.
|
||||||
|
asked := l.wants(strings.ToLower(string(t)))
|
||||||
|
if t != provider.TypeSecret {
|
||||||
|
asked = len(l.o.Kinds) > 0 && asked
|
||||||
|
}
|
||||||
|
if asked && l.load(p, ok, e.At) {
|
||||||
|
l.enqueue(job{p: p, c: &c, t: t, key: key})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
|
||||||
|
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
||||||
|
o.Container = c
|
||||||
|
r, err := ref.ForObject(o, "")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
||||||
|
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
|
||||||
|
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceEntry(src provider.Source) Entry {
|
||||||
|
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
|
||||||
|
if r, err := ref.ForSource(src); err == nil {
|
||||||
|
e.Link = r.String()
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerEntry(c provider.Container) Entry {
|
||||||
|
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
|
||||||
|
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||||
|
e.Link = r.String()
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// put emits e, unless it was already (or emitting has failed). Entries
|
||||||
|
// without a link (one couldn't be made) are left out, but not errors.
|
||||||
|
func (l *lister) put(p provider.Provider, e Entry) {
|
||||||
|
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.Account = p.ID()
|
||||||
|
if l.o.Mark != nil && e.Link != "" {
|
||||||
|
m := l.o.Mark(e.Link, e.Account)
|
||||||
|
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
|
||||||
|
if m.Recent {
|
||||||
|
e.LastUsed, e.Uses = m.LastUsed, m.Uses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
|
||||||
|
if l.stopped != nil || l.seen[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.seen[id] = true
|
||||||
|
l.stopped = l.emit(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wants reports whether any of kinds is listed.
|
||||||
|
func (l *lister) wants(kinds ...string) bool {
|
||||||
|
if len(l.o.Kinds) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, k := range kinds {
|
||||||
|
if slices.Contains(l.o.Kinds, k) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) enqueue(j job) {
|
||||||
|
if l.pending[j.key] || l.failed[j.key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.pending[j.key] = true
|
||||||
|
l.queue = append(l.queue, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) start(ctx context.Context) {
|
||||||
|
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
|
||||||
|
j := l.queue[0]
|
||||||
|
l.queue = l.queue[1:]
|
||||||
|
l.active++
|
||||||
|
go func() { l.results <- l.fetch(ctx, j) }()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) fetch(ctx context.Context, j job) result {
|
||||||
|
kind := j.p.Kind()
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
|
||||||
|
defer cancel()
|
||||||
|
r := result{job: j}
|
||||||
|
switch {
|
||||||
|
case j.c != nil:
|
||||||
|
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
|
||||||
|
case j.src != nil:
|
||||||
|
r.containers, r.err = j.p.Containers(ctx, *j.src)
|
||||||
|
default:
|
||||||
|
r.sources, r.err = j.p.Sources(ctx)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lister) handle(r result) {
|
||||||
|
delete(l.pending, r.key)
|
||||||
|
now := l.o.Now()
|
||||||
|
if r.err != nil {
|
||||||
|
l.failed[r.key] = true
|
||||||
|
l.put(r.p, r.failed())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.loaded = l.loaded || l.cacheable(r.p)
|
||||||
|
cache := l.cacheable(r.p)
|
||||||
|
switch {
|
||||||
|
case r.c != nil:
|
||||||
|
if cache {
|
||||||
|
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
|
||||||
|
}
|
||||||
|
l.objects(r.p, *r.c, r.objects)
|
||||||
|
case r.src != nil:
|
||||||
|
r.containers = slices.Clone(r.containers) // the provider's may be shared
|
||||||
|
for i := range r.containers {
|
||||||
|
r.containers[i].Source = *r.src
|
||||||
|
}
|
||||||
|
if cache {
|
||||||
|
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
|
||||||
|
}
|
||||||
|
l.containers(r.p, *r.src, r.containers)
|
||||||
|
default:
|
||||||
|
if cache {
|
||||||
|
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
|
||||||
|
}
|
||||||
|
for _, src := range r.sources {
|
||||||
|
l.source(r.p, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed is the entry for a list that couldn't be loaded.
|
||||||
|
func (r result) failed() Entry {
|
||||||
|
var e Entry
|
||||||
|
switch {
|
||||||
|
case r.c != nil:
|
||||||
|
e = containerEntry(*r.c)
|
||||||
|
case r.src != nil:
|
||||||
|
src := *r.src
|
||||||
|
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
|
||||||
|
e = sourceEntry(src)
|
||||||
|
default:
|
||||||
|
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
|
||||||
|
}
|
||||||
|
e.Error = firstLine(r.err.Error())
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLine is an error's first line, the summary (Azure's go on with
|
||||||
|
// the caller's IDs and more): an entry is one line. Tabs, which would
|
||||||
|
// split it into columns, are spaces.
|
||||||
|
func firstLine(s string) string {
|
||||||
|
for line := range strings.Lines(s) {
|
||||||
|
if line = strings.TrimSpace(line); line != "" {
|
||||||
|
return strings.ReplaceAll(line, "\t", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unreadable"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
|
||||||
|
|
||||||
|
// accountOf is the account part of a source's provider label ("alice" in
|
||||||
|
// "Azure · alice"), or "" when there's a single account.
|
||||||
|
func accountOf(src provider.Source) string {
|
||||||
|
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func sorted[T any](s []T, name func(T) string) []T {
|
||||||
|
s = slices.Clone(s)
|
||||||
|
slices.SortStableFunc(s, func(a, b T) int {
|
||||||
|
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package listing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// counting counts a provider's requests.
|
||||||
|
type counting struct {
|
||||||
|
provider.Provider
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||||
|
c.calls.Add(1)
|
||||||
|
return c.Provider.Sources(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||||
|
c.calls.Add(1)
|
||||||
|
return c.Provider.Containers(ctx, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||||
|
c.calls.Add(1)
|
||||||
|
return c.Provider.Objects(ctx, ct, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run lists, returning the entries and, apart, the lists that failed.
|
||||||
|
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
|
||||||
|
t.Helper()
|
||||||
|
if o.Cache.Version == 0 {
|
||||||
|
o.Cache = cachefile.New()
|
||||||
|
}
|
||||||
|
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
|
||||||
|
var got, failed []Entry
|
||||||
|
f, _ := Run(context.Background(), o, func(e Entry) error {
|
||||||
|
if e.Error != "" {
|
||||||
|
failed = append(failed, e)
|
||||||
|
} else {
|
||||||
|
got = append(got, e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return got, f, failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(es []Entry, kind, path string) bool {
|
||||||
|
for _, e := range es {
|
||||||
|
if e.Kind == kind && strings.Join(e.Path, "/") == path {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLoadsAndCaches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
az := &counting{Provider: fake.Azure(0)}
|
||||||
|
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
|
||||||
|
for _, want := range [][2]string{
|
||||||
|
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
|
||||||
|
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
|
||||||
|
} {
|
||||||
|
if !has(got, want[0], want[1]) {
|
||||||
|
t.Errorf("no %s %s", want[0], want[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
|
||||||
|
t.Errorf("failed = %+v", errs)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, e := range got {
|
||||||
|
if seen[e.Link] {
|
||||||
|
t.Errorf("%s listed twice", e.Link)
|
||||||
|
}
|
||||||
|
seen[e.Link] = true
|
||||||
|
if e.Provider != "azure" && e.Provider != "repo" {
|
||||||
|
t.Errorf("provider %q", e.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range got {
|
||||||
|
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
|
||||||
|
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
|
||||||
|
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
|
||||||
|
}
|
||||||
|
for k := range f.Containers {
|
||||||
|
if cachefile.ProviderOf(k) != az.ID() {
|
||||||
|
t.Errorf("cached a repo: %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Again with that cache: the same, without asking Azure.
|
||||||
|
before := az.calls.Load()
|
||||||
|
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
|
||||||
|
if len(again) != len(got) {
|
||||||
|
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
|
||||||
|
}
|
||||||
|
// The vault that couldn't be read wasn't cached: it's asked again.
|
||||||
|
if n := az.calls.Load() - before; n != 1 {
|
||||||
|
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale: loaded again, but each entry is still listed once.
|
||||||
|
before = az.calls.Load()
|
||||||
|
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||||
|
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||||
|
if az.calls.Load()-before < 3 {
|
||||||
|
t.Error("stale lists weren't loaded again")
|
||||||
|
}
|
||||||
|
links := map[string]bool{}
|
||||||
|
for _, e := range stale {
|
||||||
|
if links[e.Link] {
|
||||||
|
t.Errorf("%s listed twice", e.Link)
|
||||||
|
}
|
||||||
|
links[e.Link] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCachedOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
az := &counting{Provider: fake.Azure(0)}
|
||||||
|
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||||
|
before := az.calls.Load()
|
||||||
|
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
|
||||||
|
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
|
||||||
|
if az.calls.Load() != before || len(errs) != 0 {
|
||||||
|
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
|
||||||
|
}
|
||||||
|
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
|
||||||
|
t.Error("cached secrets or repos missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCachedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
az := fake.Azure(0)
|
||||||
|
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||||
|
// Forget one vault's secrets: they load, after everything cached.
|
||||||
|
var dropped string
|
||||||
|
for k := range f.Objects {
|
||||||
|
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
|
||||||
|
dropped = k
|
||||||
|
delete(f.Objects, k)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dropped == "" {
|
||||||
|
t.Fatalf("no kv-app-prod in %v", f.Objects)
|
||||||
|
}
|
||||||
|
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
|
||||||
|
last := got[len(got)-1]
|
||||||
|
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
|
||||||
|
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStopsWhenTheReaderGoes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gone := errors.New("broken pipe")
|
||||||
|
n := 0
|
||||||
|
var errs []Entry
|
||||||
|
Run(context.Background(), Options{
|
||||||
|
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
|
||||||
|
Timeout: func(provider.Kind) time.Duration { return time.Second },
|
||||||
|
}, func(e Entry) error {
|
||||||
|
if e.Error != "" {
|
||||||
|
errs = append(errs, e)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
if n == 2 {
|
||||||
|
return gone
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if n != 2 || len(errs) != 0 {
|
||||||
|
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListKinds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// Sources only: nothing under them is asked for.
|
||||||
|
az := &counting{Provider: fake.Azure(0)}
|
||||||
|
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
|
||||||
|
if len(got) == 0 || az.calls.Load() != 1 {
|
||||||
|
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
|
||||||
|
}
|
||||||
|
for _, e := range got {
|
||||||
|
if e.Kind != "source" {
|
||||||
|
t.Errorf("listed a %s", e.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
|
||||||
|
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
|
||||||
|
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
|
||||||
|
t.Errorf("no keys: %+v", got)
|
||||||
|
}
|
||||||
|
for _, e := range got {
|
||||||
|
if e.Kind != "key" {
|
||||||
|
t.Errorf("listed a %s", e.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failed) == 0 {
|
||||||
|
t.Error("a list that failed wasn't listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorsAreOneLine(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
|
||||||
|
"\n timeout\tafter 30s\n": "timeout after 30s",
|
||||||
|
"": "unreadable",
|
||||||
|
} {
|
||||||
|
if got := firstLine(in); got != want {
|
||||||
|
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMarks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
az := fake.Azure(0)
|
||||||
|
var starredLink string
|
||||||
|
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
|
||||||
|
Mark: func(link, account string) Marks {
|
||||||
|
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
|
||||||
|
starredLink = link
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(link, "/api-key") {
|
||||||
|
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
|
||||||
|
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
|
||||||
|
}
|
||||||
|
return Marks{Starred: link == starredLink && account == az.ID()}
|
||||||
|
}})
|
||||||
|
var starred, recent int
|
||||||
|
for _, e := range got {
|
||||||
|
if e.Starred {
|
||||||
|
starred++
|
||||||
|
if e.Link != starredLink {
|
||||||
|
t.Errorf("%s starred", e.Link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
|
||||||
|
t.Errorf("%s: note %q", e.Link, e.Note)
|
||||||
|
}
|
||||||
|
if e.Recent {
|
||||||
|
recent++
|
||||||
|
if e.Uses != 3 || e.LastUsed.IsZero() {
|
||||||
|
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||||
|
}
|
||||||
|
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
|
||||||
|
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if starred != 1 || recent == 0 {
|
||||||
|
t.Errorf("%d starred, %d recent", starred, recent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedListNotRetried(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
az := &counting{Provider: fake.Azure(0)}
|
||||||
|
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||||
|
// Everything stale: sources reload, and their vaults are queued again
|
||||||
|
// as each list arrives. kv-restricted fails once, and isn't asked again.
|
||||||
|
before := az.calls.Load()
|
||||||
|
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||||
|
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||||
|
if len(failed) != 1 {
|
||||||
|
t.Errorf("failed: %+v", failed)
|
||||||
|
}
|
||||||
|
fresh := &counting{Provider: fake.Azure(0)}
|
||||||
|
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
|
||||||
|
if n := az.calls.Load() - before; n > fresh.calls.Load() {
|
||||||
|
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
|
||||||
|
func(Entry) error { return nil }) // no Timeout: a default, not a panic
|
||||||
|
}
|
||||||
@@ -218,6 +218,19 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expire sets when the object name in vault expires, for --demo: a date
|
||||||
|
// relative to today in the test data would change the tests' screenshots
|
||||||
|
// from day to day.
|
||||||
|
func (p *Provider) Expire(vault, name string, at time.Time) {
|
||||||
|
for _, objs := range p.objects {
|
||||||
|
for i := range objs {
|
||||||
|
if objs[i].Container.Name == vault && objs[i].Name == name {
|
||||||
|
objs[i].Expires = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||||
o.Container = c
|
o.Container = c
|
||||||
o.Type = t
|
o.Type = t
|
||||||
@@ -287,9 +300,13 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
Reference: secretRef("kv-app-prod", "api-key"),
|
Reference: secretRef("kv-app-prod", "api-key"),
|
||||||
}, "sk_live_51HxDEMO0000000000000000")
|
}, "sk_live_51HxDEMO0000000000000000")
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||||
}, "deprecated")
|
}, "deprecated")
|
||||||
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
|
||||||
|
Reference: secretRef("kv-app-prod", "smtp-password"),
|
||||||
|
}, "smtp-demo-password")
|
||||||
|
|
||||||
p.add(kv, provider.TypeKey, provider.Object{
|
p.add(kv, provider.TypeKey, provider.Object{
|
||||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||||
@@ -349,6 +366,19 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||||
}, "dev-password")
|
}, "dev-password")
|
||||||
|
// Left behind, as dev vaults are: expired, disabled, or both.
|
||||||
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||||
|
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
||||||
|
}, "whsec_demo_expired")
|
||||||
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
|
||||||
|
Reference: secretRef("kv-app-dev", "test-user-password"),
|
||||||
|
}, "disabled-demo-password")
|
||||||
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
|
||||||
|
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
||||||
|
}, "expired-and-disabled")
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
@@ -366,6 +396,7 @@ func AzureOps(delay time.Duration) *Provider {
|
|||||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||||
|
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
||||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||||
}, "break-glass-demo-password")
|
}, "break-glass-demo-password")
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
@@ -384,6 +415,12 @@ func AzureOps(delay time.Duration) *Provider {
|
|||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||||
}, "pd-demo-token")
|
}, "pd-demo-token")
|
||||||
|
// Expiring soon whenever the demo runs: the other dates are fixed,
|
||||||
|
// and would have passed. Tests use the first account only.
|
||||||
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
|
||||||
|
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
||||||
|
}, "glsa_demo_expiring")
|
||||||
|
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package fake
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seed stars, notes and uses a few of the demo's secrets, so --demo
|
||||||
|
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
|
||||||
|
// admin are the two Azure accounts' provider IDs.
|
||||||
|
func Seed(st *store.Store, alice, admin string) {
|
||||||
|
type item struct {
|
||||||
|
link, account, source string
|
||||||
|
name string // else the link's last part
|
||||||
|
note string
|
||||||
|
path []string
|
||||||
|
star bool
|
||||||
|
uses int
|
||||||
|
ago time.Duration
|
||||||
|
}
|
||||||
|
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
|
||||||
|
for _, it := range []item{
|
||||||
|
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
|
||||||
|
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
|
||||||
|
note: "rotated by the data team each quarter"},
|
||||||
|
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
|
||||||
|
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
|
||||||
|
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
|
||||||
|
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
|
||||||
|
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||||
|
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
|
||||||
|
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||||
|
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
|
||||||
|
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
|
||||||
|
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
|
||||||
|
// A subscription, for both accounts, and a repo's file.
|
||||||
|
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
|
||||||
|
name: "platform-prod", star: true},
|
||||||
|
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
|
||||||
|
name: "platform-prod", star: true},
|
||||||
|
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
|
||||||
|
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
|
||||||
|
path: []string{"lazysecrets-demo"}, star: true},
|
||||||
|
} {
|
||||||
|
context := func(n *store.Node) {
|
||||||
|
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
|
||||||
|
n.Path, n.Source, n.Account = it.path, it.source, it.account
|
||||||
|
}
|
||||||
|
if it.star || it.note != "" {
|
||||||
|
_ = st.Update(it.link, func(n *store.Node) {
|
||||||
|
context(n)
|
||||||
|
if it.star {
|
||||||
|
n.ToggleStar(it.account)
|
||||||
|
}
|
||||||
|
n.Note = it.note
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for range it.uses {
|
||||||
|
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,10 @@ type Ref struct {
|
|||||||
// Share is a share link's sealed files, as in the link; the other
|
// Share is a share link's sealed files, as in the link; the other
|
||||||
// fields are empty. Package share reads it.
|
// fields are empty. Package share reads it.
|
||||||
Share string
|
Share string
|
||||||
|
// Account, when set, is the provider (its ID) to open or read it
|
||||||
|
// with when several could, from lazysecrets list. It isn't part of
|
||||||
|
// the link.
|
||||||
|
Account string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsShare reports whether r is a share link.
|
// IsShare reports whether r is a share link.
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ type Node struct {
|
|||||||
// alone isn't.
|
// alone isn't.
|
||||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||||
|
|
||||||
|
// UsedLately reports whether the item was used within the last 90 days
|
||||||
|
// (what Recent lists). Starred and noted records are kept longer, so a
|
||||||
|
// use alone doesn't say.
|
||||||
|
func (n Node) UsedLately(now time.Time) bool {
|
||||||
|
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
|
||||||
|
}
|
||||||
|
|
||||||
// StarredFor reports whether the item is starred for account.
|
// StarredFor reports whether the item is starred for account.
|
||||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||||
|
|
||||||
|
|||||||
@@ -119,3 +119,15 @@ func TestStarsPerAccount(t *testing.T) {
|
|||||||
t.Errorf("unstar alice: %v", n.StarredBy)
|
t.Errorf("unstar alice: %v", n.StarredBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsedLately(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
|
||||||
|
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
|
||||||
|
t.Errorf("used %v ago: %v", ago, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Node{}).UsedLately(now) {
|
||||||
|
t.Error("never used is lately")
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-9
@@ -131,8 +131,15 @@ type Options struct {
|
|||||||
HandlerSetup *HandlerSetup
|
HandlerSetup *HandlerSetup
|
||||||
// NoHistory stops counting uses of items for the Recent list.
|
// NoHistory stops counting uses of items for the Recent list.
|
||||||
NoHistory bool
|
NoHistory bool
|
||||||
|
// RecentIcon marks items used lately with ◷ in the lists
|
||||||
|
// (ui.recentIcon).
|
||||||
|
RecentIcon bool
|
||||||
// Open is a reference to open at startup (lazysecrets open <ref>).
|
// Open is a reference to open at startup (lazysecrets open <ref>).
|
||||||
Open *ref.Ref
|
Open *ref.Ref
|
||||||
|
// OpenAction, if set, is what to do with Open, rather than asking:
|
||||||
|
// go to it, and maybe copy or show its value (OpenActions). It ends in
|
||||||
|
// the list, like Go to, rather than on the details.
|
||||||
|
OpenAction string
|
||||||
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
||||||
// DefaultFg the terminal's default text colour.
|
// DefaultFg the terminal's default text colour.
|
||||||
Styles *styles.Styles
|
Styles *styles.Styles
|
||||||
@@ -206,6 +213,9 @@ type Model struct {
|
|||||||
statusW int // the status pane's width: the left column's, zoomed or not
|
statusW int // the status pane's width: the left column's, zoomed or not
|
||||||
changelog []panes.Field
|
changelog []panes.Field
|
||||||
cacheDirty bool
|
cacheDirty bool
|
||||||
|
// cacheDropped are cache keys deleted since the last save, so saving
|
||||||
|
// deletes them from the file too.
|
||||||
|
cacheDropped map[string]bool
|
||||||
sourcesAt map[string]time.Time
|
sourcesAt map[string]time.Time
|
||||||
placeDir string
|
placeDir string
|
||||||
savedPlace string // the link last saved as the place
|
savedPlace string // the link last saved as the place
|
||||||
@@ -283,6 +293,8 @@ type Model struct {
|
|||||||
prompt *modal // popup that has the keyboard
|
prompt *modal // popup that has the keyboard
|
||||||
linked []provider.Container // vaults opened from links, this session
|
linked []provider.Container // vaults opened from links, this session
|
||||||
open *ref.Ref // reference to open at startup
|
open *ref.Ref // reference to open at startup
|
||||||
|
recentIcon bool // mark items used lately (◷)
|
||||||
|
openAction string // and what to do with it, or ask
|
||||||
|
|
||||||
shareKey string // the config's sharing.key
|
shareKey string // the config's sharing.key
|
||||||
shareRules share.Rules // and sharing.keyRules
|
shareRules share.Rules // and sharing.keyRules
|
||||||
@@ -415,7 +427,8 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
if m.store == nil {
|
if m.store == nil {
|
||||||
m.store, _ = store.Load("")
|
m.store, _ = store.Load("")
|
||||||
}
|
}
|
||||||
m.sources = newSourcesList(iconSet, m.store)
|
m.recentIcon = opts.RecentIcon
|
||||||
|
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
|
||||||
m.sources.Empty = "no sources found"
|
m.sources.Empty = "no sources found"
|
||||||
m.collapsed = map[string]bool{}
|
m.collapsed = map[string]bool{}
|
||||||
m.flatSources = opts.FlatSources
|
m.flatSources = opts.FlatSources
|
||||||
@@ -447,7 +460,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
}
|
}
|
||||||
m.defaultFg = opts.DefaultFg
|
m.defaultFg = opts.DefaultFg
|
||||||
m.help = newHelp(m.st)
|
m.help = newHelp(m.st)
|
||||||
m.open = opts.Open
|
m.open, m.openAction = opts.Open, opts.OpenAction
|
||||||
saved := m.store.Layout()
|
saved := m.store.Layout()
|
||||||
m.split, m.heights = saved.Split, saved.Heights
|
m.split, m.heights = saved.Split, saved.Heights
|
||||||
|
|
||||||
@@ -521,18 +534,26 @@ func (m Model) Init() tea.Cmd {
|
|||||||
cmds = append(cmds, m.fetchLatest(false))
|
cmds = append(cmds, m.fetchLatest(false))
|
||||||
}
|
}
|
||||||
if m.open != nil {
|
if m.open != nil {
|
||||||
r := *m.open
|
msg := openRefMsg{ref: *m.open, action: m.openAction}
|
||||||
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
|
cmds = append(cmds, func() tea.Msg { return msg })
|
||||||
}
|
}
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openRefMsg starts opening a reference from outside the key handler.
|
// openRefMsg starts opening a reference from outside the key handler:
|
||||||
type openRefMsg struct{ ref ref.Ref }
|
// asking what to do with it, or doing action (OpenActions).
|
||||||
|
type openRefMsg struct {
|
||||||
|
ref ref.Ref
|
||||||
|
action string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenActions are what lazysecrets open --action takes, the Open link
|
||||||
|
// menu's choices that go there.
|
||||||
|
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
|
||||||
|
|
||||||
// OpenLink is a message offering r in the Open link popup, for links
|
// OpenLink is a message offering r in the Open link popup, for links
|
||||||
// handed over by the link handler (Program.Send).
|
// handed over by the link handler (Program.Send).
|
||||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
|
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
@@ -589,7 +610,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case openRefMsg:
|
case openRefMsg:
|
||||||
m.deferHandlerSetup() // the link first
|
m.deferHandlerSetup() // the link first
|
||||||
|
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
|
||||||
|
cmds = append(cmds, m.findRef(msg.ref, act))
|
||||||
|
} else {
|
||||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||||
|
}
|
||||||
|
|
||||||
case resolvedMsg:
|
case resolvedMsg:
|
||||||
m.handleResolved(msg)
|
m.handleResolved(msg)
|
||||||
@@ -666,7 +691,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
m.logErrOf(src.Name, msg.err)
|
m.logErrOf(src.Name, msg.err)
|
||||||
}
|
}
|
||||||
delete(m.cache.containers, msg.key)
|
uncache(&m, m.cache.containers, msg.key)
|
||||||
m.containers.SetError(msg.err)
|
m.containers.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
||||||
@@ -687,7 +712,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if c, ok := m.containers.Selected(); ok {
|
if c, ok := m.containers.Selected(); ok {
|
||||||
m.logErrOf(c.Name, msg.err)
|
m.logErrOf(c.Name, msg.err)
|
||||||
}
|
}
|
||||||
delete(m.cache.objects, msg.key)
|
uncache(&m, m.cache.objects, msg.key)
|
||||||
m.secrets.SetError(msg.err)
|
m.secrets.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
if msg.bound != nil {
|
if msg.bound != nil {
|
||||||
|
|||||||
+17
-15
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// While typing a filter, backspace deletes a character instead.
|
// While typing a filter, backspace deletes a character instead.
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
d.typeText("apx")
|
d.typeText("api-x")
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||||
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
|
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
|
||||||
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
|
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
|
||||||
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3", "/")
|
d.keys("3", "/")
|
||||||
d.typeText("api")
|
d.typeText("api-k")
|
||||||
if d.m.secrets.Len() != 1 {
|
if d.m.secrets.Len() != 1 {
|
||||||
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
|
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
|
||||||
}
|
}
|
||||||
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
d.keys("esc") // leave filter mode, clearing the query
|
d.keys("esc") // leave filter mode, clearing the query
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
d.typeText("api")
|
d.typeText("api-k")
|
||||||
d.keys("enter", "esc") // apply, then esc clears the applied filter
|
d.keys("enter", "esc") // apply, then esc clears the applied filter
|
||||||
if d.m.secrets.Len() != 3 {
|
if d.m.secrets.Len() != 4 {
|
||||||
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
|
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
|
||||||
}
|
}
|
||||||
if _, _, s := d.selected(); s != "api-key" {
|
if _, _, s := d.selected(); s != "api-key" {
|
||||||
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3", "/")
|
d.keys("3", "/")
|
||||||
d.typeText("a") // all three secrets contain an a
|
d.typeText("a") // all four secrets contain an a
|
||||||
if d.m.secrets.Len() != 3 {
|
if d.m.secrets.Len() != 4 {
|
||||||
t.Fatalf("visible = %d", d.m.secrets.Len())
|
t.Fatalf("visible = %d", d.m.secrets.Len())
|
||||||
}
|
}
|
||||||
_, _, initial := d.selected() // kept from before filtering: the middle row
|
_, _, initial := d.selected() // kept from before filtering: the middle row
|
||||||
var seen []string
|
var seen []string
|
||||||
for _, k := range []rune{'j', 'j', 'k'} {
|
for _, k := range []rune{'j', 'j', 'j', 'k'} {
|
||||||
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
|
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
|
||||||
_, _, s := d.selected()
|
_, _, s := d.selected()
|
||||||
seen = append(seen, s)
|
seen = append(seen, s)
|
||||||
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
|
|||||||
if !d.m.secrets.Filtering() {
|
if !d.m.secrets.Filtering() {
|
||||||
t.Fatal("ctrl+j/k left filter mode")
|
t.Fatal("ctrl+j/k left filter mode")
|
||||||
}
|
}
|
||||||
// down to the bottom, stay there, back up to where it started
|
// down to the bottom, stay there, back up one
|
||||||
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
|
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
|
||||||
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
|
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
|
||||||
}
|
}
|
||||||
if !strings.Contains(d.screen(), "ctrl+j/n down") {
|
if !strings.Contains(d.screen(), "ctrl+j/n down") {
|
||||||
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
|
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
|
||||||
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3", "/")
|
d.keys("3", "/")
|
||||||
d.typeText("api")
|
d.typeText("api-k")
|
||||||
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
|
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
|
||||||
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
|
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
|
||||||
t.Errorf("ctrl+y copied %q", d.clip().content)
|
t.Errorf("ctrl+y copied %q", d.clip().content)
|
||||||
@@ -1187,7 +1187,8 @@ func TestDetailsFollowFocus(t *testing.T) {
|
|||||||
func TestStatusLineCountsRepos(t *testing.T) {
|
func TestStatusLineCountsRepos(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t, fake.Azure(0), fake.Repos())
|
d := newDriver(t, fake.Azure(0), fake.Repos())
|
||||||
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
// The status line, not the pane, which an expired secret's ⚠ fills.
|
||||||
|
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
|
||||||
t.Errorf("status line: %q", line)
|
t.Errorf("status line: %q", line)
|
||||||
}
|
}
|
||||||
d = newDriver(t, fake.Azure(0))
|
d = newDriver(t, fake.Azure(0))
|
||||||
@@ -1202,8 +1203,8 @@ func TestStatusPane(t *testing.T) {
|
|||||||
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
||||||
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||||
fake.Azure(0), signedOut{fake.Azure(0)})
|
fake.Azure(0), signedOut{fake.Azure(0)})
|
||||||
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
|
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
|
||||||
t.Errorf("status line:\n%s", d.screen())
|
t.Errorf("status line: %q", line)
|
||||||
}
|
}
|
||||||
d.keys("0")
|
d.keys("0")
|
||||||
if d.m.focus != paneStatus {
|
if d.m.focus != paneStatus {
|
||||||
@@ -1303,7 +1304,8 @@ func TestExpiringInStatus(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
|
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
|
||||||
d.keys("3", "]", "]") // load kv-app-prod's certificates
|
d.keys("3", "]", "]") // load kv-app-prod's certificates
|
||||||
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
|
// old-tls and legacy-token.
|
||||||
|
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
|
||||||
t.Errorf("status line:\n%s", d.screen())
|
t.Errorf("status line:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
d.keys("0")
|
d.keys("0")
|
||||||
|
|||||||
@@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.detail.Refresh(s)
|
m.detail.Refresh(s)
|
||||||
delete(m.cache.details, detailKey(s))
|
uncache(m, m.cache.details, detailKey(s))
|
||||||
return m.describe(s)
|
return m.describe(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uncache deletes key from a cache, and, at the next save, from the file.
|
||||||
|
func uncache[T any](m *Model, c map[string]entry[T], key string) {
|
||||||
|
if _, ok := c[key]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(c, key)
|
||||||
|
if m.cacheDropped == nil {
|
||||||
|
m.cacheDropped = map[string]bool{}
|
||||||
|
}
|
||||||
|
m.cacheDropped[key] = true
|
||||||
|
m.cacheDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
// sameListing reports whether a and b list the same: what a list
|
// sameListing reports whether a and b list the same: what a list
|
||||||
// says of an object, not what describing it adds.
|
// says of an object, not what describing it adds.
|
||||||
func sameListing(a, b provider.Object) bool {
|
func sameListing(a, b provider.Object) bool {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem {
|
|||||||
row := sourceRowKey(p.ID(), src.ID)
|
row := sourceRowKey(p.ID(), src.ID)
|
||||||
for _, c := range m.cache.containers[row].value {
|
for _, c := range m.cache.containers[row].value {
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||||
if state, _ := m.expiry(o); state != notExpiring {
|
if state, _ := m.expiry(o); state != notExpiring {
|
||||||
out = append(out, expiringItem{o, row})
|
out = append(out, expiringItem{o, row})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,28 +12,18 @@ import (
|
|||||||
// list once they have expired (✗ expired) or will within the configured
|
// list once they have expired (✗ expired) or will within the configured
|
||||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||||
|
|
||||||
type expiryState int
|
type expiryState = expiry.State
|
||||||
|
|
||||||
const (
|
const (
|
||||||
notExpiring expiryState = iota
|
notExpiring = expiry.None
|
||||||
expiresSoon
|
expiresSoon = expiry.Soon
|
||||||
expired
|
expired = expiry.Expired
|
||||||
)
|
)
|
||||||
|
|
||||||
// expiry tells whether o has expired or will within the warning period,
|
// expiry tells whether o has expired or will within the warning period,
|
||||||
// and how long it has left.
|
// and how long it has left.
|
||||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
||||||
if o.Expires.IsZero() {
|
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||||
return notExpiring, 0
|
|
||||||
}
|
|
||||||
left := o.Expires.Sub(m.now())
|
|
||||||
switch {
|
|
||||||
case left <= 0:
|
|
||||||
return expired, 0
|
|
||||||
case left <= m.expiryWarning:
|
|
||||||
return expiresSoon, left
|
|
||||||
}
|
|
||||||
return notExpiring, left
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||||
@@ -47,9 +38,4 @@ func (m Model) expiryWord(o provider.Object) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// daysLeft is a short "12d", or "5h" on the last day.
|
// daysLeft is a short "12d", or "5h" on the last day.
|
||||||
func daysLeft(d time.Duration) string {
|
func daysLeft(d time.Duration) string { return expiry.Left(d) }
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,11 @@ package app
|
|||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
@@ -19,6 +21,22 @@ import (
|
|||||||
|
|
||||||
const star = "★ "
|
const star = "★ "
|
||||||
|
|
||||||
|
// recentMark marks items used lately (Recent), with ui.recentIcon.
|
||||||
|
const recentMark = "◷ "
|
||||||
|
|
||||||
|
// marks is what goes before a list row's name: ★ when it's starred for
|
||||||
|
// account and, with recent, ◷ when it was used lately.
|
||||||
|
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
|
||||||
|
var s string
|
||||||
|
if n.StarredFor(account) {
|
||||||
|
s = st.Favourite.Render(star)
|
||||||
|
}
|
||||||
|
if recent && n.UsedLately(now) {
|
||||||
|
s += st.Muted.Render(recentMark)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func sourceKey(s provider.Source) string {
|
func sourceKey(s provider.Source) string {
|
||||||
if r, err := ref.ForSource(s); err == nil {
|
if r, err := ref.ForSource(s); err == nil {
|
||||||
return r.String()
|
return r.String()
|
||||||
@@ -94,9 +112,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
|||||||
}
|
}
|
||||||
line += sty.Muted.Render(" " + c.Detail)
|
line += sty.Muted.Render(" " + c.Detail)
|
||||||
}
|
}
|
||||||
if n.StarredFor(c.Source.Provider) {
|
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
||||||
line = sty.Favourite.Render(star) + line
|
|
||||||
}
|
|
||||||
if withSource {
|
if withSource {
|
||||||
src := c.Source.Name
|
src := c.Source.Name
|
||||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||||
@@ -116,9 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
|||||||
if o.Enabled != nil && !*o.Enabled {
|
if o.Enabled != nil && !*o.Enabled {
|
||||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||||
}
|
}
|
||||||
if n.StarredFor(o.Container.Source.Provider) {
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||||
line = sty.Favourite.Render(star) + line
|
|
||||||
}
|
|
||||||
switch state, left := m.expiry(o); state {
|
switch state, left := m.expiry(o); state {
|
||||||
case expired:
|
case expired:
|
||||||
line += " " + sty.Error.Render("✗ expired")
|
line += " " + sty.Error.Render("✗ expired")
|
||||||
@@ -167,7 +181,7 @@ func (m *Model) rebind(c provider.Container) string {
|
|||||||
m.linked[i].Source = c.Source
|
m.linked[i].Source = c.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key := containerKey(c) + "|" + string(m.objType)
|
key := cachefile.ObjectsKey(c, m.objType)
|
||||||
if m.onLinks() {
|
if m.onLinks() {
|
||||||
m.containers.SetItems(m.linked)
|
m.containers.SetItems(m.linked)
|
||||||
m.containers.Select(containerKey(c))
|
m.containers.Select(containerKey(c))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
@@ -394,7 +395,7 @@ func TestStarWhileFiltering(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3", "/")
|
d.keys("3", "/")
|
||||||
for _, r := range "api" {
|
for _, r := range "api-k" {
|
||||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||||
}
|
}
|
||||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||||
@@ -434,3 +435,20 @@ func TestStarIsPerAccount(t *testing.T) {
|
|||||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecentIcon(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mem, _ := store.Load("")
|
||||||
|
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
|
||||||
|
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
|
||||||
|
d.keys("3")
|
||||||
|
scr := d.screen()
|
||||||
|
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
|
||||||
|
t.Errorf("want ◷ on api-key only:\n%s", scr)
|
||||||
|
}
|
||||||
|
off := newDriverOpts(t, Options{Store: mem})
|
||||||
|
off.keys("3")
|
||||||
|
if strings.Contains(off.screen(), "◷") {
|
||||||
|
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
)
|
)
|
||||||
@@ -160,7 +161,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
|||||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||||
add("web", c.URL)
|
add("web", c.URL)
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
|||||||
if m.history {
|
if m.history {
|
||||||
var used []string
|
var used []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if !nodes[k].LastUsed.IsZero() {
|
if nodes[k].UsedLately(m.now()) {
|
||||||
used = append(used, k)
|
used = append(used, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ type resolvedMsg struct {
|
|||||||
act linkAction
|
act linkAction
|
||||||
sourceRow string
|
sourceRow string
|
||||||
others []string // other accounts' rows that also found the vault
|
others []string // other accounts' rows that also found the vault
|
||||||
|
// find ends in the list, like Go to, not on the details: opened by a
|
||||||
|
// tool that found it (lazysecrets open --action, from fzf).
|
||||||
|
find bool
|
||||||
choices []choice // several candidates: ask
|
choices []choice // several candidates: ask
|
||||||
linked *provider.Container
|
linked *provider.Container
|
||||||
err error
|
err error
|
||||||
@@ -218,6 +221,9 @@ func (m *Model) openSource(r ref.Ref) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if row, ok := preferredRow(r, choices); ok {
|
||||||
|
choices = []choice{{sourceRow: row}}
|
||||||
|
}
|
||||||
switch len(choices) {
|
switch len(choices) {
|
||||||
case 0:
|
case 0:
|
||||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||||
@@ -404,18 +410,59 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
|||||||
for i := range msg.choices {
|
for i := range msg.choices {
|
||||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||||
}
|
}
|
||||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
|
||||||
case len(msg.others) > 0:
|
case len(msg.others) > 0:
|
||||||
var choices []choice
|
var choices []choice
|
||||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||||
}
|
}
|
||||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
if row, ok := preferredRow(msg.ref, choices); ok {
|
||||||
|
m.navResolved(msg, row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
|
||||||
case msg.linked != nil:
|
case msg.linked != nil:
|
||||||
m.addLinked(*msg.linked)
|
m.addLinked(*msg.linked)
|
||||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
m.navResolved(msg, linksRowID)
|
||||||
default:
|
default:
|
||||||
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
m.navResolved(msg, msg.sourceRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferredRow is the choice under the account the reference asks for
|
||||||
|
// (ref.Ref.Account), if it's one of them.
|
||||||
|
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
||||||
|
for _, c := range choices {
|
||||||
|
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
|
||||||
|
return c.sourceRow, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// navResolved goes to what a resolved link points at, under row.
|
||||||
|
func (m *Model) navResolved(msg resolvedMsg, row string) {
|
||||||
|
if msg.find {
|
||||||
|
m.startNav(msg.ref, msg.act, row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.startLinkNav(msg.ref, msg.act, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRef is openRef ending in the list, like Go to: for a link a tool
|
||||||
|
// found (lazysecrets open --action).
|
||||||
|
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
|
||||||
|
cmd := m.openRef(r, act)
|
||||||
|
if cmd == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg := cmd()
|
||||||
|
if rm, ok := msg.(resolvedMsg); ok {
|
||||||
|
rm.find = true
|
||||||
|
return rm
|
||||||
|
}
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("3", "/")
|
d.keys("3", "/")
|
||||||
d.send(tea.PasteMsg{Content: "api"})
|
d.send(tea.PasteMsg{Content: "api-k"})
|
||||||
if d.m.secrets.Len() != 1 || d.m.nav != nil {
|
if d.m.secrets.Len() != 1 || d.m.nav != nil {
|
||||||
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
|
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
|
||||||
}
|
}
|
||||||
@@ -203,8 +203,21 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
|
|||||||
t.Fatal("open at startup didn't ask what to do")
|
t.Fatal("open at startup didn't ask what to do")
|
||||||
}
|
}
|
||||||
d2.keys("o")
|
d2.keys("o")
|
||||||
if _, _, s := d2.selected(); s != "api-key" {
|
if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
|
||||||
t.Errorf("open at startup: selected %q", s)
|
t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --action: no question, it does that.
|
||||||
|
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
|
||||||
|
if d3.m.prompt != nil {
|
||||||
|
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
|
||||||
|
}
|
||||||
|
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
|
||||||
|
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
|
||||||
|
}
|
||||||
|
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
|
||||||
|
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
|
||||||
|
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +318,20 @@ func TestOpenAsksWhichAccount(t *testing.T) {
|
|||||||
t.Errorf("j enter picked %q", c.Source.Provider)
|
t.Errorf("j enter picked %q", c.Source.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lazysecrets open --account: no question, that account.
|
||||||
|
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||||
|
r.Account = "Azure:bob@example.com/t1"
|
||||||
|
d.send(OpenLink(r))
|
||||||
|
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
|
||||||
|
d.keys("o")
|
||||||
|
}
|
||||||
|
if strings.Contains(d.screen(), "Several accounts can see") {
|
||||||
|
t.Fatalf("asked, with an account given:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
|
||||||
|
t.Errorf("--account opened via %q", c.Source.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
// Opening into a folded account unfolds it.
|
// Opening into a folded account unfolds it.
|
||||||
d.keys("1", "g") // alice's header
|
d.keys("1", "g") // alice's header
|
||||||
d.keys("enter") // fold it
|
d.keys("enter") // fold it
|
||||||
|
|||||||
+23
-78
@@ -1,14 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
@@ -23,58 +21,28 @@ import (
|
|||||||
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
||||||
// to go back there on the next start.
|
// to go back there on the next start.
|
||||||
|
|
||||||
// cacheFileVersion is the file's format; a file of another version is
|
|
||||||
// ignored, and replaced on the next save.
|
|
||||||
const cacheFileVersion = 1
|
|
||||||
|
|
||||||
// cacheMaxAge drops what hasn't been loaded again for this long.
|
|
||||||
const cacheMaxAge = 30 * 24 * time.Hour
|
|
||||||
|
|
||||||
// persistEvery is how often a changed cache is saved while running, so
|
// persistEvery is how often a changed cache is saved while running, so
|
||||||
// little is lost if lazysecrets is killed rather than quit.
|
// little is lost if lazysecrets is killed rather than quit.
|
||||||
const persistEvery = 30 * time.Second
|
const persistEvery = 30 * time.Second
|
||||||
|
|
||||||
type cacheFile struct {
|
|
||||||
Version int `json:"version"`
|
|
||||||
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
|
||||||
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
|
|
||||||
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
|
|
||||||
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
|
|
||||||
Cursor map[string]string `json:"cursor,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type stamped[T any] struct {
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
Value T `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type persistTickMsg struct{}
|
type persistTickMsg struct{}
|
||||||
|
|
||||||
type persistedMsg struct{ err error }
|
type persistedMsg struct{ err error }
|
||||||
|
|
||||||
// keepCached reports whether a cache key's account still exists and
|
// keepCached reports whether a cache key's account still exists and
|
||||||
// isn't a repo: every key starts with the provider ID.
|
// isn't a repo: every key starts with the provider ID.
|
||||||
func (m *Model) keepCached(key string) bool {
|
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
|
||||||
id, _ := splitSourceRow(key)
|
|
||||||
p, ok := m.byID[id]
|
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
|
||||||
return ok && p.Kind() != provider.KindRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadCacheFile fills the cache from the file, leaving out what's too
|
// loadCacheFile fills the cache from the file, leaving out what's too
|
||||||
// old or belongs to accounts that aren't signed in any more.
|
// old or belongs to accounts that aren't signed in any more.
|
||||||
func (m *Model) loadCacheFile() {
|
func (m *Model) loadCacheFile() {
|
||||||
b, err := os.ReadFile(m.cachePath)
|
f := cachefile.Read(m.cachePath)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var f cacheFile
|
|
||||||
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := m.now()
|
now := m.now()
|
||||||
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
|
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
|
||||||
for id, e := range f.Sources {
|
for id, e := range f.Sources {
|
||||||
if m.keepCached(id+"|") && recent(e.At) {
|
if m.keepProvider(id) && recent(e.At) {
|
||||||
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
||||||
m.sourcesAt[id] = e.At
|
m.sourcesAt[id] = e.At
|
||||||
}
|
}
|
||||||
@@ -102,33 +70,27 @@ func (m *Model) loadCacheFile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cacheSnapshot is the cache as the file holds it.
|
// cacheSnapshot is the cache as the file holds it.
|
||||||
func (m *Model) cacheSnapshot() cacheFile {
|
func (m *Model) cacheSnapshot() cachefile.File {
|
||||||
f := cacheFile{
|
f := cachefile.New()
|
||||||
Version: cacheFileVersion,
|
f.Dropped, m.cacheDropped = m.cacheDropped, nil
|
||||||
Sources: map[string]stamped[[]provider.Source]{},
|
|
||||||
Containers: map[string]stamped[[]provider.Container]{},
|
|
||||||
Objects: map[string]stamped[[]provider.Object]{},
|
|
||||||
Details: map[string]stamped[provider.Object]{},
|
|
||||||
Cursor: map[string]string{},
|
|
||||||
}
|
|
||||||
for id, r := range m.sourceResults {
|
for id, r := range m.sourceResults {
|
||||||
if r.err == nil && m.keepCached(id+"|") {
|
if r.err == nil && m.keepProvider(id) {
|
||||||
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
|
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, e := range m.cache.containers {
|
for k, e := range m.cache.containers {
|
||||||
if m.keepCached(k) {
|
if m.keepCached(k) {
|
||||||
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
|
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, e := range m.cache.objects {
|
for k, e := range m.cache.objects {
|
||||||
if m.keepCached(k) {
|
if m.keepCached(k) {
|
||||||
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
|
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, e := range m.cache.details {
|
for k, e := range m.cache.details {
|
||||||
if m.keepCached(k) {
|
if m.keepCached(k) {
|
||||||
f.Details[k] = stamped[provider.Object]{e.at, e.value}
|
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, v := range m.cache.cursor {
|
for k, v := range m.cache.cursor {
|
||||||
@@ -139,28 +101,11 @@ func (m *Model) cacheSnapshot() cacheFile {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCacheFile writes f atomically, readable by the user only.
|
// saveCache saves f, merged with the file (lazysecrets list may have
|
||||||
func writeCacheFile(path string, f cacheFile) error {
|
// loaded more since), from any goroutine.
|
||||||
b, err := json.Marshal(f)
|
func (m *Model) saveCache(f cachefile.File) func() error {
|
||||||
if err != nil {
|
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
|
||||||
return err
|
return func() error { return cachefile.Save(path, f, keep, now) }
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
|
||||||
if _, err := tmp.Write(b); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmp.Name(), path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistTick saves the cache, and where lazysecrets is, in the
|
// persistTick saves the cache, and where lazysecrets is, in the
|
||||||
@@ -169,8 +114,8 @@ func (m *Model) persistTick() tea.Cmd {
|
|||||||
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
||||||
if m.cacheDirty && m.cachePath != "" {
|
if m.cacheDirty && m.cachePath != "" {
|
||||||
m.cacheDirty = false
|
m.cacheDirty = false
|
||||||
f, path := m.cacheSnapshot(), m.cachePath
|
save := m.saveCache(m.cacheSnapshot())
|
||||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
|
cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
|
||||||
}
|
}
|
||||||
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
||||||
m.savedPlace = p.Link
|
m.savedPlace = p.Link
|
||||||
@@ -185,7 +130,7 @@ func (m *Model) persistTick() tea.Cmd {
|
|||||||
func (m Model) Persist() error {
|
func (m Model) Persist() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
if m.cachePath != "" {
|
if m.cachePath != "" {
|
||||||
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
|
errs = append(errs, m.saveCache(m.cacheSnapshot())())
|
||||||
}
|
}
|
||||||
if m.placeDir != "" {
|
if m.placeDir != "" {
|
||||||
if p, ok := m.place(); ok {
|
if p, ok := m.place(); ok {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
@@ -80,15 +82,16 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
|||||||
file := filepath.Join(t.TempDir(), "cache.json")
|
file := filepath.Join(t.TempDir(), "cache.json")
|
||||||
az := fake.Azure(0)
|
az := fake.Azure(0)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
f := cacheFile{
|
f := cachefile.File{
|
||||||
Version: cacheFileVersion,
|
Version: cachefile.Version,
|
||||||
Containers: map[string]stamped[[]provider.Container]{
|
Containers: map[string]cachefile.Stamped[[]provider.Container]{
|
||||||
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
|
az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
|
||||||
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
|
az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
|
||||||
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
|
"Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := writeCacheFile(file, f); err != nil {
|
b, _ := json.Marshal(f) // as an older lazysecrets left it
|
||||||
|
if err := os.WriteFile(file, b, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
d := newDriverOpts(t, Options{CacheFile: file}, az)
|
d := newDriverOpts(t, Options{CacheFile: file}, az)
|
||||||
@@ -97,7 +100,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
|||||||
t.Error("dropped a recent entry")
|
t.Error("dropped a recent entry")
|
||||||
}
|
}
|
||||||
if _, ok := c[az.ID()+"|old"]; ok {
|
if _, ok := c[az.ID()+"|old"]; ok {
|
||||||
t.Error("kept an entry older than cacheMaxAge")
|
t.Error("kept an entry older than cachefile.MaxAge")
|
||||||
}
|
}
|
||||||
if _, ok := c["Azure:gone@x/t|recent"]; ok {
|
if _, ok := c["Azure:gone@x/t|recent"]; ok {
|
||||||
t.Error("kept an entry of an account that isn't signed in")
|
t.Error("kept an entry of an account that isn't signed in")
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
// containerKey identifies a container in the UI and caches. The same vault
|
// containerKey identifies a container in the UI and caches. The same vault
|
||||||
// seen through two accounts must not share cached lists, since each
|
// seen through two accounts must not share cached lists, since each
|
||||||
// account may see different things.
|
// account may see different things.
|
||||||
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
|
||||||
|
|
||||||
// providerFor finds the provider instance a source came from. Sources
|
// providerFor finds the provider instance a source came from. Sources
|
||||||
// remembered from an earlier run (favourites) may name an account that
|
// remembered from an earlier run (favourites) may name an account that
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
)
|
)
|
||||||
@@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
return nil
|
return nil
|
||||||
}, reload)
|
}, reload)
|
||||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||||
r, err := ref.ForObject(o, "")
|
r, err := ref.ForObject(o, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
|||||||
}
|
}
|
||||||
for _, v := range e.value {
|
for _, v := range e.value {
|
||||||
c.vaults++
|
c.vaults++
|
||||||
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
key := cachefile.ObjectsKey(v, provider.TypeSecret)
|
||||||
if objs, ok := m.cache.objects[key]; ok {
|
if objs, ok := m.cache.objects[key]; ok {
|
||||||
c.vaultsLoaded++
|
c.vaultsLoaded++
|
||||||
c.secrets += len(objs.value)
|
c.secrets += len(objs.value)
|
||||||
@@ -263,7 +264,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
|
|||||||
// queueContainer queues loading c's secrets, unless loaded lately and not
|
// queueContainer queues loading c's secrets, unless loaded lately and not
|
||||||
// force.
|
// force.
|
||||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||||
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
|||||||
m.crawl.active--
|
m.crawl.active--
|
||||||
key := msg.job.row
|
key := msg.job.row
|
||||||
if msg.job.container != nil {
|
if msg.job.container != nil {
|
||||||
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
|
||||||
}
|
}
|
||||||
delete(m.crawl.pending, key)
|
delete(m.crawl.pending, key)
|
||||||
switch {
|
switch {
|
||||||
@@ -365,7 +366,7 @@ func (m *Model) sourceMark(row string) string {
|
|||||||
}
|
}
|
||||||
loaded, loading := 0, false
|
loaded, loading := 0, false
|
||||||
for _, c := range e.value {
|
for _, c := range e.value {
|
||||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||||
loading = loading || m.crawl.pending[key]
|
loading = loading || m.crawl.pending[key]
|
||||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||||
loaded++
|
loaded++
|
||||||
@@ -386,7 +387,7 @@ func (m *Model) sourceMark(row string) string {
|
|||||||
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
||||||
// its vaults are searchable.
|
// its vaults are searchable.
|
||||||
func (m *Model) vaultMark(c provider.Container) string {
|
func (m *Model) vaultMark(c provider.Container) string {
|
||||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||||
switch {
|
switch {
|
||||||
case m.crawl.pending[key]:
|
case m.crawl.pending[key]:
|
||||||
return m.st.Muted.Render("◌")
|
return m.st.Muted.Render("◌")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
@@ -617,7 +618,7 @@ func (m Model) loadHint() string {
|
|||||||
}
|
}
|
||||||
loaded := 0
|
loaded := 0
|
||||||
for _, c := range e.value {
|
for _, c := range e.value {
|
||||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||||
loaded++
|
loaded++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||||
@@ -39,7 +41,7 @@ type sourceRow struct {
|
|||||||
|
|
||||||
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
|
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
|
||||||
// either; provider IDs contain "/" (Azure:user/tenant).
|
// either; provider IDs contain "/" (Azure:user/tenant).
|
||||||
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
|
||||||
|
|
||||||
func splitSourceRow(row string) (providerID, sourceID string) {
|
func splitSourceRow(row string) (providerID, sourceID string) {
|
||||||
providerID, sourceID, _ = strings.Cut(row, "|")
|
providerID, sourceID, _ = strings.Cut(row, "|")
|
||||||
@@ -56,7 +58,7 @@ func sourceRowID(r sourceRow) string {
|
|||||||
return sourceRowKey(r.provider, r.source.ID)
|
return sourceRowKey(r.provider, r.source.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
||||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||||
ID: sourceRowID,
|
ID: sourceRowID,
|
||||||
// Headers have no filter text, so filtering shows just sources.
|
// Headers have no filter text, so filtering shows just sources.
|
||||||
@@ -78,8 +80,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
|||||||
node := mem.Node(sourceKey(r.source))
|
node := mem.Node(sourceKey(r.source))
|
||||||
kind := st.Kind(r.group)
|
kind := st.Kind(r.group)
|
||||||
starred := ""
|
starred := ""
|
||||||
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
|
if r.kind == rowSource {
|
||||||
starred = st.Favourite.Render(star)
|
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case r.kind == rowLinks:
|
case r.kind == rowLinks:
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||||
│ 2 accounts ││ name platform-prod │
|
│ ⚠ 1 expiring · 2 accounts ││ name platform-prod │
|
||||||
╰───────────────────────────────╯│ ID sub-prod │
|
╰───────────────────────────────╯│ ID sub-prod │
|
||||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||||
│ ▾ Azure (2) ││ vaults 3 │
|
│ ▾ Azure (2) ││ vaults 3 │
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||||
│ db-password ││ │
|
│ db-password ││ │
|
||||||
│ api-key ││ │
|
│ api-key ││ │
|
||||||
│ legacy-token (disabled) ││ │
|
│ legacy-token ✗ expired ││ │
|
||||||
|
│ smtp-password (disabled) ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -28,6 +29,5 @@
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
|
||||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||||
@@ -11,6 +11,7 @@ type Set struct {
|
|||||||
Repo string
|
Repo string
|
||||||
Encrypted string // an encrypted file (SOPS)
|
Encrypted string // an encrypted file (SOPS)
|
||||||
PlainFile string // a plain text secrets file (.env)
|
PlainFile string // a plain text secrets file (.env)
|
||||||
|
Note string // has a note (lazysecrets list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// None renders no icons.
|
// None renders no icons.
|
||||||
@@ -24,6 +25,7 @@ var NerdFont = Set{
|
|||||||
Repo: "\U000F02A2", // md-git
|
Repo: "\U000F02A2", // md-git
|
||||||
Encrypted: "\U000F033E", // md-lock
|
Encrypted: "\U000F033E", // md-lock
|
||||||
PlainFile: "\U000F0214", // md-file
|
PlainFile: "\U000F0214", // md-file
|
||||||
|
Note: "\U000F09ED",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
||||||
|
|||||||
Reference in new issue
Block a user