From be83a9d07496e416216c770dce06b3383647bc63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 19:58:33 +0200 Subject: [PATCH] feat: each provider can have its own timeout 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. --- cmd/lazysecrets/main.go | 33 ++++++++++++++++++++---- docs/config.md | 5 +++- internal/config/config.go | 9 ++++++- internal/config/config_test.go | 3 ++- internal/ui/app/app.go | 45 +++++++++++++++++++++++---------- internal/ui/app/commands.go | 2 +- internal/ui/app/fields.go | 2 +- internal/ui/app/links.go | 2 +- internal/ui/app/search.go | 3 ++- internal/ui/app/sharefiles.go | 6 ++--- internal/ui/app/timeout_test.go | 25 ++++++++++++++++++ 11 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 internal/ui/app/timeout_test.go diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 61b602d..3face6b 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -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", diff --git a/docs/config.md b/docs/config.md index 7cf5927..4125ab4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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/**"] diff --git a/internal/config/config.go b/internal/config/config.go index f415d3b..c82970b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3011dd..28a3fe8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 45ab4ee..448dda8 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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) diff --git a/internal/ui/app/commands.go b/internal/ui/app/commands.go index 6631aca..08b0999 100644 --- a/internal/ui/app/commands.go +++ b/internal/ui/app/commands.go @@ -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) diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 1aa7e63..4b6b2c8 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -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 { diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 21bfae4..3063558 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -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 diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go index 0b4cc47..3a8ec03 100644 --- a/internal/ui/app/search.go +++ b/internal/ui/app/search.go @@ -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() diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index e5cf8a5..06f5fb0 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -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} diff --git a/internal/ui/app/timeout_test.go b/internal/ui/app/timeout_test.go new file mode 100644 index 0000000..4b20a83 --- /dev/null +++ b/internal/ui/app/timeout_test.go @@ -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) + } +}