feat: each provider can have its own timeout
CI / Checks (pull_request) Successful in 41s
PR title / Conventional title (pull_request) Successful in 1s

providers.azure.timeout, providers.databricks.timeout and
providers.repos.timeout override providers.timeout for that provider's
requests; --timeout still sets them all. lazysecrets share, which reads
a key and repo files, waits the longest of them.
This commit is contained in:
keb committed 2026-10-13 19:58:42 +02:00
1 parent 46c702a27c
commit be83a9d074
11 files changed
+106 -29

No files matched your search

+28 -5
View File
@@ -3,6 +3,7 @@
package main
import (
"cmp"
"context"
"flag"
"fmt"
@@ -106,7 +107,7 @@ func run() error {
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)")
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,
"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)")
@@ -207,9 +208,24 @@ Flags:
// Precedence: flag > environment > config file > default.
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
// Each provider's own timeout, unless --timeout sets them all.
timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
}
for k, d := range map[provider.Kind]time.Duration{
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
}
@@ -314,12 +330,18 @@ Flags:
log.SetOutput(io.Discard)
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel()
return get(ctx, providers, st, *getRef)
}
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()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
}
@@ -372,6 +394,7 @@ Flags:
m := app.New(providers, app.Options{
Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons,
ClipboardClear: *clipClear,
FlatSources: cfg.UI.SourcesView == "flat",
+4 -1
View File
@@ -27,17 +27,20 @@ updates:
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
providers:
timeout: 30s # per backend request
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account
enabled: true # default
timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
+8 -1
View File
@@ -123,7 +123,8 @@ type KeyRules struct {
}
type Providers struct {
// Timeout bounds each backend request; defaults to 30s.
// 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"`
Databricks Databricks `yaml:"databricks"`
@@ -134,6 +135,8 @@ type Providers struct {
// that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots.
type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"`
@@ -148,6 +151,8 @@ type Repos struct {
}
type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -158,6 +163,8 @@ type Azure struct {
}
type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"`
+2 -1
View File
@@ -29,6 +29,7 @@ storage:
providers:
timeout: 1m
azure:
timeout: 2m
hideSubscriptions: [platform-mgmt]
vaults:
- https://kv-foo.vault.azure.net/
@@ -40,7 +41,7 @@ providers:
t.Fatal(err)
}
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Providers.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)
}
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
+31 -14
View File
@@ -65,6 +65,8 @@ const (
type Options struct {
// Timeout bounds every provider call. Defaults to 30s.
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 bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -146,7 +148,7 @@ type Clipboard interface {
type Model struct {
providers []provider.Provider
byID map[string]provider.Provider
timeout time.Duration
timeout timeouts
keys keys.Map
st styles.Styles
@@ -378,7 +380,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{
providers: providers,
byID: map[string]provider.Provider{},
timeout: opts.Timeout,
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
keys: keys.Default(),
st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -1246,7 +1248,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
}
m.secrets.SetLoading()
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects)
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
return func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1276,7 +1278,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}
}
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 {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1427,11 +1429,26 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart()
}
// begin starts a cancellable request for pane p, cancelling any in flight.
func (m *Model) begin(p slot) (context.Context, int) {
// timeouts bound each provider request: its kind's, else the default.
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.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
return ctx, m.seq
}
@@ -1485,7 +1502,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen
var cmds []tea.Cmd
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 {
defer cancel()
s, err := p.Sources(ctx)
@@ -1496,7 +1513,7 @@ func (m Model) sourcesCmd() tea.Cmd {
})
// The source it started in, quickly, on its own.
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 {
defer cancel()
src, ok := c.Current(ctx)
@@ -1513,7 +1530,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src)
ctx, req := m.begin(slotContainers)
ctx, req := m.begin(slotContainers, src.Kind)
return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later
@@ -1532,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 {
cands := m.candidates(c.Source)
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 {
var errs []error
for _, p := range cands {
@@ -1560,7 +1577,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue)
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
m.detail.SetLoading()
return func() tea.Msg {
v, err := p.Value(ctx, s)
@@ -1579,7 +1596,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing()
}
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 {
obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1607,7 +1624,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true)
}
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 + "…"}
return func() tea.Msg {
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
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout
timeout := m.timeout.of(o.Container.Source.Kind)
m.usedObject(o)
return func() tea.Msg {
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}
ctx, req := m.begin(slotDetails)
ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
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}
return nil
}
ctx, req := m.begin(slotCopy)
ctx, req := m.begin(slotCopy, r.Kind)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
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 {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
@@ -311,6 +311,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
}
c := *job.container
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
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")
return nil
}
general, timeout := m.shareKey, m.timeout
general, timeout := m.shareKey, m.timeout.of(src.Kind)
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
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) {
providers, st, timeout := m.providers, m.store, m.timeout
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()
k := key
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.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
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 + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
+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)
}
}