From de38b8b281c3d314b272adcd6c0fcd198a55d516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Thu, 15 Oct 2026 14:19:30 +0200 Subject: [PATCH] feat: Docker Compose files in repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose.yaml, docker-compose.yml and their variants tracked by git are listed under Repos, their services' environment variables as secrets, named service.VAR. - environment as a map or a list, and <<: *anchors shared between services; the secret patterns pick the keys, the rest hidden, with compose.secretPatterns for compose files' own. - ${VAR}, $VAR, ${VAR:?…} and variables without a value are marked as references; docker-compose.example.yml and the like are hidden. - Jasypt values decrypt with the service's JASYPT_ENCRYPTOR_PASSWORD. - A blue Docker icon, themeable with composeColor. --- README.md | 4 +- cmd/lazysecrets/list.go | 2 +- cmd/lazysecrets/main.go | 2 +- docs/config.md | 1 + docs/keys.md | 4 +- docs/repos.md | 40 ++++-- internal/config/config.go | 6 +- internal/provider/repo/compose.go | 141 ++++++++++++++++++++ internal/provider/repo/compose_test.go | 140 ++++++++++++++++++++ internal/provider/repo/configfile.go | 170 +++++++++++++++++++++++++ internal/provider/repo/details.go | 6 +- internal/provider/repo/jasypt.go | 48 +++---- internal/provider/repo/repo.go | 26 ++-- internal/provider/repo/spring.go | 169 +++++------------------- internal/provider/repo/spring_test.go | 2 +- internal/ui/app/favourites.go | 9 +- internal/ui/icons/icons.go | 2 + internal/ui/keys/keys.go | 2 +- internal/ui/styles/styles.go | 2 + internal/ui/styles/theme.go | 3 + 20 files changed, 577 insertions(+), 202 deletions(-) create mode 100644 internal/provider/repo/compose.go create mode 100644 internal/provider/repo/compose_test.go create mode 100644 internal/provider/repo/configfile.go diff --git a/README.md b/README.md index 8db978a..d67590d 100644 --- a/README.md +++ b/README.md @@ -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, `.env` and Spring Boot config files in your git repos. +- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose 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, .env and Spring files](docs/repos.md): which repos and files, decrypting +- [SOPS, .env, Spring and compose 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 diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 4781d4b..c590e3a 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -57,7 +57,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)") kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")") - hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring config keys that don't look secret, and files and repos with only those (json: \"hidden\")") + hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")") if err := fs.Parse(args); err != nil { return err } diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 55a382d..22dcdfd 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -347,7 +347,7 @@ Flags: repos := repo.New(repo.Settings{ Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots), Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, - SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring}, + SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose}, }) // The repo it started in (shown on top as Repo) and the // others, checked for secret files in the background like diff --git a/docs/config.md b/docs/config.md index fa79722..d634117 100644 --- a/docs/config.md +++ b/docs/config.md @@ -75,6 +75,7 @@ theme: databricksColor: ["#ff757f"] repoColor: ["#c099ff"] springColor: ["#c3e88d"] # Spring Boot config files + composeColor: ["#82aaff"] # Docker Compose files ``` A role lazysecrets doesn't know, a typo or one from a newer version, is diff --git a/docs/keys.md b/docs/keys.md index 5f1d440..b9a4008 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -20,7 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter | `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | | `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars | | `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it | -| `H` | show / hide what's hidden: [Spring keys](repos.md#spring-boot-config) that don't look secret, and files and repos with only those; shown dimmed | +| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed | | `r` | refresh the focused pane | | `q`, `ctrl+c` | quit | @@ -54,7 +54,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, `.env` or Spring file, or a secret's file at its line ([how](repos.md#opening-files)) | +| `e` | open the selected SOPS, `.env`, Spring or compose 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) | diff --git a/docs/repos.md b/docs/repos.md index d84c110..0bd0dc9 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -1,9 +1,9 @@ -# SOPS, .env and Spring files in git repos +# SOPS, .env, Spring and compose files in git repos lazysecrets shows git repositories under **Repos**: their [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, -`.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 +`.env`, INI), plain env files, [Spring Boot config](#spring-boot-config) +and [Docker Compose files](#docker-compose) 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 @@ -47,7 +47,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 and Spring files too. +`include`/`exclude` apply to env, Spring and compose files too. ```yaml providers: @@ -100,10 +100,14 @@ providers: spring: secretPatterns: # Spring's: only what differs keys: ["(?i)(password|secret)$", "bearer$"] + compose: + secretPatterns: # and compose's + values: [] ``` A repo's `.lazysecrets.yaml` can set them too, the same way -(`secretPatterns:`, `spring: secretPatterns:`), for everyone. Each list +(`secretPatterns:`, `spring: secretPatterns:`, `compose: +secretPatterns:`), for everyone. Each list comes from the first that sets it: the repo's for the type, the repo's, the config's for the type, the config's, the defaults. @@ -113,6 +117,22 @@ document's keys get its profile, `spring.datasource.password (prod)`. Values Maven fills in at build time (`@project.version@`) are read as they are. +## Docker Compose + +`compose.yaml`, `docker-compose.yml` and their variants +(`docker-compose.test.yml`), tracked by git; `docker-compose.example.yml` +and the like are hidden. Their secrets are the services' `environment` +variables, as a map (`DB_PASSWORD: x`) or a list (`- DB_PASSWORD=x`), +named `service.VAR` (`api.DB_PASSWORD`); a `<<: *anchor` shared between +services is read too. The same [secret patterns](#spring-boot-config) +pick them, the rest hidden, with `compose: secretPatterns:` for compose +files' own. + +`${DB_PASSWORD}`, `$DB_PASSWORD` and `${DB_PASSWORD:?…}`, and a variable +without a value (`DB_PASSWORD:`, `- DB_PASSWORD`), taken from where +compose runs, are marked as references; `${DB_PASSWORD:-x}` has its +default in the file. + ## Decrypting Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and @@ -131,15 +151,17 @@ repo's history, or easily could be. ### Jasypt -A value like `ENC(...)` in a plain `.env` or Spring file is +A value like `ENC(...)` in a plain `.env`, Spring or compose file is [Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied with jasypt-spring-boot's default algorithm (3.0+: `PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring: 1. `$JASYPT_ENCRYPTOR_PASSWORD`, -2. the file's own: `jasypt.encryptor.password` in a Spring file, - `JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` (or - `${VAR:default}`) in it is read from the environment. +2. the file's own: `jasypt.encryptor.password` in a Spring file, the + service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file, + `JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` in it (with a + default: `${VAR:default}` in Spring, `${VAR:-default}` elsewhere) is + read from the environment. Environment variables are lazysecrets' own: a link opened from the browser starts it without your shell's. A `.env` whose values are all Jasypt's diff --git a/internal/config/config.go b/internal/config/config.go index 9a3a5ea..078cabf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,9 +155,10 @@ type Repos struct { Include []string `yaml:"include"` Exclude []string `yaml:"exclude"` // SecretPatterns say which keys of config files are secrets; Spring - // overrides them for Spring Boot's, list by list. + // and Compose override them for their files, list by list. SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"` Spring repo.FileType `yaml:"spring"` + Compose repo.FileType `yaml:"compose"` } type Azure struct { @@ -298,7 +299,8 @@ func (c Config) validate(path string) error { } } if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"), - c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns")); err != nil { + c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"), + c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns")); err != nil { return fmt.Errorf("%s: %w", path, err) } for i, v := range c.Providers.Azure.Vaults { diff --git a/internal/provider/repo/compose.go b/internal/provider/repo/compose.go new file mode 100644 index 0000000..398aa3e --- /dev/null +++ b/internal/provider/repo/compose.go @@ -0,0 +1,141 @@ +package repo + +import ( + "path" + "regexp" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Docker Compose files: compose.yaml, docker-compose.yml and their +// variants (docker-compose.test.yml). Their keys are the services' +// environment variables, named service.VAR. + +var composeType = configType{ + name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose, + jasyptSetting: func(name string) string { + service, _, _ := strings.Cut(name, ".") + return service + "." + jasyptEnv + }, + placeholder: composePlaceholder, +} + +var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`) + +// IsCompose reports whether f is named like a Docker Compose file. +func IsCompose(f string) bool { + return composeName.MatchString(strings.ToLower(path.Base(f))) +} + +// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default}, +// ${VAR:?error}, ${VAR?error} or $VAR. +var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`) + +func composePlaceholder(v string) (name, def string, hasDef, ok bool) { + m := composePlaceholderRe.FindStringSubmatch(v) + if m == nil { + return "", "", false, false + } + return m[1] + m[4], m[3], m[2] != "", true +} + +func parseCompose(_ string, b []byte) ([]configKey, error) { + var doc yaml.Node + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 { + return nil, nil + } + var out []configKey + for _, service := range pairs(field(doc.Content[0], "services")) { + for _, k := range environment(field(service.value, "environment")) { + k.name = service.key.Value + "." + k.key + out = append(out, k) + } + } + return out, nil +} + +// environment reads a service's environment, a map (KEY: value) or a list +// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from +// where compose runs: a reference. +func environment(n *yaml.Node) []configKey { + var out []configKey + switch n = deref(n); { + case n == nil: + case n.Kind == yaml.MappingNode: + for _, p := range pairs(n) { + v := deref(p.value) + ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value) + out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line}) + } + case n.Kind == yaml.SequenceNode: + for _, item := range n.Content { + item = deref(item) + k, v, ok := strings.Cut(item.Value, "=") + out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line}) + } + } + return out +} + +type pair struct{ key, value *yaml.Node } + +// pairs are a mapping's entries, with those merged in by << (anchors +// shared between services) first and its own over them. +func pairs(n *yaml.Node) []pair { + n = deref(n) + if n == nil || n.Kind != yaml.MappingNode { + return nil + } + var merged, own []pair + for i := 0; i+1 < len(n.Content); i += 2 { + k, v := n.Content[i], n.Content[i+1] + if k.Value != "<<" { + own = append(own, pair{k, v}) + continue + } + from := []*yaml.Node{v} + if v.Kind == yaml.SequenceNode { + from = v.Content + } + for _, m := range from { + merged = append(merged, pairs(m)...) + } + } + var out []pair + for _, p := range append(merged, own...) { + if i := indexOf(out, p.key.Value); i >= 0 { + out[i] = p + } else { + out = append(out, p) + } + } + return out +} + +func indexOf(ps []pair, key string) int { + for i, p := range ps { + if p.key.Value == key { + return i + } + } + return -1 +} + +func field(n *yaml.Node, key string) *yaml.Node { + ps := pairs(n) + if i := indexOf(ps, key); i >= 0 { + return ps[i].value + } + return nil +} + +func deref(n *yaml.Node) *yaml.Node { + for n != nil && n.Kind == yaml.AliasNode { + n = n.Alias + } + return n +} diff --git a/internal/provider/repo/compose_test.go b/internal/provider/repo/compose_test.go new file mode 100644 index 0000000..236d652 --- /dev/null +++ b/internal/provider/repo/compose_test.go @@ -0,0 +1,140 @@ +package repo + +import ( + "context" + "maps" + "os" + "path/filepath" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func TestIsCompose(t *testing.T) { + for f, want := range map[string]bool{ + "docker-compose.yml": true, + "deploy/compose.yaml": true, + "docker-compose.test.yml": true, + "docker-compose-dev.yaml": true, + "docker-compose.example.yml": true, // listed hidden + "applications.yaml": false, + "composer.json": false, + "compose.yml.bak": false, + } { + if IsCompose(f) != want { + t.Errorf("%s: %t", f, !want) + } + } +} + +func TestComposePlaceholder(t *testing.T) { + for v, want := range map[string]bool{ + "${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true, + "${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file + "hunter2": false, "pre-${DB_PASSWORD}": false, "$": false, + } { + if got := reference(composePlaceholder, v); got != want { + t.Errorf("%s: reference %t", v, got) + } + } + if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef { + t.Errorf("default: %s %s %t", name, def, hasDef) + } +} + +func TestParseCompose(t *testing.T) { + keys, err := parseCompose("", []byte(`x-db: &db + DB_USER: app + DB_PASSWORD: shared +services: + api: + image: api + environment: + <<: *db + DB_PASSWORD: own + API_KEY: ${API_KEY} + FROM_HOST: + worker: + environment: + - QUEUE_PASSWORD=hunter2 + - TOKEN + - EMPTY= + web: + image: web +`)) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, k := range keys { + ref := "" + if k.ref { + ref = " (ref)" + } + got[k.name] = k.value + ref + } + want := map[string]string{ + "api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)", + "worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "", + } + if !maps.Equal(got, want) { + t.Errorf("got %v\nwant %v", got, want) + } + for _, k := range keys { + if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") { + t.Errorf("%s: line %d, key %s", k.name, k.line, k.key) + } + } +} + +func TestComposeFiles(t *testing.T) { + root := gitRepo(t, map[string]string{ + "docker-compose.yml": `services: + api: + environment: + PORT: "8080" + DB_PASSWORD: ${DB_PASSWORD} + JASYPT_ENCRYPTOR_PASSWORD: test-password + API_TOKEN: ` + jasyptHunter2 + ` +`, + "docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n", + }) + t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "") + ctx := context.Background() + p := New(Settings{Dir: root}) + srcs, _ := p.Sources(ctx) + cs, err := p.Containers(ctx, srcs[0]) + if err != nil || len(cs) != 2 { + t.Fatalf("files: %+v %v", cs, err) + } + for _, c := range cs { + if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") { + t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden) + } + } + compose := cs[1] // after docker-compose.example.yml + + keys, _ := p.Objects(ctx, compose, provider.TypeSecret) + got := map[string]string{} + for _, k := range shown(keys) { + got[k.Name] = k.Detail + } + if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) { + t.Errorf("keys %v, want %v", got, want) + } + // The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values. + link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"} + if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" { + t.Errorf("jasypt: %q %v", v.Reveal(), err) + } + if f, _ := p.File(compose, &link); f.Line != 7 { + t.Errorf("line %d", f.Line) + } + + // compose.secretPatterns over the shared ones. + os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644) + keys, _ = p.Objects(ctx, compose, provider.TypeSecret) + if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" { + t.Errorf("compose's patterns: %v", s) + } +} diff --git a/internal/provider/repo/configfile.go b/internal/provider/repo/configfile.go new file mode 100644 index 0000000..d594711 --- /dev/null +++ b/internal/provider/repo/configfile.go @@ -0,0 +1,170 @@ +package repo + +import ( + "fmt" + "os" + "path/filepath" + "slices" + + "git.kebcloud.no/keb/lazysecrets/internal/jasypt" + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Config files (Spring's, compose's) are read directly, like env files, +// but most of their keys aren't secret: those the secret patterns +// (patterns.go) don't match are hidden, and a file with none of them is. + +// configType is a type of config file: what it's called (in settings, as +// spring:, and on the file's row) and how its keys are read. +type configType struct { + name string + is func(file string) bool + format func(file string) string // yaml, properties + parse func(file string, b []byte) ([]configKey, error) + // jasyptSetting is the key that holds the Jasypt password for the + // key named name. + jasyptSetting func(name string) string + // placeholder reads a value that only refers to another, + // ${DB_PASSWORD}: what it names, and its default if it has one. + placeholder func(v string) (name, def string, hasDef, ok bool) +} + +var configTypes = []configType{springType, composeType} + +func isConfig(f string) bool { + _, ok := configTypeOf(f) + return ok +} + +// configTypeOf is the type of config file f is, if it's one. +func configTypeOf(f string) (configType, bool) { + for _, t := range configTypes { + if t.is(f) { + return t, true + } + } + return configType{}, false +} + +// detail is the file's row: "spring · yaml". +func (t configType) detail(file string) string { return t.name + " · " + t.format(file) } + +// configKey is a key of a config file, with its value. +type configKey struct { + key string // as the app sees it: spring.datasource.password, DB_PASSWORD + name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD + value string + ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD} + line int // 1-based +} + +func (k configKey) jasypt() bool { return jasypt.Is(k.value) } + +// plain reports whether the file holds the value itself, readable. +func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() } + +// reference reports whether v is only a placeholder without a default: +// one with a default has the default in the file. +func reference(placeholder func(string) (string, string, bool, bool), v string) bool { + _, _, hasDef, ok := placeholder(v) + return ok && !hasDef +} + +func (t configType) read(file string) ([]configKey, error) { + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return t.parse(file, b) +} + +func (t configType) keyNamed(file, name string) (configKey, error) { + keys, err := t.read(file) + if err != nil { + return configKey{}, err + } + for _, k := range keys { + if k.name == name { + return k, nil + } + } + return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file)) +} + +func (t configType) value(file, name string) (provider.SecretValue, error) { + k, err := t.keyNamed(file, name) + if err != nil { + return provider.SecretValue{}, err + } + return provider.NewSecretValue([]byte(k.value)), nil +} + +// configHidden reports whether a config file has no secret keys. One that +// can't be read isn't hidden: its error is worth seeing. +func (p *Provider) configHidden(t configType, root, file string) bool { + keys, err := t.read(file) + if err != nil { + return false + } + filter, err := p.filterFor(root, t.name) + if err != nil { + return false + } + return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) }) +} + +// configObjects lists the file's keys, those that don't look like secrets +// hidden. +func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) { + keys, err := t.parse(c.ID, b) + if err != nil { + return nil, fmt.Errorf("%s: %w", c.Name, err) + } + filter, err := p.filterFor(c.Source.ID, t.name) + if err != nil { + return nil, err + } + format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)} + var out []provider.Object + for _, k := range keys { + state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, "" + switch { + case k.ref: + state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference" + case k.jasypt(): + state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt" + } + out = append(out, provider.Object{ + Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail, + Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state}, + }) + } + return out, nil +} + +// configDetails says how many of the file's keys aren't listed, and +// whether any listed one is in plain text. +func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) { + keys, err := t.read(c.ID) + if err != nil { + return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false + } + filter, err := p.filterFor(c.Source.ID, t.name) + 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.key, k.value) { + secrets++ + plain = plain || k.plain() + } + if k.jasypt() { + jasypt++ + } + } + return []provider.Attr{ + {Label: "encryption", Value: jasyptShare(jasypt, len(keys))}, + {Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, + }, plain +} diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go index 7a9ebcd..1844c2f 100644 --- a/internal/provider/repo/details.go +++ b/internal/provider/repo/details.go @@ -25,10 +25,10 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide var out []provider.Attr meta, isSops := readMeta(c.ID) // A plain file's values are at risk in git, unless they're all Jasypt; - // a Spring file's only when one of its secrets is in plain text. + // a config file's only when one of its secrets is in plain text. plain := !isSops - if !isSops && IsSpring(c.ID) { - out, plain = p.springDetails(c) + if t, ok := configTypeOf(c.ID); ok && !isSops { + out, plain = p.configDetails(t, c) } else if isSops { out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) for i, k := range meta.recipients { diff --git a/internal/provider/repo/jasypt.go b/internal/provider/repo/jasypt.go index c1bb68d..c13b5f3 100644 --- a/internal/provider/repo/jasypt.go +++ b/internal/provider/repo/jasypt.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "git.kebcloud.no/keb/lazysecrets/internal/jasypt" "git.kebcloud.no/keb/lazysecrets/internal/provider" @@ -17,12 +16,12 @@ const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD" // jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain // file; other values are as they are. -func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) { +func jasyptValue(file, name string, v provider.SecretValue) (provider.SecretValue, error) { s := v.Reveal() if !jasypt.Is(s) { return v, nil } - password, from, err := jasyptPassword(file) + password, from, err := jasyptPassword(file, name) if err != nil { return provider.SecretValue{}, err } @@ -36,39 +35,32 @@ func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, err return provider.NewSecretValue([]byte(plain)), nil } -// jasyptPassword is the password for a file's Jasypt values, and where it's -// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting -// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD -// in an env file), with a ${VAR} in it read from the environment. -func jasyptPassword(file string) (password, from string, err error) { +// jasyptPassword is the password for the Jasypt value of the key named +// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's +// own setting (jasypt.encryptor.password in a Spring file; the service's +// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file), +// with a ${VAR} in it read from the environment. +func jasyptPassword(file, name string) (password, from string, err error) { if v := os.Getenv(jasyptEnv); v != "" { return v, "$" + jasyptEnv, nil } - setting, read := jasyptEnv, plainValue - if IsSpring(file) { - setting, read = "jasypt.encryptor.password", springValue + setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose + if t, ok := configTypeOf(file); ok { + setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder } v, err := read(file, setting) if err != nil { return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file)) } - return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file)) -} - -// placeholder is Spring's ${VAR} or ${VAR:default}. -var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`) - -// resolvePlaceholder reads a ${VAR} value from the environment, falling -// back to its default; other values are as they are. -func resolvePlaceholder(v, from string) (string, string, error) { - m := placeholder.FindStringSubmatch(v) + from = setting + " in " + filepath.Base(file) + env, def, hasDef, ok := placeholder(v.Reveal()) switch { - case m == nil: - return v, from, nil - case os.Getenv(m[1]) != "": - return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil - case m[2] != "": - return m[2][1:], "the default in " + from, nil + case !ok: + return v.Reveal(), from, nil + case os.Getenv(env) != "": + return os.Getenv(env), "$" + env + " (" + from + ")", nil + case hasDef: + return def, "the default in " + from, nil } - return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1]) + return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env) } diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index ed1cf0e..8537997 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -1,7 +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, plain env files and -// Spring Boot config (spring.go). +// config files: Spring Boot's and Docker Compose's (configfile.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 @@ -336,9 +336,10 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi case isEnvNamed(f): c.Detail = provider.DetailPlainEnv c.Hidden = isExample(f) - case IsSpring(f): - c.Detail = "spring · " + springFormat(f) - c.Hidden = p.springHidden(r.root, path) + case isConfig(f): + t, _ := configTypeOf(f) + c.Detail = t.detail(f) + c.Hidden = isExample(f) || p.configHidden(t, r.root, path) default: return } @@ -376,8 +377,8 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O return nil, err } meta, isSops := parseMeta(c.ID, b) - if !isSops && IsSpring(c.ID) { - return p.springObjects(c, b) + if t, ok := configTypeOf(c.ID); ok && !isSops { + return p.configObjects(t, c, b) } keys, err := parseKeys(c.ID, b) if err != nil { @@ -421,14 +422,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre } if _, isSops := readMeta(file); !isSops { read := plainValue - if IsSpring(file) { - read = springValue + if t, ok := configTypeOf(file); ok { + read = t.value } v, err := read(file, o.Name) if err != nil { return v, err } - return jasyptValue(file, v) + return jasyptValue(file, o.Name, v) } if !ok { var err error @@ -477,8 +478,8 @@ 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 { + if t, ok := configTypeOf(path); ok && f.Sops == "" { + if k, err := t.keyNamed(path, o.Name); err == nil { f.Line = k.line } return f, nil @@ -638,6 +639,7 @@ type lazysecretsFile struct { Exclude []string `yaml:"exclude"` SecretPatterns SecretPatterns `yaml:"secretPatterns"` Spring FileType `yaml:"spring"` + Compose FileType `yaml:"compose"` Sharing struct { // Key is the link to the secret that encrypts the repo's // shared env files (package share). @@ -646,7 +648,7 @@ type lazysecretsFile struct { } func (f lazysecretsFile) types() map[string]FileType { - return map[string]FileType{"spring": f.Spring} + return map[string]FileType{"spring": f.Spring, "compose": f.Compose} } // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any. diff --git a/internal/provider/repo/spring.go b/internal/provider/repo/spring.go index 0f3735a..9dcc231 100644 --- a/internal/provider/repo/spring.go +++ b/internal/provider/repo/spring.go @@ -5,19 +5,14 @@ import ( "errors" "fmt" "io" - "os" "path" "path/filepath" "regexp" - "slices" "strconv" "strings" "unicode" "go.yaml.in/yaml/v3" - - "git.kebcloud.no/keb/lazysecrets/internal/jasypt" - "git.kebcloud.no/keb/lazysecrets/internal/provider" ) // Spring Boot config files: application*.properties/.yml and bootstrap*, @@ -40,51 +35,42 @@ func springFormat(f string) string { 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 +var springType = configType{ + name: "spring", is: IsSpring, format: springFormat, parse: parseSpring, + jasyptSetting: func(string) string { return "jasypt.encryptor.password" }, + placeholder: springPlaceholder, } -// 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], ":${}") -} +// springPlaceholderRe is Spring's ${name} or ${name:default}. +var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`) -func (k springKey) jasypt() bool { - return jasypt.Is(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 +func springPlaceholder(v string) (name, def string, hasDef, ok bool) { + m := springPlaceholderRe.FindStringSubmatch(v) + if m == nil { + return "", "", false, false } - return parseSpring(file, b) + return m[1], m[3], m[2] != "", true } -func parseSpring(file string, b []byte) ([]springKey, error) { +func parseSpring(file string, b []byte) ([]configKey, error) { + var keys []configKey + var err error if springFormat(file) == "properties" { - return parseProperties(b), nil + keys = parseProperties(b) + } else { + keys, err = parseSpringYAML(b) } - return parseSpringYAML(b) + for i := range keys { + keys[i].ref = reference(springPlaceholder, keys[i].value) + } + return keys, err } // 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 +func springDocs(docs [][]configKey) []configKey { + var out []configKey for i, doc := range docs { suffix := "" if i > 0 { @@ -107,13 +93,13 @@ func springDocs(docs [][]springKey) []springKey { // not YAML until it's quoted. var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`) -func parseSpringYAML(b []byte) ([]springKey, error) { +func parseSpringYAML(b []byte) ([]configKey, 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 + var docs [][]configKey for { var doc yaml.Node err := dec.Decode(&doc) @@ -123,7 +109,7 @@ func parseSpringYAML(b []byte) ([]springKey, error) { if err != nil { return nil, err } - var keys []springKey + var keys []configKey if len(doc.Content) > 0 { springWalk(doc.Content[0], "", &keys) } @@ -134,7 +120,7 @@ func parseSpringYAML(b []byte) ([]springKey, error) { // 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) { +func springWalk(n *yaml.Node, name string, out *[]configKey) { switch n.Kind { case yaml.MappingNode: for i := 0; i+1 < len(n.Content); i += 2 { @@ -154,7 +140,7 @@ func springWalk(n *yaml.Node, name string, out *[]springKey) { } case yaml.ScalarNode: if name != "" { - *out = append(*out, springKey{key: name, value: n.Value, line: n.Line}) + *out = append(*out, configKey{key: name, value: n.Value, line: n.Line}) } } } @@ -162,10 +148,10 @@ func springWalk(n *yaml.Node, name string, out *[]springKey) { // 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 { +func parseProperties(b []byte) []configKey { lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n") - var docs [][]springKey - var doc []springKey + var docs [][]configKey + var doc []configKey for i := 0; i < len(lines); i++ { start := i + 1 line := strings.TrimLeftFunc(lines[i], unicode.IsSpace) @@ -181,7 +167,7 @@ func parseProperties(b []byte) []springKey { 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}) + doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start}) } return springDocs(append(docs, doc)) } @@ -248,94 +234,3 @@ func unescapeProperty(s string) string { } return b.String() } - -// springHidden reports whether a Spring file has no secret keys. One that -// can't be read isn't hidden: its error is worth seeing. -func (p *Provider) springHidden(root, file string) bool { - keys, err := readSpring(file) - if err != nil { - return false - } - filter, err := p.filterFor(root, "spring") - if err != nil { - return false - } - return !slices.ContainsFunc(keys, func(k springKey) bool { return filter.secret(k.key, k.value) }) -} - -// springObjects lists the file's keys, those that don't look like secrets -// hidden. -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.filterFor(c.Source.ID, "spring") - if err != nil { - return nil, err - } - format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)} - var out []provider.Object - for _, k := range keys { - state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, "" - switch { - case k.reference(): - state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference" - case k.jasypt(): - state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt" - } - out = append(out, provider.Object{ - Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail, - Hidden: !filter.secret(k.key, k.value), 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.filterFor(c.Source.ID, "spring") - 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.key, k.value) { - secrets++ - plain = plain || k.plain() - } - if k.jasypt() { - jasypt++ - } - } - return []provider.Attr{ - {Label: "encryption", Value: jasyptShare(jasypt, len(keys))}, - {Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, - }, plain -} diff --git a/internal/provider/repo/spring_test.go b/internal/provider/repo/spring_test.go index 70a8412..e3a836f 100644 --- a/internal/provider/repo/spring_test.go +++ b/internal/provider/repo/spring_test.go @@ -30,7 +30,7 @@ func TestIsSpring(t *testing.T) { } } -func springNames(keys []springKey) map[string]string { +func springNames(keys []configKey) map[string]string { out := map[string]string{} for _, k := range keys { out[k.name] = k.value diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 980bf5e..f5a46b7 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -102,13 +102,16 @@ 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 leaf - // for Spring config, 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 whale for compose, 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 switch { case strings.HasPrefix(c.Detail, "spring"): icon, style = iconSet.Spring, sty.Spring + case strings.HasPrefix(c.Detail, "compose"): + icon, style = iconSet.Compose, sty.Compose case !strings.HasPrefix(c.Detail, "sops"): icon, style = iconSet.PlainFile, sty.Warning } diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index bea7dab..b406de1 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -12,6 +12,7 @@ type Set struct { Encrypted string // an encrypted file (SOPS) PlainFile string // a plain text secrets file (.env) Spring string // a Spring Boot config file + Compose string // a Docker Compose file Note string // has a note (lazysecrets list) } @@ -27,6 +28,7 @@ var NerdFont = Set{ Encrypted: "\U000F033E", // md-lock PlainFile: "\U000F0214", // md-file Spring: "\U000F032A", // md-leaf + Compose: "\U000F0868", // md-docker Note: "\U000F09ED", } diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index 03e67f6..55b0f39 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -103,7 +103,7 @@ func (m *Map) Actions() []Action { a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), - a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring config keys that don't look like secrets, and files and repos with only those. Shown, they're dimmed; Go to finds them too."), + a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring and compose keys that don't look like secrets, example files (.env.example), and files and repos with only those. Shown, they're dimmed; Go to finds them too."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8205c43..a95c121 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -35,6 +35,7 @@ type Styles struct { KindDatabricks lipgloss.Style KindRepo lipgloss.Style Spring lipgloss.Style // Spring Boot config files + Compose lipgloss.Style // Docker Compose files // themed marks the selection colours as set by the theme, so // OnBackground leaves them. @@ -72,6 +73,7 @@ func Default() Styles { KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red), KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta), Spring: lipgloss.NewStyle().Foreground(lipgloss.Green), + Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue), } } diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index 3ff56be..bb136ac 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -43,6 +43,7 @@ var Roles = map[string]string{ "databricksColor": "Databricks workspaces and scopes", "repoColor": "repos and their secret files (SOPS)", "springColor": "Spring Boot config files", + "composeColor": "Docker Compose files", } // FromTheme returns the default styles with the theme's roles applied, and @@ -102,6 +103,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) { st.KindRepo = v.fg(lipgloss.NewStyle()) case "springColor": st.Spring = v.fg(lipgloss.NewStyle()) + case "composeColor": + st.Compose = v.fg(lipgloss.NewStyle()) } } return st, fg, nil -- 2.54.0