Compare commits
7
Commits
f9a7ed73be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96395cb6a2 | ||
|
|
39cbac9aab | ||
|
|
637bf6a253
|
||
|
|
29b0c76f40
|
||
|
|
df429002c9 | ||
|
|
0ff5204d4d | ||
|
|
6842c72662 |
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.2.1 – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
|
||||
|
||||
## v0.2.0 – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,7 +23,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||
on a secret.
|
||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
|
||||
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env` and Spring Boot config files in your git repos.
|
||||
|
||||
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
||||
|
||||
@@ -87,7 +87,7 @@ pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
|
||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [SOPS, .env and Spring files](docs/repos.md): which repos and files, decrypting
|
||||
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||
opening links, also from the browser and chat
|
||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||
|
||||
@@ -69,11 +69,7 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
|
||||
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
launch := []string{exe}
|
||||
if configPath != defaultConfigPath() {
|
||||
launch = append(launch, "--config", configPath)
|
||||
}
|
||||
launch = append(launch, "launch")
|
||||
launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
|
||||
if term != "" {
|
||||
launch = append(launch, "--terminal", term)
|
||||
}
|
||||
@@ -188,8 +184,14 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
|
||||
// innerCommand runs lazysecrets on link, with the environment it needs.
|
||||
func innerCommand(configPath, exe, link string) []string {
|
||||
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
|
||||
if configPath != defaultConfigPath() {
|
||||
inner = append(inner, "--config", configPath)
|
||||
}
|
||||
inner = append(inner, configArgs(configPath)...)
|
||||
return append(inner, "open", link)
|
||||
}
|
||||
|
||||
// configArgs passes the config files on, unless they're the default.
|
||||
func configArgs(configPath string) []string {
|
||||
if configPath == defaultConfigPath() {
|
||||
return nil
|
||||
}
|
||||
return []string{"--config", configPath}
|
||||
}
|
||||
+47
-64
@@ -171,26 +171,22 @@ Flags:
|
||||
return nil
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
sub := "" // the subcommand; "" browses
|
||||
var subArgs []string
|
||||
var open *ref.Ref
|
||||
var getRef ref.Ref
|
||||
var openAction string
|
||||
var shareArgs, listArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) > 0 && args[0] == "share":
|
||||
shareArgs = args[1:]
|
||||
if len(shareArgs) == 0 {
|
||||
shareArgs = []string{} // still sharing: says how
|
||||
}
|
||||
case len(args) > 0 && args[0] == "list":
|
||||
listArgs = append([]string{}, args[1:]...) // not nil: listing
|
||||
case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
|
||||
sub, subArgs = args[0], args[1:]
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
sub = "keybindings"
|
||||
case len(args) > 0 && args[0] == "get":
|
||||
r, _, err := refArg("get", args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
getRef = &r
|
||||
sub, getRef = "get", r
|
||||
case len(args) > 0 && args[0] == "handler":
|
||||
return handler(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
@@ -236,7 +232,7 @@ Flags:
|
||||
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
||||
return err
|
||||
}
|
||||
if printKeys {
|
||||
if sub == "keybindings" {
|
||||
fmt.Print(keymap.YAML())
|
||||
return nil
|
||||
}
|
||||
@@ -264,25 +260,13 @@ Flags:
|
||||
}
|
||||
// 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
|
||||
if cfg.UI.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.UI.ExpiryWarningDays
|
||||
}
|
||||
revealTimeout := 60 * time.Second
|
||||
if cfg.Values.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.Values.RevealTimeout
|
||||
}
|
||||
loadDelay := 150 * time.Millisecond
|
||||
if cfg.UI.LoadDelay != nil {
|
||||
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["clipboard-clear"] {
|
||||
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
|
||||
}
|
||||
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
|
||||
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
|
||||
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
|
||||
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
|
||||
if !set["icons"] {
|
||||
switch {
|
||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||
@@ -292,7 +276,17 @@ Flags:
|
||||
}
|
||||
}
|
||||
|
||||
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
|
||||
recentIcon := valueOr(cfg.UI.RecentIcon, false)
|
||||
|
||||
startDir := *dir
|
||||
if startDir == "" {
|
||||
startDir, _ = os.Getwd()
|
||||
}
|
||||
// What was loaded last time.
|
||||
cacheFile := ""
|
||||
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
|
||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||
// sample vaults never end up in the real file.
|
||||
@@ -350,17 +344,9 @@ Flags:
|
||||
providers = append(providers, dbx)
|
||||
}
|
||||
if config.On(pc.Repos.Enabled) {
|
||||
start := *dir
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := repo.DefaultRoots
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
repos := repo.New(repo.Settings{
|
||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
|
||||
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Spring: pc.Repos.Spring,
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
@@ -376,19 +362,14 @@ Flags:
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if getRef != nil {
|
||||
switch sub {
|
||||
case "get":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
if listArgs != nil {
|
||||
cachePath := ""
|
||||
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||
cachePath = filepath.Join(d, "cache.json")
|
||||
}
|
||||
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
|
||||
}
|
||||
if shareArgs != nil {
|
||||
return get(ctx, providers, st, getRef)
|
||||
case "list":
|
||||
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
|
||||
case "share":
|
||||
// The files from a repo, the key from whichever provider it's
|
||||
// in: the longest timeout.
|
||||
d := *timeout
|
||||
@@ -397,7 +378,7 @@ Flags:
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||
defer cancel()
|
||||
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
|
||||
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
@@ -431,16 +412,10 @@ Flags:
|
||||
onActive = srv.Touch
|
||||
}
|
||||
|
||||
// What was loaded last time, and where it was left in this folder.
|
||||
var cacheFile, placeDir string
|
||||
// Where it was left in this folder.
|
||||
placeDir := ""
|
||||
if !*demo {
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
placeDir = *dir
|
||||
if placeDir == "" {
|
||||
placeDir, _ = os.Getwd()
|
||||
}
|
||||
placeDir = startDir
|
||||
if abs, err := filepath.Abs(placeDir); err == nil {
|
||||
placeDir = abs
|
||||
}
|
||||
@@ -457,7 +432,7 @@ Flags:
|
||||
NoHistory: !config.On(cfg.Storage.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
ExpiryWarning: expiryWarning,
|
||||
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(cfg.Updates.Sources),
|
||||
@@ -490,6 +465,14 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
// valueOr is *p, or def when p is nil (not set in the config).
|
||||
func valueOr[T any](p *T, def T) T {
|
||||
if p == nil {
|
||||
return def
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// configFiles are the config files given, for the status pane: those
|
||||
// that exist, or the first if none does (where to create one).
|
||||
func configFiles(list string) []string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,8 +33,12 @@ func updater(servers []string) *app.Updater {
|
||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||
// changelog, then (unless --check) install it.
|
||||
func updateCmd(configPath string, args []string) error {
|
||||
check := len(args) == 1 && args[0] == "--check"
|
||||
if len(args) > 0 && !check {
|
||||
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||
check := fs.Bool("check", false, "just say if there's a newer release")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return errors.New("usage: lazysecrets update [--check]")
|
||||
}
|
||||
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||
@@ -49,14 +54,14 @@ func updateCmd(configPath string, args []string) error {
|
||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||
}
|
||||
if !update.Newer(current, r.Tag) {
|
||||
if !update.Newer(current, "v999.0.0") {
|
||||
if strings.HasPrefix(current, "dev") {
|
||||
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
|
||||
if check {
|
||||
if *check {
|
||||
fmt.Println("lazysecrets update installs it.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// version is set by release builds:
|
||||
@@ -31,7 +30,7 @@ func versionString() string {
|
||||
// go install …@<commit>.
|
||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||
dirty := ""
|
||||
if strings.HasSuffix(v, "+dirty") {
|
||||
if m[2] != "" {
|
||||
dirty = "-dirty"
|
||||
}
|
||||
return "dev-" + m[1][:7] + dirty
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 113 KiB |
@@ -74,6 +74,7 @@ theme:
|
||||
azureColor: ["#82aaff"]
|
||||
databricksColor: ["#ff757f"]
|
||||
repoColor: ["#c099ff"]
|
||||
springColor: ["#c3e88d"] # Spring Boot config files
|
||||
```
|
||||
|
||||
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ match case exactly.
|
||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||
| `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) |
|
||||
| `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, `.env` or Spring 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)) |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
|
||||
+41
-4
@@ -1,9 +1,9 @@
|
||||
# SOPS and .env files in git repos
|
||||
# SOPS, .env and Spring files in git repos
|
||||
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||
`.env`, INI), plain env files and [Spring Boot config](#spring-boot-config)
|
||||
as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||
escaped, `app\.name`, so it doesn't read as nesting.
|
||||
|
||||
## Which repos
|
||||
@@ -46,7 +46,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
|
||||
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
||||
4. the whole repo.
|
||||
|
||||
`include`/`exclude` apply to env files too.
|
||||
`include`/`exclude` apply to env and Spring files too.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -65,6 +65,43 @@ sharing:
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||
```
|
||||
|
||||
## Spring Boot config
|
||||
|
||||
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
|
||||
like, tracked by git (not those in a Helm chart's `templates/`). Most of
|
||||
their keys aren't secret, so only those that look like one are listed:
|
||||
|
||||
- **keys** named like a secret: ending in `password`, `pwd`, `secret`,
|
||||
`token`, `key`, `credentials`, `salt`, `connection-string` and the
|
||||
like (`spring.datasource.password`, `client-secret`, `checkoutKey`),
|
||||
- **values** that look like one: Jasypt's `ENC(...)`, a URL with a
|
||||
password (`jdbc:postgresql://app:pw@db/app`), or a connection string
|
||||
with `AccountKey=`, `SharedAccessKey=` or `Password=`.
|
||||
|
||||
A file's details count the keys left out, and `e` opens it whole. A
|
||||
`${DB_PASSWORD}` value is marked as a reference: the secret is set
|
||||
elsewhere. A tracked file is marked in red only when a listed key has its
|
||||
value in plain text.
|
||||
|
||||
Each list of regexps replaces its default; `keys: [".*"]` lists every key:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
repos:
|
||||
spring:
|
||||
keys: ["(?i)(password|secret|token|key)$", "bearer$"] # matched against the key's name
|
||||
values: ["^ENC\\(.+\\)$"] # and against its value
|
||||
```
|
||||
|
||||
A repo's `.lazysecrets.yaml` can set `spring` too, for everyone, and wins
|
||||
over the config, one list at a time.
|
||||
|
||||
Keys are named as Spring names them, `spring.datasource.password`; in a
|
||||
file with several documents (`---`, or `#---` in `.properties`), a later
|
||||
document's keys get its profile, `spring.datasource.password (prod)`.
|
||||
Values Maven fills in at build time (`@project.version@`) are read as
|
||||
they are.
|
||||
|
||||
## Decrypting
|
||||
|
||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
@@ -153,6 +154,8 @@ type Repos struct {
|
||||
// a repo's .sops.yaml creation_rules say where to look.
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
// Spring says which keys of Spring Boot config files are secrets.
|
||||
Spring repo.SpringPatterns `yaml:"spring"`
|
||||
}
|
||||
|
||||
type Azure struct {
|
||||
@@ -292,6 +295,9 @@ func (c Config) validate(path string) error {
|
||||
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
|
||||
}
|
||||
}
|
||||
if err := c.Providers.Repos.Spring.Check(); err != nil {
|
||||
return fmt.Errorf("%s: providers.repos.%w", path, err)
|
||||
}
|
||||
for i, v := range c.Providers.Azure.Vaults {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"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",
|
||||
"regexp": "providers:\n repos:\n spring:\n keys: [\"(pass\"]\n",
|
||||
} {
|
||||
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
|
||||
@@ -35,15 +35,18 @@ func Dir(stateDir string) string {
|
||||
type Status struct {
|
||||
PID int `json:"pid"`
|
||||
LastActive time.Time `json:"lastActive"` // last key press or focus
|
||||
// Reach is how readily it can be shown: 2 in view (outside tmux, or
|
||||
// in a tmux session a client shows), 1 in a tmux session that a tmux
|
||||
// client can be switched to, 0 not at all.
|
||||
Reach int `json:"reach"`
|
||||
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
|
||||
// App is the terminal app it would show in (macOS), preferring the
|
||||
// app the link was clicked in.
|
||||
App string `json:"app,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
unreachable = 0
|
||||
switchable = 1 // in a tmux session that a tmux client can be switched to
|
||||
inView = 2 // outside tmux, or in a tmux session a client shows
|
||||
)
|
||||
|
||||
type request struct {
|
||||
Op string `json:"op"` // "status" or "open"
|
||||
Link string `json:"link,omitempty"`
|
||||
@@ -187,7 +190,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, best.Reach > 0
|
||||
return best, best.Reach > unreachable
|
||||
}
|
||||
|
||||
// Open hands link, clicked in front, to f.
|
||||
|
||||
+24
-25
@@ -1,6 +1,7 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
@@ -25,9 +26,10 @@ func tmux(args ...string) (string, error) {
|
||||
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
|
||||
|
||||
type client struct {
|
||||
name, session, activity string // activity: Unix seconds
|
||||
pid int
|
||||
app string
|
||||
name, session string
|
||||
activity int // Unix seconds
|
||||
pid int
|
||||
app string
|
||||
}
|
||||
|
||||
// clients lists the tmux server's clients, the one last typed in first.
|
||||
@@ -42,30 +44,31 @@ func clients() []client {
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(f[1])
|
||||
if err != nil {
|
||||
activity, err1 := strconv.Atoi(f[0])
|
||||
pid, err2 := strconv.Atoi(f[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
|
||||
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
|
||||
}
|
||||
// Activity has the same width for centuries: strings compare like
|
||||
// numbers.
|
||||
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
|
||||
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
|
||||
return cs
|
||||
}
|
||||
|
||||
// place is where this instance would show: the tmux client (zero outside
|
||||
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
|
||||
// a client on its session), 1 after switching a client, 0 nowhere.
|
||||
// tmux), the app, and its reach.
|
||||
func place(front string) (client, string, int) {
|
||||
if !inTmux() {
|
||||
return client{}, linkhandler.HostApp(os.Getpid()), 2
|
||||
return client{}, linkhandler.HostApp(os.Getpid()), inView
|
||||
}
|
||||
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
|
||||
if err != nil {
|
||||
return client{}, "", 0
|
||||
return client{}, "", unreachable
|
||||
}
|
||||
cs := clients()
|
||||
if len(cs) == 0 {
|
||||
return client{}, "", unreachable
|
||||
}
|
||||
// Best first: in the front app on its session, in the front app,
|
||||
// on its session, any.
|
||||
rank := func(c client) int {
|
||||
@@ -78,20 +81,16 @@ func place(front string) (client, string, int) {
|
||||
}
|
||||
return r
|
||||
}
|
||||
var best client
|
||||
found := false
|
||||
for _, c := range cs {
|
||||
if !found || rank(c) > rank(best) {
|
||||
best, found = c, true
|
||||
best := cs[0]
|
||||
for _, c := range cs[1:] {
|
||||
if rank(c) > rank(best) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return client{}, "", 0
|
||||
}
|
||||
if best.session == session {
|
||||
return best, best.app, 2
|
||||
return best, best.app, inView
|
||||
}
|
||||
return best, best.app, 1
|
||||
return best, best.app, switchable
|
||||
}
|
||||
|
||||
// Raise brings this instance into view for a link clicked in front: in
|
||||
@@ -99,14 +98,14 @@ func place(front string) (client, string, int) {
|
||||
// then its app to the front, showing a hidden dropdown terminal.
|
||||
func Raise(front string) {
|
||||
c, app, reach := place(front)
|
||||
if reach == 0 {
|
||||
if reach == unreachable {
|
||||
return
|
||||
}
|
||||
if inTmux() {
|
||||
pane := os.Getenv("TMUX_PANE")
|
||||
_, _ = tmux("select-window", "-t", pane)
|
||||
_, _ = tmux("select-pane", "-t", pane)
|
||||
if reach == 1 {
|
||||
if reach == switchable {
|
||||
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||
@@ -99,13 +100,7 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
|
||||
// Sources lists the subscriptions the signed-in identity can see, skipping
|
||||
// disabled and deleted ones.
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
var out []provider.Source
|
||||
pager := p.subs.NewListPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
|
||||
for _, s := range page.Value {
|
||||
switch deref(s.State) {
|
||||
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
||||
@@ -118,7 +113,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
p.mu.Lock()
|
||||
p.tenants[id] = tenant
|
||||
p.mu.Unlock()
|
||||
out = append(out, provider.Source{
|
||||
srcs = append(srcs, provider.Source{
|
||||
Kind: provider.KindAzure,
|
||||
ID: id,
|
||||
Name: deref(s.DisplayName),
|
||||
@@ -126,6 +121,10 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
||||
})
|
||||
}
|
||||
return srcs
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
if len(p.opts.Vaults) > 0 {
|
||||
@@ -230,22 +229,29 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Container
|
||||
pager := client.NewListBySubscriptionPager(nil)
|
||||
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
vaults = append(vaults, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
return vaults
|
||||
})
|
||||
}
|
||||
|
||||
// all lists every page, items picking out what to keep of each.
|
||||
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
|
||||
var out []T
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
out = append(out, items(page)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -288,51 +294,40 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
||||
var out []provider.Object
|
||||
switch t {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
tenant := p.tenant(c.Source.ID)
|
||||
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
|
||||
for _, s := range page.Value {
|
||||
// Certificate-backing secrets appear under Certificates.
|
||||
if s.ID == nil || deref(s.Managed) {
|
||||
continue
|
||||
if s.ID != nil && !deref(s.Managed) {
|
||||
objs = append(objs, secretObject(c, s, tenant))
|
||||
}
|
||||
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
|
||||
for _, k := range page.Value {
|
||||
if k.KID == nil || deref(k.Managed) {
|
||||
continue
|
||||
if k.KID != nil && !deref(k.Managed) {
|
||||
objs = append(objs, keyObject(c, k))
|
||||
}
|
||||
out = append(out, keyObject(c, k))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID == nil {
|
||||
continue
|
||||
if cp.ID != nil {
|
||||
objs = append(objs, certObject(c, cp))
|
||||
}
|
||||
out = append(out, certObject(c, cp))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
@@ -353,7 +348,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
||||
if resp.Key != nil {
|
||||
if resp.Key.KID != nil {
|
||||
o.Version = resp.Key.KID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
|
||||
}
|
||||
o.Attrs = keyAttrs(resp.Key)
|
||||
}
|
||||
@@ -364,7 +359,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
||||
}
|
||||
if resp.ID != nil {
|
||||
o.Version = resp.ID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
|
||||
}
|
||||
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
||||
o.ContentType = deref(pol.SecretProperties.ContentType)
|
||||
@@ -381,56 +376,46 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenant := p.tenant(o.Container.Source.ID)
|
||||
var out []provider.Object
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
|
||||
for _, s := range page.Value {
|
||||
if s.ID != nil {
|
||||
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
|
||||
objs = append(objs, secretObject(o.Container, s, tenant))
|
||||
}
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
|
||||
for _, k := range page.Value {
|
||||
if k.KID != nil {
|
||||
out = append(out, keyObject(o.Container, k))
|
||||
objs = append(objs, keyObject(o.Container, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID != nil {
|
||||
out = append(out, certObject(o.Container, cp))
|
||||
objs = append(objs, certObject(o.Container, cp))
|
||||
}
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
tenant := p.tenant(o.Container.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Reference = versionedReference(out[i])
|
||||
if o.Type != provider.TypeSecret { // secretObject's is the version's already
|
||||
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
|
||||
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
|
||||
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||
@@ -452,56 +437,49 @@ func versionedReference(o provider.Object) string {
|
||||
// Value returns a secret's value, a key's public key or a certificate's
|
||||
// public certificate, as of o.Version (latest if empty).
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
v, _, err := p.VersionedValue(ctx, o)
|
||||
return v, err
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
pemKey, err := publicKeyPEM(resp.Key)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
return provider.NewSecretValue([]byte(pemKey)), nil
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
if len(resp.CER) == 0 {
|
||||
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
}
|
||||
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
|
||||
}
|
||||
return provider.SecretValue{}, provider.ErrUnsupported
|
||||
v, _, err := p.VersionedValue(ctx, o)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// VersionedValue is Value, with the version read.
|
||||
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
|
||||
if o.Type != provider.TypeSecret {
|
||||
v, err := p.Value(ctx, o)
|
||||
return v, o.Version, err
|
||||
}
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
}
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
var text string
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
}
|
||||
version := o.Version
|
||||
if resp.ID != nil {
|
||||
version = resp.ID.Version()
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
}
|
||||
if text, err = publicKeyPEM(resp.Key); err != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
}
|
||||
if len(resp.CER) == 0 {
|
||||
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
}
|
||||
text = certPEM(resp.CER)
|
||||
default:
|
||||
return provider.SecretValue{}, "", provider.ErrUnsupported
|
||||
}
|
||||
version := o.Version
|
||||
if resp.ID != nil {
|
||||
version = resp.ID.Version()
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
||||
return provider.NewSecretValue([]byte(text)), o.Version, nil
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
|
||||
|
||||
// portalAssetURL links to a Key Vault object's page: a version's with
|
||||
// one in objectID; a secret's own (its versions) without.
|
||||
func portalAssetURL(tenant, kind, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
||||
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
|
||||
}
|
||||
|
||||
func tenantPrefix(tenant string) string {
|
||||
@@ -73,19 +73,13 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch t {
|
||||
case provider.TypeKey:
|
||||
return base + "/keys"
|
||||
case provider.TypeCertificate:
|
||||
return base + "/certificates"
|
||||
}
|
||||
return base + "/secrets"
|
||||
return base + "/" + t.Collection()
|
||||
}
|
||||
|
||||
// objectURL is the data-plane URL of an object, e.g.
|
||||
// https://kv.vault.azure.net/secrets/name.
|
||||
func objectURL(c provider.Container, collection, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
|
||||
}
|
||||
|
||||
// secretObject is a secret, or a version of one, from a listing. Its URL
|
||||
@@ -93,7 +87,7 @@ func objectURL(c provider.Container, collection, name string) string {
|
||||
// a version, that version's.
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "secrets", name)
|
||||
url := objectURL(c, provider.TypeSecret, name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
@@ -103,7 +97,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
||||
ContentType: deref(p.ContentType),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
||||
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
@@ -113,7 +107,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
||||
|
||||
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
name := p.KID.Name()
|
||||
url := objectURL(c, "keys", name)
|
||||
url := objectURL(c, provider.TypeKey, name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeKey,
|
||||
@@ -132,7 +126,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
|
||||
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "certificates", name)
|
||||
url := objectURL(c, provider.TypeCertificate, name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeCertificate,
|
||||
|
||||
@@ -71,13 +71,10 @@ func graphInfo(m map[string]any) vaultInfo {
|
||||
|
||||
// armInfo reads a vault's settings from the management API.
|
||||
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
||||
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
|
||||
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
|
||||
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
||||
info.group = id.ResourceGroupName
|
||||
}
|
||||
for k, t := range v.Tags {
|
||||
info.tags[k] = deref(t)
|
||||
}
|
||||
pr := v.Properties
|
||||
if pr == nil {
|
||||
return info
|
||||
@@ -123,7 +120,11 @@ func (v vaultInfo) attrs() []provider.Attr {
|
||||
} else {
|
||||
add("soft delete", "off")
|
||||
}
|
||||
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
|
||||
if on(v.purge, false) {
|
||||
add("purge prot.", "on")
|
||||
} else {
|
||||
add("purge prot.", "off")
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(v.publicAccess, "disabled"):
|
||||
add("network", "private endpoints only")
|
||||
@@ -132,8 +133,11 @@ func (v vaultInfo) attrs() []provider.Attr {
|
||||
default:
|
||||
add("network", "all networks")
|
||||
}
|
||||
if v.endpoints > 0 {
|
||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
||||
switch {
|
||||
case v.endpoints == 1:
|
||||
add("private", "1 endpoint")
|
||||
case v.endpoints > 1:
|
||||
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
|
||||
}
|
||||
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
|
||||
label := ""
|
||||
|
||||
@@ -203,17 +203,20 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
// fallbackVault is the backing vault of an AKV-backed scope, to read it
|
||||
// from when Databricks fails, or "" when there's none to fall back to. It
|
||||
// falls back to the scope's Detail, so a scope remembered from an earlier
|
||||
// session (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
|
||||
if p.keyVault == nil || ctx.Err() != nil {
|
||||
return ""
|
||||
}
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
|
||||
url = detail
|
||||
}
|
||||
}
|
||||
return url
|
||||
@@ -264,8 +267,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
url := p.fallbackVault(ctx, c)
|
||||
if url == "" {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
@@ -302,8 +305,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
url := p.fallbackVault(ctx, o.Container)
|
||||
if url == "" {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
|
||||
@@ -225,9 +225,7 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
|
||||
o.Type = t
|
||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||
o.URL = base + map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
||||
}[t]
|
||||
o.URL = base + "/" + t.Collection()
|
||||
}
|
||||
k := listKey{c.ID, t}
|
||||
p.objects[k] = append(p.objects[k], o)
|
||||
@@ -256,12 +254,13 @@ const (
|
||||
|
||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
func secretRef(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
// Azure returns a fake Azure provider with two subscriptions.
|
||||
func Azure(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
objectURL := func(vault, kind, name string) string {
|
||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||
}
|
||||
@@ -373,9 +372,6 @@ func Azure(delay time.Duration) *Provider {
|
||||
// its own, like an admin account next to a personal one.
|
||||
func AzureOps(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
|
||||
@@ -84,6 +84,20 @@ const (
|
||||
TypeCertificate ObjectType = "Certificate"
|
||||
)
|
||||
|
||||
// Collection is the type's path segment in Key Vault URLs and links, e.g.
|
||||
// "secrets".
|
||||
func (t ObjectType) Collection() string {
|
||||
switch t {
|
||||
case TypeSecret:
|
||||
return "secrets"
|
||||
case TypeKey:
|
||||
return "keys"
|
||||
case TypeCertificate:
|
||||
return "certificates"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||
type Container struct {
|
||||
Source Source
|
||||
|
||||
@@ -22,7 +22,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
if isSops {
|
||||
// A plain file's values are at risk in git; a Spring file's only when
|
||||
// one of its secrets is in plain text.
|
||||
plain := !isSops
|
||||
if !isSops && IsSpring(c.ID) {
|
||||
out, plain = p.springDetails(c)
|
||||
} else if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
@@ -43,7 +48,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
return strings.TrimSpace(string(b)), err == nil
|
||||
}
|
||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||
case tracked && !isSops:
|
||||
case tracked && plain:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case tracked:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||
@@ -51,7 +56,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Package repo browses the encrypted secret files in git repositories:
|
||||
// a repo is a source, its secret files the containers (vault level), and
|
||||
// their keys the secrets. Secret files are SOPS files and plain env files.
|
||||
// their keys the secrets. Secret files are SOPS files, plain env files and
|
||||
// Spring Boot config (spring.go).
|
||||
//
|
||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||
// metadata are read directly. A value is decrypted when it's revealed or
|
||||
@@ -49,6 +50,9 @@ type Settings struct {
|
||||
// Include and Exclude are globs relative to a repo's root (** matches
|
||||
// any depth), for repos without their own .lazysecrets.yaml.
|
||||
Include, Exclude []string
|
||||
// Spring says which keys of Spring Boot config files are secrets, for
|
||||
// repos whose .lazysecrets.yaml doesn't.
|
||||
Spring SpringPatterns
|
||||
// Sops is the sops binary; "sops" (on PATH) when empty.
|
||||
Sops string
|
||||
}
|
||||
@@ -320,6 +324,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||
case IsEnv(f):
|
||||
c.Detail = provider.DetailPlainEnv
|
||||
case IsSpring(f):
|
||||
c.Detail = "spring · " + springFormat(f)
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -356,30 +362,26 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, isSops := readMeta(c.ID)
|
||||
meta, isSops := parseMeta(c.ID, b)
|
||||
if !isSops && IsSpring(c.ID) {
|
||||
return p.springObjects(c, b)
|
||||
}
|
||||
keys, err := parseKeys(c.ID, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||
}
|
||||
if !isSops { // a plain env file
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, provider.Object{
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||
if isSops {
|
||||
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
|
||||
}
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
o := provider.Object{
|
||||
Container: c, Type: provider.TypeSecret,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||
}
|
||||
if !k.encrypted {
|
||||
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
if isSops && !k.encrypted {
|
||||
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
@@ -398,15 +400,20 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||
file = o.Container.ID
|
||||
if file == "" {
|
||||
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
||||
}
|
||||
extract = extractFor(file, o.Name)
|
||||
file = filePath(o.Container)
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
if IsSpring(file) {
|
||||
return springValue(file, o.Name)
|
||||
}
|
||||
return plainValue(file, o.Name)
|
||||
}
|
||||
if !ok {
|
||||
var err error
|
||||
if extract, err = extractOf(file, o.Name); err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
||||
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -429,12 +436,18 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
|
||||
return os.ReadFile(f.Path)
|
||||
}
|
||||
|
||||
// filePath is the file c is. A container made from a link has no ID: the
|
||||
// file is its path in the repo.
|
||||
func filePath(c provider.Container) string {
|
||||
if c.ID != "" {
|
||||
return c.ID
|
||||
}
|
||||
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||
}
|
||||
|
||||
// File is the file c is, and the line o's key is on.
|
||||
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||
path := c.ID
|
||||
if path == "" { // made from a link
|
||||
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||
}
|
||||
path := filePath(c)
|
||||
f := provider.File{Path: path}
|
||||
if _, isSops := readMeta(path); isSops {
|
||||
f.Sops = p.s.Sops
|
||||
@@ -442,6 +455,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
|
||||
if o == nil {
|
||||
return f, nil
|
||||
}
|
||||
if f.Sops == "" && IsSpring(path) {
|
||||
if k, err := springKeyNamed(path, o.Name); err == nil {
|
||||
f.Line = k.line
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f, err
|
||||
@@ -593,8 +612,9 @@ func normalizeRemote(remote string) (string, string) {
|
||||
|
||||
// lazysecretsFile is a repo's own .lazysecrets.yaml.
|
||||
type lazysecretsFile struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Spring SpringPatterns `yaml:"spring"`
|
||||
Sharing struct {
|
||||
// Key is the link to the secret that encrypts the repo's
|
||||
// shared env files (package share).
|
||||
@@ -604,33 +624,38 @@ type lazysecretsFile struct {
|
||||
|
||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||
func ShareKey(root string) (string, error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
return f.Sharing.Key, err
|
||||
}
|
||||
|
||||
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
|
||||
// empty.
|
||||
func readLazysecretsFile(root string) (lazysecretsFile, error) {
|
||||
var f lazysecretsFile
|
||||
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
return f, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
return f, err
|
||||
}
|
||||
var f lazysecretsFile
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
return f.Sharing.Key, nil
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// selector says which files to look at: user (the include/exclude of the
|
||||
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
|
||||
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
|
||||
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
include, exclude := p.s.Include, p.s.Exclude
|
||||
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
|
||||
var f lazysecretsFile
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||
include, exclude = f.Include, f.Exclude
|
||||
}
|
||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||
include, exclude = f.Include, f.Exclude
|
||||
}
|
||||
inc, err := globs(include)
|
||||
if err != nil {
|
||||
@@ -902,12 +927,21 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
return sopsMeta{}, false // not a format sops writes: skip reading
|
||||
}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil || !bytes.Contains(b, []byte("sops")) {
|
||||
if err != nil {
|
||||
return sopsMeta{}, false
|
||||
}
|
||||
return parseMeta(file, b)
|
||||
}
|
||||
|
||||
// parseMeta is readMeta, of the file's contents b.
|
||||
func parseMeta(file string, b []byte) (sopsMeta, bool) {
|
||||
format := formatOf(file)
|
||||
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
|
||||
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
|
||||
}
|
||||
var m sopsMeta
|
||||
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
|
||||
switch formatOf(file) {
|
||||
switch format {
|
||||
case "yaml", "json":
|
||||
var doc struct {
|
||||
Sops map[string]any `yaml:"sops"`
|
||||
@@ -1035,7 +1069,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
||||
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
@@ -1100,48 +1135,23 @@ func escape(k string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
||||
}
|
||||
|
||||
// extractFor rebuilds the extract path from a key name, for objects made
|
||||
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
|
||||
// its names are the keys as they are.
|
||||
func extractFor(file, name string) string {
|
||||
if formatOf(file) == "dotenv" {
|
||||
return quote(name)
|
||||
// extractOf is the sops --extract path of the key named name, for objects
|
||||
// made from a link.
|
||||
func extractOf(file, name string) (string, error) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b, part strings.Builder
|
||||
flush := func() {
|
||||
if part.Len() > 0 {
|
||||
b.WriteString(quote(part.String()))
|
||||
part.Reset()
|
||||
keys, err := parseKeys(file, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.name == name {
|
||||
return k.extract, nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
switch c := name[i]; c {
|
||||
case '\\':
|
||||
if i+1 < len(name) {
|
||||
i++
|
||||
}
|
||||
part.WriteByte(name[i])
|
||||
case '.':
|
||||
flush()
|
||||
case '[':
|
||||
end := strings.IndexByte(name[i:], ']')
|
||||
if end < 0 || !digits(name[i+1:i+end]) {
|
||||
part.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
b.WriteString(name[i : i+end+1])
|
||||
i += end
|
||||
default:
|
||||
part.WriteByte(c)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func digits(s string) bool {
|
||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
||||
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||
}
|
||||
|
||||
func absPath(p string) string {
|
||||
|
||||
@@ -237,6 +237,25 @@ func TestDecrypt(t *testing.T) {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
|
||||
// A dotenv file's export lines: sops keeps export in the key.
|
||||
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
|
||||
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envFile := filepath.Join(root, "prod.env")
|
||||
os.WriteFile(envFile, enc, 0o600)
|
||||
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
|
||||
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
|
||||
if len(envKeys) != 1 {
|
||||
t.Fatalf("env keys: %v", envKeys)
|
||||
}
|
||||
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
|
||||
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
|
||||
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the key: sops' own error.
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||
@@ -554,15 +573,22 @@ func TestKeyNamesEscaped(t *testing.T) {
|
||||
var got []string
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+k.extract)
|
||||
if e := extractFor("s.yaml", k.name); e != k.extract {
|
||||
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
|
||||
}
|
||||
}
|
||||
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||
}
|
||||
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
|
||||
t.Errorf("dotenv: %s", e)
|
||||
|
||||
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = nil
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+k.extract)
|
||||
}
|
||||
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Spring Boot config files (application*.properties/.yml, bootstrap*) hold
|
||||
// all of an app's config, most of it not secret: of their keys, only those
|
||||
// named like a secret or with a value that looks like one are listed. The
|
||||
// patterns are configurable, and the file's details count the rest, so a
|
||||
// secret the patterns miss isn't hidden without a trace.
|
||||
|
||||
// DefaultSpringKeys and DefaultSpringValues are the patterns used when
|
||||
// neither the config nor the repo's .lazysecrets.yaml sets them. The key
|
||||
// pattern is anchored at the end of the name: spring.datasource.password,
|
||||
// client-secret and checkoutKey, not the oauth2.*, keycloak.* and
|
||||
// token-uri keys every Spring app has. passord is Norwegian. The file's
|
||||
// details count the keys left out.
|
||||
var (
|
||||
DefaultSpringKeys = []string{
|
||||
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
|
||||
}
|
||||
DefaultSpringValues = []string{
|
||||
`^ENC\(.+\)$`, // Jasypt
|
||||
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
|
||||
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
|
||||
}
|
||||
)
|
||||
|
||||
// SpringPatterns are regexps matched against a Spring config key's name
|
||||
// and its value: a key is listed when either matches. A list that isn't
|
||||
// set is DefaultSpringKeys or DefaultSpringValues.
|
||||
type SpringPatterns struct {
|
||||
Keys *[]string `yaml:"keys"`
|
||||
Values *[]string `yaml:"values"`
|
||||
}
|
||||
|
||||
// Check says which pattern doesn't compile.
|
||||
func (p SpringPatterns) Check() error {
|
||||
_, err := compileSpring(p)
|
||||
return err
|
||||
}
|
||||
|
||||
type springFilter struct{ keys, values []*regexp.Regexp }
|
||||
|
||||
func compileSpring(p SpringPatterns) (springFilter, error) {
|
||||
var f springFilter
|
||||
var errs []error
|
||||
add := func(field string, patterns *[]string, defaults []string, out *[]*regexp.Regexp) {
|
||||
list := defaults
|
||||
if patterns != nil {
|
||||
list = *patterns
|
||||
}
|
||||
for i, s := range list {
|
||||
re, err := regexp.Compile(s)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("spring.%s[%d]: %w", field, i, err))
|
||||
continue
|
||||
}
|
||||
*out = append(*out, re)
|
||||
}
|
||||
}
|
||||
add("keys", p.Keys, DefaultSpringKeys, &f.keys)
|
||||
add("values", p.Values, DefaultSpringValues, &f.values)
|
||||
return f, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (f springFilter) secret(k springKey) bool {
|
||||
return anyMatch(f.keys, k.key) || anyMatch(f.values, k.value)
|
||||
}
|
||||
|
||||
// springFilterFor is the repo's patterns: its .lazysecrets.yaml's, else the
|
||||
// config's, each list on its own.
|
||||
func (p *Provider) springFilterFor(root string) (springFilter, error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
if err != nil {
|
||||
return springFilter{}, err
|
||||
}
|
||||
patterns := p.s.Spring
|
||||
if f.Spring.Keys != nil {
|
||||
patterns.Keys = f.Spring.Keys
|
||||
}
|
||||
if f.Spring.Values != nil {
|
||||
patterns.Values = f.Spring.Values
|
||||
}
|
||||
filter, err := compileSpring(patterns)
|
||||
if err != nil && (f.Spring.Keys != nil || f.Spring.Values != nil) {
|
||||
err = fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
return filter, err
|
||||
}
|
||||
|
||||
// springName is application.yml, application-prod.properties, bootstrap.yaml
|
||||
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
|
||||
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
|
||||
|
||||
// IsSpring reports whether f is named like a Spring Boot config file, and
|
||||
// isn't a Helm chart's template of one.
|
||||
func IsSpring(f string) bool {
|
||||
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
|
||||
}
|
||||
|
||||
func springFormat(f string) string {
|
||||
if strings.ToLower(filepath.Ext(f)) == ".properties" {
|
||||
return "properties"
|
||||
}
|
||||
return "yaml"
|
||||
}
|
||||
|
||||
type springKey struct {
|
||||
key string // spring.datasource.password
|
||||
name string // key, with (profile) after it in a later document
|
||||
value string
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// reference reports whether the value is only a placeholder, ${DB_PASSWORD}:
|
||||
// the secret is elsewhere. One with a default, ${DB_PASSWORD:hunter2}, has
|
||||
// the default in the file.
|
||||
func (k springKey) reference() bool {
|
||||
return strings.HasPrefix(k.value, "${") && strings.HasSuffix(k.value, "}") &&
|
||||
!strings.ContainsAny(k.value[2:len(k.value)-1], ":${}")
|
||||
}
|
||||
|
||||
func (k springKey) jasypt() bool {
|
||||
return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")")
|
||||
}
|
||||
|
||||
// plain reports whether the file holds the value itself, readable.
|
||||
func (k springKey) plain() bool {
|
||||
return k.value != "" && !k.reference() && !k.jasypt()
|
||||
}
|
||||
|
||||
// readSpring lists a Spring config file's keys, every one, with values.
|
||||
func readSpring(file string) ([]springKey, error) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseSpring(file, b)
|
||||
}
|
||||
|
||||
func parseSpring(file string, b []byte) ([]springKey, error) {
|
||||
if springFormat(file) == "properties" {
|
||||
return parseProperties(b), nil
|
||||
}
|
||||
return parseSpringYAML(b)
|
||||
}
|
||||
|
||||
// springDocs names the keys of a multi-document file's later documents
|
||||
// after the profile they're for, so each name is one key:
|
||||
// spring.datasource.password (prod).
|
||||
func springDocs(docs [][]springKey) []springKey {
|
||||
var out []springKey
|
||||
for i, doc := range docs {
|
||||
suffix := ""
|
||||
if i > 0 {
|
||||
suffix = fmt.Sprintf(" (document %d)", i+1)
|
||||
for _, k := range doc {
|
||||
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
|
||||
suffix = " (" + k.value + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range doc {
|
||||
k.name = k.key + suffix
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mavenFiltered is a value Maven fills in at build time, @project.version@:
|
||||
// not YAML until it's quoted.
|
||||
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
|
||||
|
||||
func parseSpringYAML(b []byte) ([]springKey, error) {
|
||||
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
|
||||
sub := mavenFiltered.FindSubmatch(m)
|
||||
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
|
||||
})
|
||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||
var docs [][]springKey
|
||||
for {
|
||||
var doc yaml.Node
|
||||
err := dec.Decode(&doc)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []springKey
|
||||
if len(doc.Content) > 0 {
|
||||
springWalk(doc.Content[0], "", &keys)
|
||||
}
|
||||
docs = append(docs, keys)
|
||||
}
|
||||
return springDocs(docs), nil
|
||||
}
|
||||
|
||||
// springWalk names keys the way Spring does: nested keys joined by dots,
|
||||
// list items by index (servers[0]).
|
||||
func springWalk(n *yaml.Node, name string, out *[]springKey) {
|
||||
switch n.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
child := n.Content[i].Value
|
||||
if name != "" {
|
||||
child = name + "." + child
|
||||
}
|
||||
springWalk(n.Content[i+1], child, out)
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for i, c := range n.Content {
|
||||
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
|
||||
}
|
||||
case yaml.AliasNode:
|
||||
if n.Alias != nil {
|
||||
springWalk(n.Alias, name, out)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
if name != "" {
|
||||
*out = append(*out, springKey{key: name, value: n.Value, line: n.Line})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseProperties reads a Java .properties file: key=value, key: value or
|
||||
// key value, lines continued with a trailing backslash, # and ! comments,
|
||||
// and #--- between documents (Spring Boot 2.4+).
|
||||
func parseProperties(b []byte) []springKey {
|
||||
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
|
||||
var docs [][]springKey
|
||||
var doc []springKey
|
||||
for i := 0; i < len(lines); i++ {
|
||||
start := i + 1
|
||||
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||
if line == "#---" || line == "!---" {
|
||||
docs, doc = append(docs, doc), nil
|
||||
continue
|
||||
}
|
||||
if line == "" || line[0] == '#' || line[0] == '!' {
|
||||
continue
|
||||
}
|
||||
for continued(line) && i+1 < len(lines) {
|
||||
i++
|
||||
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||
}
|
||||
k, v := splitProperty(line)
|
||||
doc = append(doc, springKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
|
||||
}
|
||||
return springDocs(append(docs, doc))
|
||||
}
|
||||
|
||||
// continued reports whether the line ends in an odd number of backslashes.
|
||||
func continued(line string) bool {
|
||||
n := len(line) - len(strings.TrimRight(line, `\`))
|
||||
return n%2 == 1
|
||||
}
|
||||
|
||||
// splitProperty cuts a logical line at its first unescaped =, : or
|
||||
// whitespace; whitespace around the separator isn't part of either side.
|
||||
func splitProperty(line string) (key, value string) {
|
||||
const space = " \t\f"
|
||||
for i := 0; i < len(line); i++ {
|
||||
switch c := line[i]; {
|
||||
case c == '\\':
|
||||
i++
|
||||
case c == '=' || c == ':':
|
||||
return line[:i], strings.TrimLeft(line[i+1:], space)
|
||||
case strings.IndexByte(space, c) >= 0:
|
||||
rest := strings.TrimLeft(line[i:], space)
|
||||
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
|
||||
rest = strings.TrimLeft(rest[1:], space)
|
||||
}
|
||||
return line[:i], rest
|
||||
}
|
||||
}
|
||||
return line, ""
|
||||
}
|
||||
|
||||
func unescapeProperty(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 == len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 'f':
|
||||
b.WriteByte('\f')
|
||||
case 'u':
|
||||
if i+5 <= len(s) {
|
||||
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
|
||||
b.WriteRune(rune(r))
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte('u')
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// springObjects lists the file's secret keys.
|
||||
func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) {
|
||||
keys, err := parseSpring(c.ID, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||
}
|
||||
filter, err := p.springFilterFor(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)}
|
||||
var out []provider.Object
|
||||
for _, k := range keys {
|
||||
if !filter.secret(k) {
|
||||
continue
|
||||
}
|
||||
state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}
|
||||
switch {
|
||||
case k.reference():
|
||||
state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
|
||||
case k.jasypt():
|
||||
state = provider.Attr{Label: "encrypted", Value: "jasypt"}
|
||||
}
|
||||
out = append(out, provider.Object{
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name,
|
||||
Attrs: []provider.Attr{format, state},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func springValue(file, name string) (provider.SecretValue, error) {
|
||||
k, err := springKeyNamed(file, name)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
return provider.NewSecretValue([]byte(k.value)), nil
|
||||
}
|
||||
|
||||
func springKeyNamed(file, name string) (springKey, error) {
|
||||
keys, err := readSpring(file)
|
||||
if err != nil {
|
||||
return springKey{}, err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.name == name {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return springKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||
}
|
||||
|
||||
// springDetails says how many of the file's keys aren't listed, and
|
||||
// whether any listed one is in plain text.
|
||||
func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, plain bool) {
|
||||
keys, err := readSpring(c.ID)
|
||||
if err != nil {
|
||||
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
|
||||
}
|
||||
filter, err := p.springFilterFor(c.Source.ID)
|
||||
if err != nil {
|
||||
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
|
||||
}
|
||||
secrets, jasypt := 0, 0
|
||||
for _, k := range keys {
|
||||
if filter.secret(k) {
|
||||
secrets++
|
||||
plain = plain || k.plain()
|
||||
}
|
||||
if k.jasypt() {
|
||||
jasypt++
|
||||
}
|
||||
}
|
||||
encryption := "none: plain text"
|
||||
if jasypt > 0 {
|
||||
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys))
|
||||
}
|
||||
return []provider.Attr{
|
||||
{Label: "encryption", Value: encryption},
|
||||
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
||||
}, plain
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestIsSpring(t *testing.T) {
|
||||
for f, want := range map[string]bool{
|
||||
"src/main/resources/application.yml": true,
|
||||
"application-prod.properties": true,
|
||||
"config/application-local.yaml": true,
|
||||
"bootstrap.yml": true,
|
||||
"applicationset/dev/applications.yaml": false, // Argo CD's
|
||||
"chart/templates/application.yaml": false, // Helm's
|
||||
"application.yml.example": false,
|
||||
"application.json": false,
|
||||
"src/main/resources/applicationContext.xml": false,
|
||||
} {
|
||||
if IsSpring(f) != want {
|
||||
t.Errorf("%s: %t", f, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func springNames(keys []springKey) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, k := range keys {
|
||||
out[k.name] = k.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestParseProperties(t *testing.T) {
|
||||
got := springNames(parseProperties([]byte(`# a comment
|
||||
! another
|
||||
a=1
|
||||
b : 2
|
||||
c 3
|
||||
d\:e=4
|
||||
long=one, \
|
||||
two
|
||||
unicode=café
|
||||
empty=
|
||||
#---
|
||||
spring.config.activate.on-profile=prod
|
||||
a=prod-1
|
||||
`)))
|
||||
want := map[string]string{
|
||||
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
|
||||
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
|
||||
}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Errorf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpringYAML(t *testing.T) {
|
||||
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
|
||||
artifacts:
|
||||
- @project.artifactId@
|
||||
spring:
|
||||
datasource:
|
||||
password: ${DB_PASSWORD}
|
||||
servers:
|
||||
- name: a
|
||||
secret: x
|
||||
logging.level:
|
||||
com.example: DEBUG
|
||||
---
|
||||
spring:
|
||||
config.activate.on-profile: prod
|
||||
datasource:
|
||||
password: hunter2
|
||||
---
|
||||
other: y
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
|
||||
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
|
||||
"logging.level.com.example": "DEBUG",
|
||||
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
|
||||
"other (document 3)": "y",
|
||||
}
|
||||
if got := springNames(keys); !maps.Equal(got, want) {
|
||||
t.Errorf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpringDefaults(t *testing.T) {
|
||||
f, err := compileSpring(SpringPatterns{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"spring.datasource.password": "x",
|
||||
"spring.security.oauth2.client.registration.a.client-secret": "x",
|
||||
"profapp.payment.checkoutKey": "x",
|
||||
"app.clients.sgig.sgig-passord": "x",
|
||||
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
|
||||
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
|
||||
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
|
||||
"app.name": "ENC(abc==)",
|
||||
} {
|
||||
if !f.secret(springKey{key: key, value: value}) {
|
||||
t.Errorf("%s=%s isn't listed", key, value)
|
||||
}
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
|
||||
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
|
||||
"app.frontend.keycloak.url": "https://keycloak",
|
||||
"server.port": "8080",
|
||||
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
|
||||
} {
|
||||
if f.secret(springKey{key: key, value: value}) {
|
||||
t.Errorf("%s=%s is listed", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpringFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"src/main/resources/application.yml": `server:
|
||||
port: 8080
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://db/app
|
||||
password: ${DB_PASSWORD}
|
||||
jasypt:
|
||||
encryptor:
|
||||
password: ${JASYPT_ENCRYPTOR_PASSWORD}
|
||||
app:
|
||||
api-key: ENC(abc==)
|
||||
`,
|
||||
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
|
||||
"applicationset/applications.yaml": "kind: ApplicationSet\n",
|
||||
})
|
||||
ctx := context.Background()
|
||||
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
|
||||
attrs, err := p.Details(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
out[a.Label] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var files []string
|
||||
for _, c := range cs {
|
||||
files = append(files, c.Name+" · "+c.Detail)
|
||||
}
|
||||
if want := []string{
|
||||
"src/main/resources/application-local.properties · spring · properties",
|
||||
"src/main/resources/application.yml · spring · yaml",
|
||||
}; !slices.Equal(files, want) {
|
||||
t.Fatalf("files %v\nwant %v", files, want)
|
||||
}
|
||||
local, yml := cs[0], cs[1]
|
||||
|
||||
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
||||
var names []string
|
||||
for _, k := range keys {
|
||||
names = append(names, k.Name)
|
||||
}
|
||||
if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) {
|
||||
t.Errorf("keys %v, want %v", names, want)
|
||||
}
|
||||
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" {
|
||||
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||
}
|
||||
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
|
||||
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
|
||||
t.Errorf("file: %+v", f)
|
||||
}
|
||||
|
||||
// References and Jasypt aren't plain text in git; a password is.
|
||||
if d := details(p, yml); d["other keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
|
||||
t.Errorf("yml: %+v", d)
|
||||
}
|
||||
if d := details(p, local); !d["git"].Warn {
|
||||
t.Errorf("local: %+v", d)
|
||||
}
|
||||
|
||||
// The config's patterns, and the repo's over them.
|
||||
all := []string{".*"}
|
||||
p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}})
|
||||
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 {
|
||||
t.Errorf("every key: %d", len(keys))
|
||||
}
|
||||
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
|
||||
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644)
|
||||
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" {
|
||||
t.Errorf("repo's patterns: %v", keys)
|
||||
}
|
||||
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644)
|
||||
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.keys[0]") {
|
||||
t.Errorf("bad pattern: %v", err)
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -92,15 +92,9 @@ func (r Ref) ContainerName() string {
|
||||
// Container.Detail.
|
||||
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
|
||||
|
||||
var collections = map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "secrets",
|
||||
provider.TypeKey: "keys",
|
||||
provider.TypeCertificate: "certificates",
|
||||
}
|
||||
|
||||
func typeOf(collection string) (provider.ObjectType, bool) {
|
||||
for t, c := range collections {
|
||||
if strings.EqualFold(c, collection) {
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
if strings.EqualFold(t.Collection(), collection) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
@@ -118,7 +112,7 @@ func (r Ref) IdentifierURL() string {
|
||||
if r.Type == "" {
|
||||
return u + "/"
|
||||
}
|
||||
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
|
||||
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
|
||||
if seg != "" {
|
||||
u += "/" + url.PathEscape(seg)
|
||||
}
|
||||
@@ -140,7 +134,7 @@ func (r Ref) String() string {
|
||||
segs = append(segs, r.Scope, r.Name)
|
||||
default:
|
||||
if r.Type != "" {
|
||||
segs = append(segs, collections[r.Type])
|
||||
segs = append(segs, r.Type.Collection())
|
||||
}
|
||||
segs = append(segs, r.Name, r.Version)
|
||||
}
|
||||
@@ -273,7 +267,7 @@ func parseLink(s string) (Ref, error) {
|
||||
r.Host = strings.ToLower(segs[0])
|
||||
rest := segs[1:]
|
||||
|
||||
switch strings.ToLower(u.Host) {
|
||||
switch host := strings.ToLower(u.Host); host {
|
||||
case "azure":
|
||||
r.Kind = provider.KindAzure
|
||||
if r.Host == "subscriptions" {
|
||||
@@ -300,11 +294,11 @@ func parseLink(s string) (Ref, error) {
|
||||
}
|
||||
case "databricks", "repo":
|
||||
r.Kind = provider.KindDatabricks
|
||||
if strings.EqualFold(u.Host, "repo") {
|
||||
if host == "repo" {
|
||||
r.Kind = provider.KindRepo
|
||||
}
|
||||
if len(rest) > 2 {
|
||||
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
|
||||
return Ref{}, fmt.Errorf("%s link has too many parts", host)
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
r.Scope = rest[0]
|
||||
|
||||
@@ -48,7 +48,11 @@ func (r Rules) Check(key []byte) error {
|
||||
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
|
||||
}
|
||||
if kinds < r.MinClasses {
|
||||
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
|
||||
kind := "kinds"
|
||||
if kinds == 1 {
|
||||
kind = "kind"
|
||||
}
|
||||
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
|
||||
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
|
||||
}
|
||||
if len(has) == 0 {
|
||||
|
||||
+9
-12
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -725,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.versionsOf != nil {
|
||||
m.setVersions(msg.items)
|
||||
} else {
|
||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||
m.secrets.SetItems(m.starredObjectsFirst(msg.items))
|
||||
cmds = append(cmds, m.refreshShown(before))
|
||||
}
|
||||
}
|
||||
@@ -1257,7 +1256,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
||||
m.secrets.SetLoading()
|
||||
return m.loadObjects(c, m.objType, key)
|
||||
}
|
||||
m.secrets.SetItems(m.starredFirst(e.value))
|
||||
m.secrets.SetItems(m.starredObjectsFirst(e.value))
|
||||
m.secrets.Select(m.cache.cursor[key])
|
||||
if e.fresh(m.now()) {
|
||||
return nil
|
||||
@@ -1676,12 +1675,8 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
|
||||
|
||||
// clipTick fires when the countdown's displayed second next changes.
|
||||
func (m *Model) clipTick() tea.Cmd {
|
||||
wait := m.clipDeadline.Sub(m.now()) % time.Second
|
||||
if wait <= 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
token := m.clipToken
|
||||
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
|
||||
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
|
||||
}
|
||||
|
||||
func (m *Model) cancelClipClear() {
|
||||
@@ -1788,13 +1783,15 @@ func (m Model) footerView() string {
|
||||
} else if l != nil && l.Filtering() {
|
||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||
} else {
|
||||
revealed := m.detail.State() == panes.ValueShown
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
|
||||
reveal := "show"
|
||||
if m.detail.State() == panes.ValueShown {
|
||||
reveal = "hide"
|
||||
}
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
|
||||
}
|
||||
var lines []string
|
||||
if !m.clipDeadline.IsZero() {
|
||||
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
||||
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
|
||||
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
|
||||
}
|
||||
if m.flash.text != "" {
|
||||
lines = append(lines, m.flashLine(m.flash))
|
||||
|
||||
@@ -306,3 +306,6 @@ func newTextInput(prompt string, width int) textinput.Model {
|
||||
in.SetWidth(width)
|
||||
return in
|
||||
}
|
||||
|
||||
// pathInputWidth fits a link or a folder path in a prompt, in the window.
|
||||
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
|
||||
@@ -101,11 +101,14 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
||||
n := st.Node(storeKey(c))
|
||||
line := c.Name
|
||||
if c.Source.Kind == provider.KindRepo {
|
||||
// A secret file: a lock for an encrypted one, a file in the
|
||||
// warning colour for plain text, and its type after it
|
||||
// ("sops · age", "env · plain text").
|
||||
// A secret file: a lock for an encrypted one, a leaf
|
||||
// for Spring config, a file in the warning colour for plain
|
||||
// text, and its type after it ("sops · age", "env · plain text").
|
||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
||||
if !strings.HasPrefix(c.Detail, "sops") {
|
||||
switch {
|
||||
case strings.HasPrefix(c.Detail, "spring"):
|
||||
icon, style = iconSet.Spring, sty.Spring
|
||||
case !strings.HasPrefix(c.Detail, "sops"):
|
||||
icon, style = iconSet.PlainFile, sty.Warning
|
||||
}
|
||||
if icon != "" {
|
||||
@@ -116,7 +119,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||
if account := accountOf(c.Source); account != "" {
|
||||
src = account + " / " + src
|
||||
}
|
||||
line += sty.Muted.Render(" · " + src)
|
||||
@@ -144,24 +147,36 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// starredContainersFirst moves starred vaults and scopes to the front,
|
||||
// keeping the order otherwise. It doesn't modify items, which may be cached.
|
||||
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
|
||||
// starredFirst moves starred items to the front, keeping the order
|
||||
// otherwise. It doesn't modify items, which may be cached.
|
||||
func starredFirst[T any](items []T, starred func(T) bool) []T {
|
||||
rank := func(x T) int {
|
||||
if starred(x) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
out := slices.Clone(items)
|
||||
slices.SortStableFunc(out, func(a, b provider.Container) int {
|
||||
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
|
||||
})
|
||||
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
|
||||
return out
|
||||
}
|
||||
|
||||
// starredFirst moves starred objects to the front, keeping the order
|
||||
// otherwise. It doesn't modify items, which may be cached.
|
||||
func (m Model) starredFirst(items []provider.Object) []provider.Object {
|
||||
out := slices.Clone(items)
|
||||
slices.SortStableFunc(out, func(a, b provider.Object) int {
|
||||
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
|
||||
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
|
||||
return starredFirst(sources, func(s provider.Source) bool {
|
||||
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
|
||||
return starredFirst(items, func(c provider.Container) bool {
|
||||
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
|
||||
return starredFirst(items, func(o provider.Object) bool {
|
||||
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// showLinkedContainers lists the vaults opened from links.
|
||||
@@ -199,7 +214,7 @@ func (m *Model) resortStarred() {
|
||||
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
||||
}
|
||||
if m.versionsOf == nil {
|
||||
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
|
||||
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +260,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
|
||||
c := o.Container
|
||||
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
|
||||
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
|
||||
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
|
||||
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
|
||||
+6
-11
@@ -39,10 +39,9 @@ type contextHelp struct {
|
||||
commands bool // custom commands apply here
|
||||
account bool // the fourth pane shows Fields (fields.go)
|
||||
of pane // what the fourth pane describes
|
||||
// revealed, when set, says whether the selected value is shown, so
|
||||
// the reveal key says just what it'll do: show or hide, the same
|
||||
// width so the bar doesn't shift.
|
||||
revealed *bool
|
||||
// reveal, when set, is what the reveal key will do, "show" or "hide":
|
||||
// the same width so the bar doesn't shift.
|
||||
reveal string
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
@@ -70,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
|
||||
default:
|
||||
reveal := k.Reveal
|
||||
if c.revealed != nil {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
|
||||
if c.reveal != "" {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
|
||||
}
|
||||
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||
}
|
||||
@@ -259,11 +258,7 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
|
||||
|
||||
// move is "↑/↓ move" for an up and a down binding.
|
||||
func move(up, down key.Binding) keyHint {
|
||||
u, d := keys.Name(up), keys.Name(down)
|
||||
if u == "" || d == "" {
|
||||
return keyHint{u + d, "move"}
|
||||
}
|
||||
return keyHint{u + "/" + d, "move"}
|
||||
return keyHint{joinKeys(up, down), "move"}
|
||||
}
|
||||
|
||||
// withKey fills a hint's format with the binding's key ("· %s loads
|
||||
|
||||
+1
-12
@@ -199,18 +199,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
|
||||
return nil
|
||||
}
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if src.ID == n.Source && n.Source != "" {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
return m.openRef(r, actGo)
|
||||
|
||||
+20
-16
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
@@ -210,18 +209,7 @@ const defaultResolveTimeout = 30 * time.Second
|
||||
// openSource goes to a subscription or workspace, asking which account
|
||||
// when several list it. Sources are loaded already, so it needs no lookup.
|
||||
func (m *Model) openSource(r ref.Ref) {
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, src) {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
|
||||
if row, ok := preferredRow(r, choices); ok {
|
||||
choices = []choice{{sourceRow: row}}
|
||||
}
|
||||
@@ -243,6 +231,24 @@ func (m *Model) openSource(r ref.Ref) {
|
||||
}
|
||||
}
|
||||
|
||||
// openWith offers each loaded source of kind that matches, one per
|
||||
// account that lists it.
|
||||
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if matches(src) {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
// refLabel draws what a link points at like the favourites list: the
|
||||
// provider's icon and the path in its colour, " platform-prod /
|
||||
// kv-app-prod / api-key", with the type and version after it. The
|
||||
@@ -831,10 +837,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) startOpenPrompt() tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "open: "
|
||||
in := newTextInput("open: ", m.pathInputWidth())
|
||||
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
m.prompt = &modal{
|
||||
title: "Open a link", input: &in,
|
||||
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
|
||||
|
||||
@@ -75,10 +75,8 @@ type modal struct {
|
||||
|
||||
// showFilterMenu opens a menu with a filter input above the options.
|
||||
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
in := newTextInput("/", 40)
|
||||
in.Placeholder = "type to filter"
|
||||
in.SetWidth(40)
|
||||
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
|
||||
m.prompt.move(0)
|
||||
m.flash = flash{}
|
||||
|
||||
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
@@ -58,11 +57,9 @@ func (m *Model) editNote() tea.Cmd {
|
||||
c := o.Container
|
||||
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
|
||||
}
|
||||
in := textinput.New()
|
||||
in.Prompt = "note: "
|
||||
in := newTextInput("note: ", 48)
|
||||
in.Placeholder = "e.g. prod SQL admin"
|
||||
in.CharLimit = 80
|
||||
in.SetWidth(48)
|
||||
in.SetValue(m.store.Node(key).Note)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
|
||||
+18
-15
@@ -29,41 +29,43 @@ type persistTickMsg struct{}
|
||||
|
||||
type persistedMsg struct{ err error }
|
||||
|
||||
// keepCached reports whether a cache key's account still exists and
|
||||
// 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) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
|
||||
// keepers report whether a provider ID, or a cache key's, is an account
|
||||
// that still exists and isn't a repo.
|
||||
func (m *Model) keepers() (keep, keepKey func(string) bool) {
|
||||
keep = cachefile.Keep(m.providers)
|
||||
return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
|
||||
}
|
||||
|
||||
// loadCacheFile fills the cache from the file, leaving out what's too
|
||||
// old or belongs to accounts that aren't signed in any more.
|
||||
func (m *Model) loadCacheFile() {
|
||||
f := cachefile.Read(m.cachePath)
|
||||
keep, keepKey := m.keepers()
|
||||
now := m.now()
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
|
||||
for id, e := range f.Sources {
|
||||
if m.keepProvider(id) && recent(e.At) {
|
||||
if keep(id) && recent(e.At) {
|
||||
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
||||
m.sourcesAt[id] = e.At
|
||||
}
|
||||
}
|
||||
for k, e := range f.Containers {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
if keepKey(k) && recent(e.At) {
|
||||
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, e := range f.Objects {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
if keepKey(k) && recent(e.At) {
|
||||
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, e := range f.Details {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
if keepKey(k) && recent(e.At) {
|
||||
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, v := range f.Cursor {
|
||||
if m.keepCached(k) {
|
||||
if keepKey(k) {
|
||||
m.cache.cursor[k] = v
|
||||
}
|
||||
}
|
||||
@@ -72,29 +74,30 @@ func (m *Model) loadCacheFile() {
|
||||
// cacheSnapshot is the cache as the file holds it.
|
||||
func (m *Model) cacheSnapshot() cachefile.File {
|
||||
f := cachefile.New()
|
||||
keep, keepKey := m.keepers()
|
||||
f.Dropped, m.cacheDropped = m.cacheDropped, nil
|
||||
for id, r := range m.sourceResults {
|
||||
if r.err == nil && m.keepProvider(id) {
|
||||
if r.err == nil && keep(id) {
|
||||
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.containers {
|
||||
if m.keepCached(k) {
|
||||
if keepKey(k) {
|
||||
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.objects {
|
||||
if m.keepCached(k) {
|
||||
if keepKey(k) {
|
||||
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.details {
|
||||
if m.keepCached(k) {
|
||||
if keepKey(k) {
|
||||
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, v := range m.cache.cursor {
|
||||
if m.keepCached(k) {
|
||||
if keepKey(k) {
|
||||
f.Cursor[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,22 @@ func (m *Model) startRevealTimer() tea.Cmd {
|
||||
|
||||
// revealTickCmd fires when the displayed second next changes.
|
||||
func (m *Model) revealTickCmd() tea.Cmd {
|
||||
wait := m.revealDeadline.Sub(m.now()) % time.Second
|
||||
if wait <= 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
token := m.revealToken
|
||||
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
|
||||
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
|
||||
}
|
||||
|
||||
// untilNextSecond is how long until a countdown to deadline shows its
|
||||
// next whole second.
|
||||
func untilNextSecond(deadline, now time.Time) time.Duration {
|
||||
if wait := deadline.Sub(now) % time.Second; wait > 0 {
|
||||
return wait
|
||||
}
|
||||
return time.Second
|
||||
}
|
||||
|
||||
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
|
||||
func secondsLeft(deadline, now time.Time) int {
|
||||
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
|
||||
}
|
||||
|
||||
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
|
||||
@@ -49,8 +59,7 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) showHidesIn() {
|
||||
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
|
||||
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
|
||||
m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
|
||||
}
|
||||
|
||||
// hideValue hides the revealed value and stops its countdown.
|
||||
|
||||
@@ -341,6 +341,19 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// vaultsLoaded counts the vaults whose secrets are cached or can't be
|
||||
// read, and says whether any are loading.
|
||||
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
|
||||
for _, c := range vaults {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
return loaded, loading
|
||||
}
|
||||
|
||||
// sourceMark shows how much of a subscription or workspace is loaded:
|
||||
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
|
||||
func (m *Model) sourceMark(row string) string {
|
||||
@@ -354,14 +367,7 @@ func (m *Model) sourceMark(row string) string {
|
||||
if !ok {
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
loaded, loading := 0, false
|
||||
for _, c := range e.value {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
loaded, loading := m.vaultsLoaded(e.value)
|
||||
switch {
|
||||
case loading:
|
||||
return m.st.Muted.Render("◌")
|
||||
|
||||
@@ -95,9 +95,7 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
|
||||
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
|
||||
|
||||
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
|
||||
in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
|
||||
v := &searchView{input: in, tab: tab, results: true}
|
||||
v.favs, v.recent = m.storeEntries()
|
||||
m.search = v
|
||||
@@ -616,13 +614,7 @@ func (m Model) loadHint() string {
|
||||
}
|
||||
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
|
||||
}
|
||||
loaded := 0
|
||||
for _, c := range e.value {
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
loaded, _ := m.vaultsLoaded(e.value)
|
||||
if loaded == len(e.value) {
|
||||
return ""
|
||||
}
|
||||
@@ -770,7 +762,7 @@ func (m Model) searchViewBox() string {
|
||||
body = append(body, " "+m.st.Error.Render(l))
|
||||
}
|
||||
}
|
||||
body = append(body, "", " "+m.goToTabs(ts))
|
||||
body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
|
||||
}
|
||||
body = append(body, " "+v.input.View(), "")
|
||||
for _, l := range strings.Split(lists, "\n") {
|
||||
@@ -803,7 +795,7 @@ func (m Model) searchViewBox() string {
|
||||
// Tabs in the title, the current one underlined, like the panes; a
|
||||
// picker has its own title, and the tabs over the input.
|
||||
if v.picker == nil {
|
||||
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
|
||||
head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
}
|
||||
// A picker: in its own border, under what it's for.
|
||||
@@ -856,19 +848,3 @@ func pickable(o option) (ref.Ref, bool) {
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// goToTabs draws Go to's tabs, the current one underlined.
|
||||
func (m Model) goToTabs(ts lipgloss.Style) string {
|
||||
var out string
|
||||
for i, name := range tabNames {
|
||||
if i > 0 {
|
||||
out += ts.Render(" - ")
|
||||
}
|
||||
if i == m.search.tab {
|
||||
out += ts.Underline(true).Render(name)
|
||||
} else {
|
||||
out += m.st.Muted.Render(name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
@@ -338,9 +337,7 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
|
||||
// askShareDir asks where to save files from a repo that isn't here. It
|
||||
// stays until they're saved, saying why when they can't be.
|
||||
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "folder: "
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
in := newTextInput("folder: ", m.pathInputWidth())
|
||||
dir := m.placeDir
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
|
||||
@@ -134,10 +134,7 @@ func (m *Model) rebuildSources() {
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
kind, id := p.Kind(), p.ID()
|
||||
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
|
||||
if account == p.Label() {
|
||||
account = ""
|
||||
}
|
||||
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
|
||||
r, done := m.sourceResults[id]
|
||||
sources := r.sources
|
||||
if !flat {
|
||||
@@ -215,17 +212,6 @@ func (m Model) currentSource() (provider.Source, bool) {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
|
||||
// starredSourcesFirst moves starred sources to the front, keeping the
|
||||
// order otherwise. It doesn't modify sources.
|
||||
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
|
||||
out := slices.Clone(sources)
|
||||
rank := func(s provider.Source) int {
|
||||
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
|
||||
return out
|
||||
}
|
||||
|
||||
// starRank orders the Favourites row, then starred sources, then the rest.
|
||||
func starRank(mem *store.Store, r sourceRow) int {
|
||||
switch {
|
||||
@@ -239,13 +225,6 @@ func starRank(mem *store.Store, r sourceRow) int {
|
||||
return 3
|
||||
}
|
||||
|
||||
func boolRank(starred bool) int {
|
||||
if starred {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// selectedSource is the source under the cursor; false on a header.
|
||||
func (m Model) selectedSource() (provider.Source, bool) {
|
||||
r, ok := m.sources.Selected()
|
||||
|
||||
@@ -2,13 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
@@ -130,15 +128,7 @@ func (m *Model) showUpdate() {
|
||||
run: func(m *Model) tea.Cmd { return m.install(r) }}}
|
||||
if r.URL != "" {
|
||||
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
|
||||
switch err := m.openURL(r.URL); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
cmd := m.copyText("link to the release", r.URL, false)
|
||||
m.flash.text += " (can't open a browser over SSH)"
|
||||
return cmd
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
}
|
||||
return nil
|
||||
return m.openPage("the release page", r.URL, func() {})
|
||||
}})
|
||||
}
|
||||
m.showMenu("lazysecrets "+r.Tag, lines, opts)
|
||||
|
||||
@@ -11,6 +11,7 @@ type Set struct {
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
Spring string // a Spring Boot config file
|
||||
Note string // has a note (lazysecrets list)
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ var NerdFont = Set{
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
Spring: "\U000F032A", // md-leaf
|
||||
Note: "\U000F09ED",
|
||||
}
|
||||
|
||||
|
||||
@@ -69,3 +69,20 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
|
||||
}
|
||||
return st.TitleBlurred
|
||||
}
|
||||
|
||||
// Tabs draws tab names for a title in style ts, the current one
|
||||
// underlined and the rest muted.
|
||||
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
|
||||
var out string
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
out += ts.Render(" - ")
|
||||
}
|
||||
if i == current {
|
||||
out += ts.Underline(true).Render(name)
|
||||
} else {
|
||||
out += st.Muted.Render(name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+27
-21
@@ -188,20 +188,31 @@ func carryStyle(lines []string) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
var valueIndent = strings.Repeat(" ", 12)
|
||||
|
||||
// fieldLabel starts a detail line: the label, padded so values line up
|
||||
// at valueIndent.
|
||||
func fieldLabel(st styles.Styles, label string) string {
|
||||
return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
|
||||
}
|
||||
|
||||
// lines renders the body, wrapped to the pane width.
|
||||
func (d Detail) lines(st styles.Styles) []string {
|
||||
inner := max(1, d.w-3)
|
||||
var out []string
|
||||
field := func(label, value string) {
|
||||
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
|
||||
indent := strings.Repeat(" ", 12)
|
||||
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
|
||||
out = append(out, prefix+wrapped[0])
|
||||
out = append(out, fieldLabel(st, label)+wrapped[0])
|
||||
for _, w := range wrapped[1:] {
|
||||
out = append(out, indent+w)
|
||||
out = append(out, valueIndent+w)
|
||||
}
|
||||
}
|
||||
muted := st.Muted.Render
|
||||
note := func() {
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.note))
|
||||
}
|
||||
}
|
||||
|
||||
if d.container == nil {
|
||||
return []string{muted(" Select a vault or scope to browse its secrets.")}
|
||||
@@ -211,30 +222,27 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
if containerLabel == "" {
|
||||
containerLabel = "vault"
|
||||
}
|
||||
source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name
|
||||
|
||||
if d.secret == nil {
|
||||
field(containerLabel, c.Name)
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.note))
|
||||
}
|
||||
note()
|
||||
if c.Detail != "" {
|
||||
field("", muted(c.Detail))
|
||||
}
|
||||
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
field("source", source)
|
||||
out = append(out, "", muted(" Select a secret to see its details."))
|
||||
return out
|
||||
}
|
||||
|
||||
s := *d.secret
|
||||
field("name", s.Name)
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.note))
|
||||
}
|
||||
note()
|
||||
field(containerLabel, c.Name)
|
||||
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
|
||||
field("", muted(c.Detail))
|
||||
}
|
||||
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
field("source", source)
|
||||
if s.Version != "" {
|
||||
field("version", s.Version)
|
||||
}
|
||||
@@ -298,14 +306,12 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
case provider.TypeCertificate:
|
||||
label, hidden, reveal = "cert", "", "show the certificate"
|
||||
}
|
||||
indent := strings.Repeat(" ", 12)
|
||||
|
||||
out = append(out, "")
|
||||
switch d.state {
|
||||
case ValueHidden:
|
||||
if hidden != "" {
|
||||
field(label, hidden)
|
||||
out = append(out, indent+hints(reveal))
|
||||
out = append(out, valueIndent+hints(reveal))
|
||||
} else {
|
||||
field(label, hints(reveal))
|
||||
}
|
||||
@@ -322,9 +328,9 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
|
||||
w = lead + w
|
||||
if i == 0 && j == 0 {
|
||||
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
|
||||
out = append(out, fieldLabel(st, label)+st.Error.Render(w))
|
||||
} else {
|
||||
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
|
||||
out = append(out, valueIndent+st.Error.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,10 +341,10 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
for _, line := range lines {
|
||||
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
|
||||
if first {
|
||||
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
|
||||
out = append(out, fieldLabel(st, label)+st.Value.Render(w))
|
||||
first = false
|
||||
} else {
|
||||
out = append(out, indent+st.Value.Render(w))
|
||||
out = append(out, valueIndent+st.Value.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,7 +358,7 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
if d.hidesIn != "" {
|
||||
more = append(more, "hides in "+d.hidesIn)
|
||||
}
|
||||
out = append(out, indent+hints("hide", more...))
|
||||
out = append(out, valueIndent+hints("hide", more...))
|
||||
}
|
||||
return append(out, d.versionLines(st)...)
|
||||
}
|
||||
@@ -373,7 +379,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
|
||||
head += muted(" · " + d.VersionsKey + " to browse")
|
||||
}
|
||||
}
|
||||
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
|
||||
out := []string{"", fieldLabel(st, "versions") + head}
|
||||
current := d.versions[0].Version
|
||||
for i, v := range d.versions {
|
||||
if i == maxVersionLines {
|
||||
|
||||
@@ -329,17 +329,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
ts := TitleStyle(st, focused)
|
||||
title := ts.Render(l.Title)
|
||||
if len(l.Tabs) > 1 {
|
||||
for i, tab := range l.Tabs {
|
||||
sep := " - "
|
||||
if i == 0 {
|
||||
sep = " "
|
||||
}
|
||||
if i == l.Tab {
|
||||
title += ts.Render(sep) + ts.Underline(true).Render(tab)
|
||||
} else {
|
||||
title += ts.Render(sep) + st.Muted.Render(tab)
|
||||
}
|
||||
}
|
||||
title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
|
||||
}
|
||||
if q := l.input.Value(); q != "" && !l.filtering {
|
||||
title += ts.Render(" /" + q)
|
||||
@@ -370,26 +360,13 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
default:
|
||||
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
|
||||
line := l.item.Render(st, l.items[l.visible[i]], inner-1)
|
||||
plain := ansi.Strip(line)
|
||||
hits := l.highlight(i, plain)
|
||||
line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
|
||||
if i == l.cursor {
|
||||
base := st.SelectedBlurred
|
||||
if focused {
|
||||
base = st.Selected
|
||||
}
|
||||
text := " " + line
|
||||
if len(hits) > 0 {
|
||||
for j := range hits {
|
||||
hits[j]++ // leading space
|
||||
}
|
||||
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
|
||||
}
|
||||
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
|
||||
} else if len(hits) > 0 {
|
||||
// Style just the matches, keeping the row's own colours.
|
||||
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
|
||||
} else {
|
||||
line = " " + line
|
||||
line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type Styles struct {
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks lipgloss.Style
|
||||
KindRepo lipgloss.Style
|
||||
Spring lipgloss.Style // Spring Boot config files
|
||||
|
||||
// themed marks the selection colours as set by the theme, so
|
||||
// OnBackground leaves them.
|
||||
@@ -70,6 +71,7 @@ func Default() Styles {
|
||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ var Roles = map[string]string{
|
||||
"azureColor": "Azure sources and vaults",
|
||||
"databricksColor": "Databricks workspaces and scopes",
|
||||
"repoColor": "repos and their secret files (SOPS)",
|
||||
"springColor": "Spring Boot config files",
|
||||
}
|
||||
|
||||
// FromTheme returns the default styles with the theme's roles applied, and
|
||||
@@ -99,6 +100,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||
case "repoColor":
|
||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||
case "springColor":
|
||||
st.Spring = v.fg(lipgloss.NewStyle())
|
||||
}
|
||||
}
|
||||
return st, fg, nil
|
||||
|
||||
@@ -45,9 +45,9 @@ const (
|
||||
// DefaultServers are where releases are looked for, in order.
|
||||
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
||||
|
||||
// latestTimeout bounds asking one server for the latest release, so a
|
||||
// server that's down leaves time for the next.
|
||||
const latestTimeout = 5 * time.Second
|
||||
// serverTimeout bounds asking one server for the latest release or its
|
||||
// checksums, so a server that's down leaves time for the next.
|
||||
const serverTimeout = 5 * time.Second
|
||||
|
||||
// maxDownload bounds what's read from the server.
|
||||
const maxDownload = 200 << 20
|
||||
@@ -151,7 +151,7 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, serverTimeout)
|
||||
defer cancel()
|
||||
// The recent releases in one request (per_page on GitHub, limit on
|
||||
// Gitea), for their changelogs.
|
||||
@@ -220,7 +220,7 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
if m := Managed(exe); m != "" {
|
||||
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
|
||||
}
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
||||
if (runtime.GOOS != "darwin" && runtime.GOOS != "linux") || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") {
|
||||
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
version := strings.TrimPrefix(r.Tag, "v")
|
||||
@@ -270,7 +270,7 @@ func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error)
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
||||
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
sctx, cancel := context.WithTimeout(ctx, serverTimeout)
|
||||
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
||||
cancel()
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user