feat: config grouped by area, with per-provider timeouts and an update interval (#60)

Regroups the config, while there are no users to migrate:

- `ui`: icons, sourcesView, collapseGroups, loadDelay, expiryWarningDays
- `values`: revealTimeout, hideOnBlur, clipboardClear
- `storage`: cache, history
- `updates`: autoCheck (was updateCheck), autoCheckInterval (new, 24h by default), updateSources (was releases)
- `providers.timeout` (was timeout), plus `timeout` on azure, databricks and repos overriding it for that provider; `--timeout` still sets them all
- `sharing` (was share), in the config and in a repo's `.lazysecrets.yaml`

docs/config.md and the pages naming these keys are updated.

Reviewed-on: #60
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #60.
This commit is contained in:
keb authored and Karl-Einar Bergstrøm committed 2026-10-13 20:23:55 +02:00
1 parent 5cd6d90555
commit 10d1fc63c7
24 files changed
+302 -166

No files matched your search

+2 -2
View File
@@ -99,10 +99,10 @@ use most:
lazysecrets never stores, caches or sends your secret values. It keeps the lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`cache: false` turns it off). It talks you, to show it at once next time (`storage.cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updateCheck: false` turns off. git.kebcloud.no, which `updates.autoCheck: false` turns off.
## Repositories ## Repositories
+52 -24
View File
@@ -3,6 +3,7 @@
package main package main
import ( import (
"cmp"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -106,7 +107,7 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit") showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)") dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends") demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request") timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
icons := flag.Bool("icons", true, icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0") "show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)") clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
@@ -207,30 +208,49 @@ Flags:
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Timeout > 0 { // Each provider's own timeout, unless --timeout sets them all.
*timeout = cfg.Timeout timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
} }
if !set["clipboard-clear"] && cfg.ClipboardClear != nil { for k, d := range map[provider.Kind]time.Duration{
*clipClear = *cfg.ClipboardClear provider.KindAzure: cfg.Providers.Azure.Timeout,
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
}
}
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
} }
expiryDays := 30 expiryDays := 30
if cfg.ExpiryWarningDays != nil { if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays expiryDays = *cfg.UI.ExpiryWarningDays
} }
revealTimeout := 60 * time.Second revealTimeout := 60 * time.Second
if cfg.RevealTimeout != nil { if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout revealTimeout = *cfg.Values.RevealTimeout
} }
loadDelay := 150 * time.Millisecond loadDelay := 150 * time.Millisecond
if cfg.LoadDelay != nil { if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.LoadDelay loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
} }
if !set["icons"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0" *icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.Icons != nil: case cfg.UI.Icons != nil:
*icons = *cfg.Icons *icons = *cfg.UI.Icons
} }
} }
@@ -310,14 +330,20 @@ Flags:
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) return get(ctx, providers, st, *getRef)
} }
if shareArgs != nil { if shareArgs != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) // The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
for _, t := range timeouts {
d = max(d, t)
}
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel() defer cancel()
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout) return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
} }
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -354,7 +380,7 @@ Flags:
// What was loaded last time, and where it was left in this folder. // What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string var cacheFile, placeDir string
if !*demo { if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) { if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json") cacheFile = filepath.Join(d, "cache.json")
} }
placeDir = *dir placeDir = *dir
@@ -368,19 +394,21 @@ Flags:
m := app.New(providers, app.Options{ m := app.New(providers, app.Options{
Timeout: *timeout, Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons, Icons: *icons,
ClipboardClear: *clipClear, ClipboardClear: *clipClear,
FlatSources: cfg.SourcesView == "flat", FlatSources: cfg.UI.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups), CollapseGroups: config.On(cfg.UI.CollapseGroups),
LoadDelay: loadDelay, LoadDelay: loadDelay,
NoHistory: !config.On(cfg.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Releases), Updater: updater(cfg.Updates.UpdateSources),
UpdateCheck: config.On(cfg.UpdateCheck), UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
@@ -390,7 +418,7 @@ Flags:
StatePath: statePath, StatePath: statePath,
Changelog: lazysecrets.Changelog, Changelog: lazysecrets.Changelog,
Dir: placeDir, Dir: placeDir,
ShareKey: cfg.Share.Key, ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg), ShareRules: shareRules(cfg),
Open: open, Open: open,
Styles: &theme, Styles: &theme,
+1 -1
View File
@@ -89,5 +89,5 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
// shareRules are the config's share.keyRules. // shareRules are the config's share.keyRules.
func shareRules(cfg config.Config) share.Rules { func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses} return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
} }
+1 -1
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Releases} c := &update.Client{Servers: cfg.Updates.UpdateSources}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
+1 -1
View File
@@ -63,7 +63,7 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
(`[`/`]`). `space` reveals a secret's value, or the public material of a (`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within fetched. `v` lists an item's versions. Items expiring within
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. `ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
+32 -23
View File
@@ -7,37 +7,45 @@ status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again. it isn't there yet); changes apply when lazysecrets starts again.
```yaml ```yaml
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 ui:
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown sourcesView: grouped # or flat; ` toggles it
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
timeout: 30s # per backend request expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once values:
sourcesView: grouped # or flat; ` toggles it revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) storage:
cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R)
updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often
updateSources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account azure: # applies to every az account
enabled: true # default enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover - https://kv-foo.vault.azure.net/ # you can read but not discover
databricks: databricks:
enabled: true enabled: true
timeout: 30s
hideProfiles: [test] hideProfiles: [test]
repos: repos:
enabled: true enabled: true
timeout: 30s
roots: [~/git] roots: [~/git]
paths: [~/work/infra] paths: [~/work/infra]
include: ["secrets/**"] include: ["secrets/**"]
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
share: # sharing env files as encrypted links sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least minLength: 32 # characters, 16 at least
@@ -45,7 +53,7 @@ share: # 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) (`share`), the [sharing env files](links.md#sharing-env-files) (`sharing`), the
[link handler](links.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`).
@@ -84,7 +92,7 @@ theme:
``` ```
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `icons: false`. as boxes, turn them off with `ui.icons: false`.
## Layering ## Layering
@@ -112,7 +120,7 @@ lazysecrets windows can save to it without losing each other's changes.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
for Recent (`R`). Records kept only for usage are dropped after 90 days for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `history: false` stops counting. unused, and beyond the 100 most recent. `storage.history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in source, vault or secret), and goes back there the next time it starts in
@@ -132,15 +140,16 @@ and copying always fetch the value live.
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
are read from disk anyway. Deleting the file is always safe, and are read from disk anyway. Deleting the file is always safe, and
`cache: false` stops using it. `storage.cache: false` stops using it.
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks for the At startup, in the background, lazysecrets asks for the latest release
latest release; within the day it reuses the answer from the state file. (`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
Releases are published on GitHub and on git.kebcloud.no, and it asks until then it reuses the answer from the state file. Releases are
them in that order, the next when one doesn't answer within 5 seconds; published on GitHub and on git.kebcloud.no, and it asks them in that
`releases` lists others, such as a mirror of your own. Offline, or with order, the next when one doesn't answer within 5 seconds;
`updates.updateSources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows its changelog. `lazysecrets update --check`) shows its changelog.
+2 -2
View File
@@ -65,8 +65,8 @@ certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored.
A revealed value hides again after `revealTimeout` (60s) and when the A revealed value hides again after `values.revealTimeout` (60s) and
terminal loses focus (`hideOnBlur`); see [config](config.md). when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
+2 -2
View File
@@ -54,7 +54,7 @@ uses it) or your config (for repos that don't name one), by its
reference: reference:
```yaml ```yaml
share: sharing:
key: https://kv-team.vault.azure.net/secrets/env-share key: https://kv-team.vault.azure.net/secrets/env-share
``` ```
@@ -67,7 +67,7 @@ isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less: that's 32 characters of two kinds; your config can ask for more, or less:
```yaml ```yaml
share: sharing:
keyRules: keyRules:
minLength: 32 # characters; 16 at least minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
+1 -1
View File
@@ -60,7 +60,7 @@ A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with: [shared](links.md#sharing-env-files) with:
```yaml ```yaml
share: sharing:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
``` ```
+82 -50
View File
@@ -19,44 +19,18 @@ import (
) )
type Config struct { type Config struct {
Icons *bool `yaml:"icons"` UI UI `yaml:"ui"`
ClipboardClear *time.Duration `yaml:"clipboardClear"` // Values keeps revealed and copied values from lingering.
// RevealTimeout hides a revealed value after this long; 0s never. Values Values `yaml:"values"`
// Defaults to 60s. // Storage is what's kept between runs.
RevealTimeout *time.Duration `yaml:"revealTimeout"` Storage Storage `yaml:"storage"`
// ExpiryWarningDays marks secrets, keys and certificates expiring // Updates sets how lazysecrets looks for newer releases.
// within this many days; 0 marks only expired ones. Defaults to 30. Updates Updates `yaml:"updates"`
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
Timeout time.Duration `yaml:"timeout"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
// UpdateCheck looks for a newer release once a day, in the
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// Releases are the repositories updates come from, tried in order:
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
// git.kebcloud.no (update.DefaultServers).
Releases []string `yaml:"releases"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
Providers Providers `yaml:"providers"` Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler. // LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"` LinkHandler LinkHandler `yaml:"linkHandler"`
// Share sets how env files are shared; see package share. // Sharing sets how env files are shared; see package share.
Share Share `yaml:"share"` Sharing Sharing `yaml:"sharing"`
// CustomCommands run shell commands on the selected item; see // CustomCommands run shell commands on the selected item; see
// package commands. // package commands.
CustomCommands []commands.Command `yaml:"customCommands"` CustomCommands []commands.Command `yaml:"customCommands"`
@@ -68,6 +42,52 @@ type Config struct {
Theme map[string][]string `yaml:"theme"` Theme map[string][]string `yaml:"theme"`
} }
type UI struct {
Icons *bool `yaml:"icons"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
}
type Values struct {
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
}
type Storage struct {
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
}
type Updates struct {
// AutoCheck looks for a newer release in the background at startup,
// at most once per AutoCheckInterval; defaults to true. U and
// lazysecrets update work either way.
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// UpdateSources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers).
UpdateSources []string `yaml:"updateSources"`
}
type LinkHandler struct { type LinkHandler struct {
// Terminal to open links in, overriding the one handler install // Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty, // detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -85,7 +105,7 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"` Prompt *bool `yaml:"prompt"`
} }
type Share struct { type Sharing struct {
// Key is the secret whose value encrypts shared files (its // Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose // lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one. // .lazysecrets.yaml doesn't name one.
@@ -103,6 +123,9 @@ type KeyRules struct {
} }
type Providers struct { type Providers struct {
// Timeout bounds each backend request; defaults to 30s. Each
// provider's own timeout overrides it for that provider.
Timeout time.Duration `yaml:"timeout"`
Azure Azure `yaml:"azure"` Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"` Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"` Repos Repos `yaml:"repos"`
@@ -112,6 +135,8 @@ type Providers struct {
// that repo is the only one shown; elsewhere, the ones in Paths and under // that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots. // Roots.
type Repos struct { type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"` Paths []string `yaml:"paths"`
@@ -126,6 +151,8 @@ type Repos struct {
} }
type Azure struct { type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID. // HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -136,6 +163,8 @@ type Azure struct {
} }
type Databricks struct { type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"` HideProfiles []string `yaml:"hideProfiles"`
@@ -222,35 +251,38 @@ func decodeInto(c *Config, path string) error {
} }
func (c Config) validate(path string) error { func (c Config) validate(path string) error {
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" { if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v) return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
} }
for _, cmd := range c.CustomCommands { for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil { if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
} }
if d := c.ExpiryWarningDays; d != nil && *d < 0 { if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d) return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
} }
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
} }
for i, r := range c.Releases { if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.UpdateSources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) return fmt.Errorf("%s: updates.updateSources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
} }
} }
if k := c.Share.Key; k != "" { if k := c.Sharing.Key; k != "" {
if _, err := share.ParseKey(k); err != nil { if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: share.key: %w", path, err) return fmt.Errorf("%s: sharing.key: %w", path, err)
} }
} }
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n) return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
} }
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 { if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n) return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
} }
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
+38 -27
View File
@@ -19,12 +19,17 @@ func write(t *testing.T, content string) string {
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := Load(write(t, `
icons: false ui:
sourcesView: flat icons: false
clipboardClear: 45s sourcesView: flat
timeout: 1m values:
clipboardClear: 45s
storage:
history: false
providers: providers:
timeout: 1m
azure: azure:
timeout: 2m
hideSubscriptions: [platform-mgmt] hideSubscriptions: [platform-mgmt]
vaults: vaults:
- https://kv-foo.vault.azure.net/ - https://kv-foo.vault.azure.net/
@@ -35,12 +40,12 @@ providers:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second || if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" { c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c) t.Errorf("config = %+v", c)
} }
if c.SourcesView != "flat" { if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
t.Errorf("sourcesView = %q", c.SourcesView) t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
} }
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) { if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong") t.Error("enabled flags wrong")
@@ -57,7 +62,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := Load(path)
if err != nil || c.Icons != nil || c.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
} }
@@ -68,14 +73,16 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n", "typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n", "bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n", "flat keys": "icons: false\n",
"view": "sourcesView: tree\n", "duration": "providers:\n timeout: soon\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n", "view": "ui:\n sourcesView: tree\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n", "sources": "updates:\n updateSources: [git.example.com/team/lazysecrets]\n",
"share key": "share:\n key: https://kv.vault.azure.net/\n", "http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n",
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n", "interval": "updates:\n autoCheckInterval: 0s\n",
"short keys": "share:\n keyRules:\n minLength: 8\n", "share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"classes": "share:\n keyRules:\n minClasses: 5\n", "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
@@ -87,7 +94,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) { func TestLoadAllLayers(t *testing.T) {
user := write(t, ` user := write(t, `
icons: false ui:
icons: false
theme: theme:
azureColor: [blue] azureColor: [blue]
errorColor: [red, bold] errorColor: [red, bold]
@@ -96,6 +104,8 @@ providers:
hideSubscriptions: [a] hideSubscriptions: [a]
`) `)
theme := write(t, ` theme := write(t, `
ui:
sourcesView: flat
theme: theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold] activeBorderColor: ["#ff966c", bold]
@@ -104,7 +114,8 @@ theme:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 { // A group set in both files merges: icons from one, sourcesView the other.
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c) t.Errorf("user settings lost: %+v", c)
} }
want := map[string][]string{ want := map[string][]string{
@@ -119,19 +130,19 @@ theme:
} }
} }
func TestReleases(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n")) c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n updateSources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || len(c.Releases) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n")) c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil { if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+3 -3
View File
@@ -599,11 +599,11 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
Share struct { Sharing struct {
// Key is the link to the secret that encrypts the repo's // Key is the link to the secret that encrypts the repo's
// shared env files (package share). // shared env files (package share).
Key string `yaml:"key"` Key string `yaml:"key"`
} `yaml:"share"` } `yaml:"sharing"`
} }
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the share.key of the repo's .lazysecrets.yaml, if any.
@@ -619,7 +619,7 @@ func ShareKey(root string) (string, error) {
if err := yaml.Unmarshal(b, &f); err != nil { if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
return f.Share.Key, nil return f.Sharing.Key, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
+1 -1
View File
@@ -243,7 +243,7 @@ func TestDefaultKey(t *testing.T) {
if err != nil || !ok || k.Name != "env-share" { if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err) t.Errorf("general: %+v %v %v", k, ok, err)
} }
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644) os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" { if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k) t.Errorf("the repo's should win: %+v", k)
} }
+39 -16
View File
@@ -65,6 +65,8 @@ const (
type Options struct { type Options struct {
// Timeout bounds every provider call. Defaults to 30s. // Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends. // Icons shows Nerd Font glyphs next to backends.
Icons bool Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH. // Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -117,9 +119,11 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply). // Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with // Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks once a day on its own. // UpdateCheck, lazysecrets also looks on its own at startup, at most
// once per UpdateEvery (0 for a day).
Updater *Updater Updater *Updater
UpdateCheck bool UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup. // HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list. // NoHistory stops counting uses of items for the Recent list.
@@ -144,7 +148,7 @@ type Clipboard interface {
type Model struct { type Model struct {
providers []provider.Provider providers []provider.Provider
byID map[string]provider.Provider byID map[string]provider.Provider
timeout time.Duration timeout timeouts
keys keys.Map keys keys.Map
st styles.Styles st styles.Styles
@@ -236,6 +240,7 @@ type Model struct {
updater *Updater updater *Updater
updateCheck bool updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart updated string // the release installed, waiting for a restart
@@ -375,7 +380,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{ m := Model{
providers: providers, providers: providers,
byID: map[string]provider.Provider{}, byID: map[string]provider.Provider{},
timeout: opts.Timeout, timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
keys: keys.Default(), keys: keys.Default(),
st: styles.Default(), st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)), spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -427,7 +432,10 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.startUpdateCheck() m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt) m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay m.settle = opts.LoadDelay
@@ -1240,7 +1248,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
} }
m.secrets.SetLoading() m.secrets.SetLoading()
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1270,7 +1278,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
} }
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions) ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1421,11 +1429,26 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart() return m.crawlStart()
} }
// begin starts a cancellable request for pane p, cancelling any in flight. // timeouts bound each provider request: its kind's, else the default.
func (m *Model) begin(p slot) (context.Context, int) { type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
m.cancelReq(p) m.cancelReq(p)
m.seq++ m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
m.reqID[p], m.cancel[p] = m.seq, cancel m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq return ctx, m.seq
} }
@@ -1479,7 +1502,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen gen := m.sourcesGen
var cmds []tea.Cmd var cmds []tea.Cmd
for _, p := range m.providers { for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
s, err := p.Sources(ctx) s, err := p.Sources(ctx)
@@ -1490,7 +1513,7 @@ func (m Model) sourcesCmd() tea.Cmd {
}) })
// The source it started in, quickly, on its own. // The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok { if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
src, ok := c.Current(ctx) src, ok := c.Current(ctx)
@@ -1507,7 +1530,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd { func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src) p := m.providerFor(src)
ctx, req := m.begin(slotContainers) ctx, req := m.begin(slotContainers, src.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later // Carry the source as given, with its provider ID, so later
@@ -1526,7 +1549,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd { func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source) cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
var errs []error var errs []error
for _, p := range cands { for _, p := range cands {
@@ -1554,7 +1577,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd { func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source) p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue) ctx, req := m.begin(slotValue, s.Container.Source.Kind)
m.detail.SetLoading() m.detail.SetLoading()
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, s) v, err := p.Value(ctx, s)
@@ -1573,7 +1596,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing() m.detail.SetDescribing()
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe) ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o) obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err} return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1601,7 +1624,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true) return m.copyText(o.Name, v.Reveal(), true)
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
m.flash = flash{text: "copying " + o.Name + "…"} m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, o) v, err := p.Value(ctx, o)
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj o := *obj
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"} m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout timeout := m.timeout.of(o.Container.Source.Kind)
m.usedObject(o) m.usedObject(o)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
+1 -1
View File
@@ -196,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
} }
} }
m.details[key] = detailsState{loading: true} m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails) ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c) attrs, err := d.Details(ctx, c)
if ctx.Err() != nil { if ctx.Err() != nil {
+1 -1
View File
@@ -158,7 +158,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true} m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil return nil
} }
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, r.Kind)
m.flash = flash{text: "copying " + r.Name + "…"} m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
var errs []error var errs []error
+2 -1
View File
@@ -295,10 +295,10 @@ func (m *Model) crawlNext() tea.Cmd {
} }
func (m *Model) crawlCmd(job crawlJob) tea.Cmd { func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil { if job.src != nil {
p := m.providerFor(*job.src) p := m.providerFor(*job.src)
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
@@ -311,6 +311,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
} }
c := *job.container c := *job.container
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
+3 -3
View File
@@ -59,7 +59,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
m.flash = warning("these files can't be shared") m.flash = warning("these files can't be shared")
return nil return nil
} }
general, timeout := m.shareKey, m.timeout general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"} m.flash = flash{text: "reading the files…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -216,7 +216,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) { func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) { return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
defer cancel() defer cancel()
k := key k := key
k.Version = "" k.Version = ""
@@ -258,7 +258,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd {
// openShare reads the key, decrypts the files and finds the repo's clone. // openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd { func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader() read := m.keyReader()
providers, timeout := m.providers, m.timeout providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"} m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
msg := shareOpenedMsg{sealed: s} msg := shareOpenedMsg{sealed: s}
+1 -1
View File
@@ -137,7 +137,7 @@ func TestShareWithShareKeyConfirms(t *testing.T) {
} }
root := cloneWith(t, map[string]string{ root := cloneWith(t, map[string]string{
".env": "A=1\n", ".env": "A=1\n",
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n", ".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
}) })
clip := &fakeClipboard{} clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root})) d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// A provider's own timeout applies to it alone.
func TestTimeoutPerProvider(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
scr := d.screen()
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
strings.Contains(scr, "Databricks: context deadline") {
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
}
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
t.Errorf("no override: %s, want the default", got)
}
}
+4 -3
View File
@@ -20,7 +20,8 @@ type Updater struct {
} }
// updateEvery is how often lazysecrets looks for a newer release on its // updateEvery is how often lazysecrets looks for a newer release on its
// own; within that, the last answer (in the state file) is used. // own unless the config says otherwise; within that, the last answer (in
// the state file) is used.
const updateEvery = 24 * time.Hour const updateEvery = 24 * time.Hour
type updateMsg struct { type updateMsg struct {
@@ -35,14 +36,14 @@ type installedMsg struct {
} }
// startUpdateCheck sets up the startup look for a newer release: the // startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked less than a day ago, else a quiet // state file's answer when it looked within updateEvery, else a quiet
// look at the server (fetchOnStart, for Init). // look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() { func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck { if m.updater == nil || !m.updateCheck {
return return
} }
last := m.store.UpdateCheck() last := m.store.UpdateCheck()
if m.now().Sub(last.At) < updateEvery { if m.now().Sub(last.At) < m.updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL}) m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return return
} }
+6
View File
@@ -83,6 +83,12 @@ func TestUpdate(t *testing.T) {
if f2.asked != 1 { if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked) t.Errorf("asked %d times a day later", f2.asked)
} }
// With autoCheckInterval: 1h, two hours on is long enough.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-2 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, UpdateEvery: time.Hour, Store: mem})
if f2.asked != 2 {
t.Errorf("asked %d times, want a look after the hour", f2.asked)
}
} }
func TestUpdateAsked(t *testing.T) { func TestUpdateAsked(t *testing.T) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// Usage: revealing, copying, opening in the browser, sharing and jumping // Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last // to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's // used and how often, for the Recent section of the jump list. The config's
// history: false turns this off. // storage.history: false turns this off.
// used counts a use of the remembered item key, keeping its context. // used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) { func (m *Model) used(key string) {