Compare commits
34
Commits
v0.2.0
..
5946f855bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5946f855bd
|
||
|
|
824f7249c3
|
||
|
|
2c9c37f809
|
||
|
|
37164158ca
|
||
|
|
4473e037f8
|
||
|
|
2db3d28d6d
|
||
|
|
e49a2d8b53
|
||
|
|
94aa74b05d
|
||
|
|
419a5f3047
|
||
|
|
c548688715
|
||
|
|
b4e4c1515b
|
||
|
|
33fa064104
|
||
|
|
33a9b0864d
|
||
|
|
0e4580f245
|
||
|
|
46af233bd3
|
||
|
|
3bc0fc8fd6
|
||
|
|
4c822e42f1
|
||
|
|
4cb40a1598
|
||
|
|
77e3fcd551
|
||
|
|
50156d706e
|
||
|
|
b0ea217d87
|
||
|
|
470f0c11e4
|
||
|
|
e8d3a3337a
|
||
|
|
bdf949339b
|
||
|
|
a4002a785c
|
||
|
|
b05d19209c
|
||
|
|
1f330bc619
|
||
|
|
572cea8351
|
||
|
|
3734989b15
|
||
|
|
24b38d7321
|
||
|
|
7b377fd468
|
||
|
|
baee1dfe61
|
||
|
|
e99462bd21
|
||
|
|
b1455451e6
|
No files matched your search
@@ -2,18 +2,6 @@
|
|||||||
|
|
||||||
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,12 +12,16 @@ 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.
|
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||||
|
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.
|
||||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||||
pipes.
|
pipes, and `lazysecrets list` everything it can find, for fzf and editor
|
||||||
|
pickers ([integration](docs/integration.md)).
|
||||||
- **Favourites**: star the secrets you use most for easy access.
|
- **Favourites**: star the secrets you use most for easy access.
|
||||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||||
marked, listed together in the status pane.
|
marked, listed together in the status pane.
|
||||||
@@ -77,32 +81,16 @@ 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
|
||||||
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
- [Links](docs/links.md): sharing, opening, and opening links from the
|
||||||
opening links, also from the browser and chat
|
browser and chat
|
||||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
- [Integration](docs/integration.md): `lazysecrets get` in scripts, and
|
||||||
|
`lazysecrets list` for fzf and editor pickers
|
||||||
- [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
|
||||||
|
|||||||
+31
-42
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -18,7 +17,6 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"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/listing"
|
||||||
"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"
|
||||||
@@ -53,7 +51,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
|
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)")
|
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)")
|
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)")
|
notes := fs.Bool("notes", true, "put ✎ before names with a note, in mutedColor (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)")
|
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)")
|
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\")")
|
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
||||||
@@ -77,7 +75,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
return fmt.Errorf("--color: %q is not always or never", *color)
|
return fmt.Errorf("--color: %q is not always or never", *color)
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
|
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, notes: *notes, expiry: *expiry,
|
||||||
color: *color == "always", warn: expiryWarn, now: now}
|
color: *color == "always", warn: expiryWarn, now: now}
|
||||||
if *iconsOn {
|
if *iconsOn {
|
||||||
m.icons = icons.NerdFont
|
m.icons = icons.NerdFont
|
||||||
@@ -95,7 +93,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
if status == "" && *expiry {
|
if status == "" && *expiry {
|
||||||
status = expiryOf(e, theme, expiryWarn, now, colored)
|
status = expiryOf(e, theme, expiryWarn, now, colored)
|
||||||
}
|
}
|
||||||
note := noteOf(e, theme, m.icons, *notes, colored)
|
note := renderer(colored)(theme.Muted, e.Note)
|
||||||
// A tab or line break in a name would break the line up.
|
// 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),
|
_, 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)
|
oneLine.Replace(note), e.Account, e.Link)
|
||||||
@@ -128,18 +126,20 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
if cachePath != "" {
|
if cachePath != "" {
|
||||||
f = cachefile.Read(cachePath)
|
f = cachefile.Read(cachePath)
|
||||||
}
|
}
|
||||||
f, loaded := listing.Run(context.Background(), listing.Options{
|
f = listing.Run(context.Background(), listing.Options{
|
||||||
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
||||||
Mark: func(link, account string) listing.Marks {
|
Mark: func(link, account string) (bool, time.Time, int, string) {
|
||||||
n := st.Node(link)
|
n := st.Node(link)
|
||||||
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
return n.StarredFor(account), n.LastUsed, n.Uses, n.Note
|
||||||
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
|
|
||||||
},
|
},
|
||||||
}, emit)
|
}, emit)
|
||||||
// Only what was loaded: list never moves a cursor, so the TUI's stay.
|
if cachePath != "" && !*cached {
|
||||||
if cachePath != "" && loaded {
|
byID := map[string]provider.Provider{}
|
||||||
f.Cursor = nil
|
for _, p := range providers {
|
||||||
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
|
byID[p.ID()] = p
|
||||||
|
}
|
||||||
|
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
|
||||||
|
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,31 +151,21 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
|
|
||||||
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
|
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,
|
// 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
|
// or when it will if that's within warn (expires in 12d), in yellow, or
|
||||||
// is "". Its mark (✗, ⚠) is before the name.
|
// is "". Its mark (✗, ⚠) is before the name.
|
||||||
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
|
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
|
||||||
render := renderer(color)
|
render := renderer(color)
|
||||||
switch state, left := expiry.Of(e.Expires, now, warn); state {
|
left := e.Expires.Sub(now)
|
||||||
case expiry.Expired:
|
switch {
|
||||||
return render(theme.Error, "expired")
|
case e.Expires.IsZero() || left > warn:
|
||||||
case expiry.Soon:
|
|
||||||
return render(theme.Warning, "expires in "+expiry.Left(left))
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
|
case left <= 0:
|
||||||
|
return render(theme.Error, "expired")
|
||||||
|
case left < 24*time.Hour:
|
||||||
|
return render(theme.Warning, fmt.Sprintf("expires in %dh", max(1, int(left.Hours()))))
|
||||||
|
}
|
||||||
|
return render(theme.Warning, fmt.Sprintf("expires in %dd", int(left.Hours()/24)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
|
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
|
||||||
@@ -204,13 +194,13 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
|
|||||||
// marks says what goes before the name, in a field of its own so a
|
// 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
|
// 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
|
// 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
|
// entry and ◷ for one used lately, as the TUI marks them; ✎ for one with a
|
||||||
// provider's icon. The marks are right-aligned in room for every kind
|
// note; 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.
|
// turned on, so they sit by the icon and names line up.
|
||||||
type marks struct {
|
type marks struct {
|
||||||
theme styles.Styles
|
theme styles.Styles
|
||||||
icons icons.Set
|
icons icons.Set
|
||||||
stars, recent, expiry, color bool
|
stars, recent, notes, expiry, color bool
|
||||||
warn time.Duration // ui.expiryWarningDays
|
warn time.Duration // ui.expiryWarningDays
|
||||||
now time.Time
|
now time.Time
|
||||||
}
|
}
|
||||||
@@ -226,14 +216,10 @@ func (m marks) of(e listing.Entry) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// What's wrong, then what to watch: room for one of them.
|
// What's wrong, then what to watch: room for one of them.
|
||||||
state := expiry.None
|
switch left := e.Expires.Sub(m.now); {
|
||||||
if m.expiry {
|
case e.Error != "" || (m.expiry && !e.Expires.IsZero() && left <= 0):
|
||||||
state, _ = expiry.Of(e.Expires, m.now, m.warn)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case e.Error != "" || state == expiry.Expired:
|
|
||||||
mark(true, m.theme.Error, "✗")
|
mark(true, m.theme.Error, "✗")
|
||||||
case state == expiry.Soon:
|
case m.expiry && !e.Expires.IsZero() && left <= m.warn:
|
||||||
mark(true, m.theme.Warning, "⚠")
|
mark(true, m.theme.Warning, "⚠")
|
||||||
default:
|
default:
|
||||||
mark(false, m.theme.Error, "")
|
mark(false, m.theme.Error, "")
|
||||||
@@ -244,6 +230,9 @@ func (m marks) of(e listing.Entry) string {
|
|||||||
if m.recent {
|
if m.recent {
|
||||||
mark(e.Recent, m.theme.Muted, "◷")
|
mark(e.Recent, m.theme.Muted, "◷")
|
||||||
}
|
}
|
||||||
|
if m.notes {
|
||||||
|
mark(e.Note != "", m.theme.Muted, "✎")
|
||||||
|
}
|
||||||
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
|
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
|
||||||
kind := provider.Kind("")
|
kind := provider.Kind("")
|
||||||
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
|
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
|
||||||
|
|||||||
@@ -87,19 +87,20 @@ func TestListExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListNotes(t *testing.T) {
|
func TestListNoteMark(t *testing.T) {
|
||||||
e := listing.Entry{Note: "rotate in May"}
|
az := icons.NerdFont.Azure
|
||||||
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
|
m := marks{icons: icons.NerdFont, stars: true, notes: true}
|
||||||
t.Errorf("icons: %q", got)
|
for _, c := range []struct {
|
||||||
|
e listing.Entry
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{listing.Entry{Provider: "azure", Note: "rotate in May"}, " ✎ " + az},
|
||||||
|
{listing.Entry{Provider: "azure", Note: "x", Starred: 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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,11 +322,8 @@ Flags:
|
|||||||
// 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"
|
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
|
||||||
fake.Seed(st, alice, admin)
|
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{
|
||||||
az,
|
fake.Azure(400*time.Millisecond).As(alice, "Azure · alice"),
|
||||||
fake.AzureOps(700*time.Millisecond).As(admin, "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(),
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 232 KiB |
+2
-2
@@ -54,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](sharing.md#sharing-env-files) (`sharing`), the
|
[sharing env files](links.md#sharing-env-files) (`sharing`), the
|
||||||
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
[link handler](links.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`).
|
||||||
|
|
||||||
|
|||||||
+57
-54
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
lazysecrets works outside its own window too: `lazysecrets get` reads a
|
lazysecrets works outside its own window too: `lazysecrets get` reads a
|
||||||
value for scripts, and `lazysecrets list` lists everything for fzf, editor
|
value for scripts, and `lazysecrets list` lists everything for fzf, editor
|
||||||
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
|
pickers and your own tools. Both take the [links](links.md) lazysecrets
|
||||||
copies and shares. For Neovim, there's
|
copies and shares. For Neovim, there's
|
||||||
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
|
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
|
||||||
|
|
||||||
@@ -19,8 +19,7 @@ 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
|
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
|
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.
|
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
|
The value ends with a newline only on a terminal.
|
||||||
value, it says why on stderr and exits with status 1.
|
|
||||||
|
|
||||||
## Listing, for fzf and pickers
|
## Listing, for fzf and pickers
|
||||||
|
|
||||||
@@ -30,65 +29,69 @@ tab-separated:
|
|||||||
| Field | |
|
| Field | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
|
| 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` |
|
| 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` |
|
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
|
||||||
| link | its `lazysecrets://` link |
|
| link | its `lazysecrets://` link |
|
||||||
|
|
||||||
What's cached prints at once, then what loads follows as it arrives: read
|
It's what Go to searches, so fzf, or an editor's picker, can search it
|
||||||
it as a stream. Values are never listed. It stops quietly when the reader
|
instead, showing the names and acting on the links:
|
||||||
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
|
```sh
|
||||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \
|
||||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
--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-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-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {2..-3})' \
|
||||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||||
```
|
```
|
||||||

|
|
||||||
|
|
||||||
|
`--with-nth='2..-3'` shows and searches only the names; `{-1}` and
|
||||||
|
`--accept-nth=-1` are the link, `{-2}` the account. All but the name are
|
||||||
|
one word, so this works without `--delimiter`. To filter by kind
|
||||||
|
yourself: `awk -F'\t' '$1 == "secret"'`.
|
||||||
|
|
||||||
|
`enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
|
||||||
|
lazysecrets, `ctrl-y` copies the value, saying so in the header, and
|
||||||
|
`ctrl-x` copies it and closes fzf (`pbcopy` on macOS; `wl-copy` or `xclip`
|
||||||
|
on Linux). A value copied this way isn't cleared from the
|
||||||
|
clipboard after `values.clipboardClear`, as one copied in lazysecrets is.
|
||||||
|
|
||||||
|
`--account` makes `open` and `get` use the account it was listed through,
|
||||||
|
when several of yours can see it: without, `open` asks which, and `get`
|
||||||
|
tries the one that last read it first. The link stays as it is, to copy
|
||||||
|
and share; the account isn't part of it. `--action` tells `open` what to
|
||||||
|
do rather than asking, as it does for a link clicked elsewhere: `go` to
|
||||||
|
it, `copy` its value too, or `show` it. It ends in the secrets list, like
|
||||||
|
Go to, rather than on the details as a clicked link does.
|
||||||
|
|
||||||
|
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
|
||||||
|
what's missing or older than `--max-age` (2m), and what's new in it follows
|
||||||
|
as it arrives: fzf shows it while you type. `--cached` loads nothing from
|
||||||
|
Azure or Databricks, for an answer at once. Repos are always read, from
|
||||||
|
disk. What it loads is cached for the TUI too. It stops when the reader
|
||||||
|
goes, after a pick in fzf.
|
||||||
|
|
||||||
|
`--kind` lists only some kinds, comma-separated: `source`, `vault` (also
|
||||||
|
Databricks scopes and repo files), `secret`, `key`, `certificate`. Lists
|
||||||
|
only other kinds need aren't loaded: `--kind source,vault` asks for no
|
||||||
|
secrets. Keys and certificates load only when asked for this way; else,
|
||||||
|
like Go to, they're listed as far as you opened them in lazysecrets.
|
||||||
|
|
||||||
|
A vault or source it can't read is a line too, as it fails: its name,
|
||||||
|
then `✗` and why in red (`errorColor`), with its link, so opening it
|
||||||
|
shows more. They're in the list, not on stderr, which would draw over fzf.
|
||||||
|
`--errors=false` leaves them out.
|
||||||
|
|
||||||
|
The name is coloured from your [theme](config.md#theme), with the
|
||||||
|
provider's icon when [icons](config.md#theme) are on (`ui.icons`); fzf
|
||||||
|
shows the colours with `--ansi`. `--color never` leaves them out, for
|
||||||
|
scripts, and `--icons=false` the icons. The link is never coloured.
|
||||||
|
|
||||||
|
`--format json` prints an object per line instead, for editor plugins,
|
||||||
|
without colours or icons, and with `error` on a list it couldn't read:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"Azure:alice@example.com/<tenant>","accountLabel":"alice"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`accountLabel`, the account's short name, is there when several az
|
||||||
|
accounts are signed in. Values are never listed.
|
||||||
+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](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.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) |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Sharing
|
# Links
|
||||||
|
|
||||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
|
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
|
||||||
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
be 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 a link
|
## Sharing
|
||||||
|
|
||||||
`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 a link
|
## Opening
|
||||||
|
|
||||||
- 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,
|
||||||
+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](sharing.md#sharing-env-files) with:
|
[shared](links.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](sharing.md#sharing-env-files).
|
`S` → `f` shares env files as an [encrypted link](links.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 [sharing](sharing.md).
|
See [links](links.md).
|
||||||
@@ -32,10 +32,6 @@ type File struct {
|
|||||||
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
|
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||||
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
|
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
|
||||||
Cursor map[string]string `json:"cursor,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.
|
// Stamped is a list or detail with when it was loaded.
|
||||||
@@ -73,16 +69,6 @@ func ProviderOf(key string) string {
|
|||||||
return id
|
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
|
// Read reads path; a missing, unreadable or other version's file is an
|
||||||
// empty cache.
|
// empty cache.
|
||||||
func Read(path string) File {
|
func Read(path string) File {
|
||||||
@@ -104,27 +90,17 @@ func Read(path string) File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save writes f to path, merged with the file there: of a list in both,
|
// 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
|
// the one loaded last wins, and f's cursors win. Entries older than
|
||||||
// file's). Entries older than MaxAge, those of providers keep says no to
|
// MaxAge, and those of providers keep says no to (accounts signed out
|
||||||
// (accounts signed out of), and f.Dropped are dropped, from the file's
|
// of), are dropped, from the file's side as well.
|
||||||
// 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 {
|
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()
|
out := New()
|
||||||
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
|
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
|
||||||
disk := Read(path)
|
disk := Read(path)
|
||||||
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
|
merge(out.Sources, disk.Sources, f.Sources, ok)
|
||||||
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
|
merge(out.Containers, disk.Containers, f.Containers, ok)
|
||||||
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
|
merge(out.Objects, disk.Objects, f.Objects, ok)
|
||||||
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
|
merge(out.Details, disk.Details, f.Details, ok)
|
||||||
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
|
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
if keep(ProviderOf(k)) {
|
if keep(ProviderOf(k)) {
|
||||||
@@ -135,19 +111,14 @@ func Save(path string, f File, keep func(providerID string) bool, now time.Time)
|
|||||||
return write(path, out)
|
return write(path, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge puts disk's entries in out, but not those dropped, then ours
|
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) {
|
||||||
// where they're newer.
|
for _, m := range []map[string]Stamped[T]{disk, ours} {
|
||||||
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 m {
|
||||||
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)) {
|
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
|
||||||
out[k] = e
|
out[k] = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write writes f atomically, readable by the user only.
|
// write writes f atomically, readable by the user only.
|
||||||
@@ -156,6 +127,9 @@ func write(path string, f File) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -89,34 +89,3 @@ func TestReadOtherVersion(t *testing.T) {
|
|||||||
t.Errorf("read another version's file: %+v", got)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//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 }
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-39
@@ -51,11 +51,6 @@ type Entry struct {
|
|||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
// Note is the user's note on it, if any.
|
// Note is the user's note on it, if any.
|
||||||
Note string `json:"note,omitempty"`
|
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
|
// 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
|
// listed. The entry is then that source or vault, again, or with an
|
||||||
// account's sources unreadable, the account without a link.
|
// account's sources unreadable, the account without a link.
|
||||||
@@ -74,37 +69,25 @@ type Options struct {
|
|||||||
// Lists only needed for other kinds aren't loaded: sources and vaults
|
// Lists only needed for other kinds aren't loaded: sources and vaults
|
||||||
// alone don't load any secrets. Lists that fail are always listed.
|
// alone don't load any secrets. Lists that fail are always listed.
|
||||||
Kinds []string
|
Kinds []string
|
||||||
// Mark tells what's remembered about the entry with link, seen
|
// Mark tells whether the entry with link is starred for account,
|
||||||
// through account; nil marks nothing.
|
// when it was last used and how often (zero: not lately), and its
|
||||||
Mark func(link, account string) Marks
|
// note; nil marks nothing.
|
||||||
Timeout func(provider.Kind) time.Duration // nil: 30s
|
Mark func(link, account string) (starred bool, lastUsed time.Time, uses int, note string)
|
||||||
|
Timeout func(provider.Kind) time.Duration
|
||||||
Now func() time.Time
|
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
|
// 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
|
// 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
|
// error from emit (the reader went away) stops it. It returns the cache
|
||||||
// with what was loaded, and whether anything was.
|
// with what was loaded.
|
||||||
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
|
func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
|
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)}
|
||||||
results: make(chan result)}
|
|
||||||
if l.o.Now == nil {
|
if l.o.Now == nil {
|
||||||
l.o.Now = time.Now
|
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 {
|
for _, p := range o.Providers {
|
||||||
if l.stopped != nil {
|
if l.stopped != nil {
|
||||||
break
|
break
|
||||||
@@ -123,7 +106,7 @@ func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File
|
|||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return l.o.Cache, l.loaded
|
return l.o.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
type job struct {
|
type job struct {
|
||||||
@@ -147,8 +130,6 @@ type lister struct {
|
|||||||
emit func(Entry) error
|
emit func(Entry) error
|
||||||
seen map[string]bool // provider ID + link of entries emitted
|
seen map[string]bool // provider ID + link of entries emitted
|
||||||
pending map[string]bool // cache keys queued or loading
|
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
|
queue []job
|
||||||
active int
|
active int
|
||||||
results chan result
|
results chan result
|
||||||
@@ -226,12 +207,12 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
|
|||||||
}
|
}
|
||||||
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
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),
|
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})
|
Expires: o.Expires, Enabled: o.Enabled})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceEntry(src provider.Source) Entry {
|
func sourceEntry(src provider.Source) Entry {
|
||||||
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
|
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src)}
|
||||||
if r, err := ref.ForSource(src); err == nil {
|
if r, err := ref.ForSource(src); err == nil {
|
||||||
e.Link = r.String()
|
e.Link = r.String()
|
||||||
}
|
}
|
||||||
@@ -239,7 +220,7 @@ func sourceEntry(src provider.Source) Entry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func containerEntry(c provider.Container) Entry {
|
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}
|
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source)}
|
||||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||||
e.Link = r.String()
|
e.Link = r.String()
|
||||||
}
|
}
|
||||||
@@ -254,11 +235,8 @@ func (l *lister) put(p provider.Provider, e Entry) {
|
|||||||
}
|
}
|
||||||
e.Account = p.ID()
|
e.Account = p.ID()
|
||||||
if l.o.Mark != nil && e.Link != "" {
|
if l.o.Mark != nil && e.Link != "" {
|
||||||
m := l.o.Mark(e.Link, e.Account)
|
e.Starred, e.LastUsed, e.Uses, e.Note = l.o.Mark(e.Link, e.Account)
|
||||||
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
|
e.Recent = !e.LastUsed.IsZero()
|
||||||
if m.Recent {
|
|
||||||
e.LastUsed, e.Uses = m.LastUsed, m.Uses
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
|
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
|
||||||
if l.stopped != nil || l.seen[id] {
|
if l.stopped != nil || l.seen[id] {
|
||||||
@@ -282,7 +260,7 @@ func (l *lister) wants(kinds ...string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *lister) enqueue(j job) {
|
func (l *lister) enqueue(j job) {
|
||||||
if l.pending[j.key] || l.failed[j.key] {
|
if l.pending[j.key] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.pending[j.key] = true
|
l.pending[j.key] = true
|
||||||
@@ -318,11 +296,9 @@ func (l *lister) handle(r result) {
|
|||||||
delete(l.pending, r.key)
|
delete(l.pending, r.key)
|
||||||
now := l.o.Now()
|
now := l.o.Now()
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
l.failed[r.key] = true
|
|
||||||
l.put(r.p, r.failed())
|
l.put(r.p, r.failed())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.loaded = l.loaded || l.cacheable(r.p)
|
|
||||||
cache := l.cacheable(r.p)
|
cache := l.cacheable(r.p)
|
||||||
switch {
|
switch {
|
||||||
case r.c != nil:
|
case r.c != nil:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
|
|||||||
}
|
}
|
||||||
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
|
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
|
||||||
var got, failed []Entry
|
var got, failed []Entry
|
||||||
f, _ := Run(context.Background(), o, func(e Entry) error {
|
f := Run(context.Background(), o, func(e Entry) error {
|
||||||
if e.Error != "" {
|
if e.Error != "" {
|
||||||
failed = append(failed, e)
|
failed = append(failed, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -87,11 +87,6 @@ func TestListLoadsAndCaches(t *testing.T) {
|
|||||||
t.Errorf("provider %q", e.Provider)
|
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 {
|
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))
|
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
|
||||||
}
|
}
|
||||||
@@ -234,17 +229,14 @@ func TestListMarks(t *testing.T) {
|
|||||||
az := fake.Azure(0)
|
az := fake.Azure(0)
|
||||||
var starredLink string
|
var starredLink string
|
||||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
|
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
|
||||||
Mark: func(link, account string) Marks {
|
Mark: func(link, account string) (bool, time.Time, int, string) {
|
||||||
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
|
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
|
||||||
starredLink = link
|
starredLink = link
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(link, "/api-key") {
|
if strings.HasSuffix(link, "/api-key") {
|
||||||
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
|
return false, time.Now(), 3, "rotate in May"
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
|
return link == starredLink && account == az.ID(), time.Time{}, 0, ""
|
||||||
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
|
|
||||||
}
|
|
||||||
return Marks{Starred: link == starredLink && account == az.ID()}
|
|
||||||
}})
|
}})
|
||||||
var starred, recent int
|
var starred, recent int
|
||||||
for _, e := range got {
|
for _, e := range got {
|
||||||
@@ -270,28 +262,3 @@ func TestListMarks(t *testing.T) {
|
|||||||
t.Errorf("%d starred, %d recent", starred, recent)
|
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,19 +218,6 @@ 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
|
||||||
@@ -300,13 +287,9 @@ 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: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -3, 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),
|
||||||
|
|||||||
@@ -32,13 +32,9 @@ func Seed(st *store.Store, alice, admin string) {
|
|||||||
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
|
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",
|
{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},
|
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",
|
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
|
||||||
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
|
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
|
||||||
// A subscription, for both accounts, and a repo's file.
|
// A subscription, 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",
|
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
|
||||||
name: "platform-prod", star: true},
|
name: "platform-prod", star: true},
|
||||||
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
|
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ 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,15 +119,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -213,9 +213,6 @@ 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
|
||||||
@@ -691,7 +688,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)
|
||||||
}
|
}
|
||||||
uncache(&m, m.cache.containers, msg.key)
|
delete(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()}
|
||||||
@@ -712,7 +709,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)
|
||||||
}
|
}
|
||||||
uncache(&m, m.cache.objects, msg.key)
|
delete(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 {
|
||||||
|
|||||||
@@ -686,7 +686,7 @@ func TestFilter(t *testing.T) {
|
|||||||
d.keys("/")
|
d.keys("/")
|
||||||
d.typeText("api-k")
|
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() != 4 {
|
if d.m.secrets.Len() != 3 {
|
||||||
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 four secrets contain an a
|
d.typeText("a") // all three secrets contain an a
|
||||||
if d.m.secrets.Len() != 4 {
|
if d.m.secrets.Len() != 3 {
|
||||||
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', 'j', 'k'} {
|
for _, k := range []rune{'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 one
|
// down to the bottom, stay there, back up to where it started
|
||||||
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
|
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
|
||||||
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
|
t.Errorf("from %q, ctrl+j, ctrl+j, 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())
|
||||||
|
|||||||
@@ -67,23 +67,10 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.detail.Refresh(s)
|
m.detail.Refresh(s)
|
||||||
uncache(m, m.cache.details, detailKey(s))
|
delete(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,7 +5,6 @@ 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"
|
||||||
@@ -29,7 +28,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[cachefile.ObjectsKey(c, t)].value {
|
for _, o := range m.cache.objects[containerKey(c)+"|"+string(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,10 +1,9 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,18 +11,28 @@ 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 = expiry.State
|
type expiryState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
notExpiring = expiry.None
|
notExpiring expiryState = iota
|
||||||
expiresSoon = expiry.Soon
|
expiresSoon
|
||||||
expired = expiry.Expired
|
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) {
|
||||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
if o.Expires.IsZero() {
|
||||||
|
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".
|
||||||
@@ -38,4 +47,9 @@ 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 { return expiry.Left(d) }
|
func daysLeft(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))
|
||||||
|
}
|
||||||
@@ -3,11 +3,9 @@ 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"
|
||||||
@@ -26,12 +24,12 @@ const recentMark = "◷ "
|
|||||||
|
|
||||||
// marks is what goes before a list row's name: ★ when it's starred for
|
// marks is what goes before a list row's name: ★ when it's starred for
|
||||||
// account and, with recent, ◷ when it was used lately.
|
// 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 {
|
func marks(st styles.Styles, n store.Node, account string, recent bool) string {
|
||||||
var s string
|
var s string
|
||||||
if n.StarredFor(account) {
|
if n.StarredFor(account) {
|
||||||
s = st.Favourite.Render(star)
|
s = st.Favourite.Render(star)
|
||||||
}
|
}
|
||||||
if recent && n.UsedLately(now) {
|
if recent && !n.LastUsed.IsZero() {
|
||||||
s += st.Muted.Render(recentMark)
|
s += st.Muted.Render(recentMark)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
@@ -112,7 +110,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
|||||||
}
|
}
|
||||||
line += sty.Muted.Render(" " + c.Detail)
|
line += sty.Muted.Render(" " + c.Detail)
|
||||||
}
|
}
|
||||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
line = marks(sty, n, c.Source.Provider, m.recentIcon) + 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 {
|
||||||
@@ -132,7 +130,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)")
|
||||||
}
|
}
|
||||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + 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")
|
||||||
@@ -181,7 +179,7 @@ func (m *Model) rebind(c provider.Container) string {
|
|||||||
m.linked[i].Source = c.Source
|
m.linked[i].Source = c.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key := cachefile.ObjectsKey(c, m.objType)
|
key := containerKey(c) + "|" + string(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))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -161,7 +160,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[cachefile.ObjectsKey(c, t)]; ok {
|
if e, ok := m.cache.objects[containerKey(c)+"|"+string(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].UsedLately(m.now()) {
|
if !nodes[k].LastUsed.IsZero() {
|
||||||
used = append(used, k)
|
used = append(used, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,14 @@ type persistedMsg struct{ err error }
|
|||||||
// 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 { return m.keepProvider(cachefile.ProviderOf(key)) }
|
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
|
||||||
|
|
||||||
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
|
func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) }
|
||||||
|
|
||||||
|
func keepIn(byID map[string]provider.Provider) func(id string) bool {
|
||||||
|
return func(id string) bool {
|
||||||
|
p, ok := byID[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.
|
||||||
@@ -72,7 +79,6 @@ 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.File {
|
func (m *Model) cacheSnapshot() cachefile.File {
|
||||||
f := cachefile.New()
|
f := cachefile.New()
|
||||||
f.Dropped, m.cacheDropped = m.cacheDropped, nil
|
|
||||||
for id, r := range m.sourceResults {
|
for id, r := range m.sourceResults {
|
||||||
if r.err == nil && m.keepProvider(id) {
|
if r.err == nil && m.keepProvider(id) {
|
||||||
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
||||||
@@ -104,7 +110,7 @@ func (m *Model) cacheSnapshot() cachefile.File {
|
|||||||
// saveCache saves f, merged with the file (lazysecrets list may have
|
// saveCache saves f, merged with the file (lazysecrets list may have
|
||||||
// loaded more since), from any goroutine.
|
// loaded more since), from any goroutine.
|
||||||
func (m *Model) saveCache(f cachefile.File) func() error {
|
func (m *Model) saveCache(f cachefile.File) func() error {
|
||||||
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
|
path, keep, now := m.cachePath, keepIn(m.byID), m.now()
|
||||||
return func() error { return cachefile.Save(path, f, keep, now) }
|
return func() error { return cachefile.Save(path, f, keep, now) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -90,7 +89,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[cachefile.ObjectsKey(c, t)].value {
|
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||||
r, err := ref.ForObject(o, "")
|
r, err := ref.ForObject(o, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -167,7 +166,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
|||||||
}
|
}
|
||||||
for _, v := range e.value {
|
for _, v := range e.value {
|
||||||
c.vaults++
|
c.vaults++
|
||||||
key := cachefile.ObjectsKey(v, provider.TypeSecret)
|
key := containerKey(v) + "|" + string(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)
|
||||||
@@ -264,7 +263,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 := cachefile.ObjectsKey(c, provider.TypeSecret)
|
key := containerKey(c) + "|" + string(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
|
||||||
}
|
}
|
||||||
@@ -328,7 +327,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 = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
|
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
||||||
}
|
}
|
||||||
delete(m.crawl.pending, key)
|
delete(m.crawl.pending, key)
|
||||||
switch {
|
switch {
|
||||||
@@ -366,7 +365,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 := cachefile.ObjectsKey(c, provider.TypeSecret)
|
key := containerKey(c) + "|" + string(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++
|
||||||
@@ -387,7 +386,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 := cachefile.ObjectsKey(c, provider.TypeSecret)
|
key := containerKey(c) + "|" + string(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,7 +12,6 @@ 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"
|
||||||
@@ -618,7 +617,7 @@ func (m Model) loadHint() string {
|
|||||||
}
|
}
|
||||||
loaded := 0
|
loaded := 0
|
||||||
for _, c := range e.value {
|
for _, c := range e.value {
|
||||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
key := containerKey(c) + "|" + string(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,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -81,7 +80,7 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
|
|||||||
kind := st.Kind(r.group)
|
kind := st.Kind(r.group)
|
||||||
starred := ""
|
starred := ""
|
||||||
if r.kind == rowSource {
|
if r.kind == rowSource {
|
||||||
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
starred = marks(st, node, r.source.Provider, recent)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case r.kind == rowLinks:
|
case r.kind == rowLinks:
|
||||||
|
|||||||
+3
-3
@@ -20,8 +20,7 @@
|
|||||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||||
│ db-password ││ │
|
│ db-password ││ │
|
||||||
│ api-key ││ │
|
│ api-key ││ │
|
||||||
│ legacy-token ✗ expired ││ │
|
│ legacy-token (disabled) ✗ exp…││ │
|
||||||
│ smtp-password (disabled) ││ │
|
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -29,5 +28,6 @@
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
╰────────────────────── 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,7 +11,6 @@ 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.
|
||||||
@@ -25,7 +24,6 @@ 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