feat: .NET appsettings files in repos (#88)

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #88.
This commit is contained in:
keb authored and Karl-Einar Bergstrøm committed 2026-10-15 16:40:00 +02:00
1 parent 59c1222f4d
commit a7db795cbe
17 files changed
+224 -22

No files matched your search

+5 -3
View File
@@ -154,11 +154,12 @@ type Repos struct {
// a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
// SecretPatterns say which keys of config files are secrets; Spring
// and Compose override them for their files, list by list.
// SecretPatterns say which keys of config files are secrets; Spring,
// Compose and Dotnet override them for their files, list by list.
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
Spring repo.FileType `yaml:"spring"`
Compose repo.FileType `yaml:"compose"`
Dotnet repo.FileType `yaml:"dotnet"`
}
type Azure struct {
@@ -300,7 +301,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"),
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns")); err != nil {
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns"),
c.Providers.Repos.Dotnet.SecretPatterns.Check("providers.repos.dotnet.secretPatterns")); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for i, v := range c.Providers.Azure.Vaults {
+2 -2
View File
@@ -22,14 +22,14 @@ type configType struct {
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.
// key named name; nil when only $JASYPT_ENCRYPTOR_PASSWORD can.
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}
var configTypes = []configType{springType, composeType, dotnetType}
func isConfig(f string) bool {
_, ok := configTypeOf(f)
+74
View File
@@ -0,0 +1,74 @@
package repo
import (
"fmt"
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// .NET config files: appsettings.json and appsettings.<Environment>.json.
// Keys are named as .NET's configuration names them, sections joined by
// colons: ConnectionStrings:Default, Servers:0:Name.
var dotnetType = configType{
name: "dotnet", is: IsDotnet, format: func(string) string { return "json" }, parse: parseDotnet,
placeholder: dotnetPlaceholder,
}
var dotnetName = regexp.MustCompile(`^appsettings(\.[^/]+)?\.json$`)
// IsDotnet reports whether f is named like a .NET appsettings file.
func IsDotnet(f string) bool {
return dotnetName.MatchString(strings.ToLower(path.Base(f)))
}
// dotnetPlaceholder reads an App Service Key Vault reference,
// @Microsoft.KeyVault(SecretUri=…): the secret is in the vault.
func dotnetPlaceholder(v string) (name, def string, hasDef, ok bool) {
if strings.HasPrefix(v, "@Microsoft.KeyVault(") && strings.HasSuffix(v, ")") {
return v, "", false, true
}
return "", "", false, false
}
func parseDotnet(_ string, b []byte) ([]configKey, error) {
var doc yaml.Node // JSON is YAML, and the YAML parser says where each key is
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
var out []configKey
if len(doc.Content) > 0 {
dotnetWalk(doc.Content[0], "", &out)
}
for i := range out {
out[i].name = out[i].key
out[i].ref = reference(dotnetPlaceholder, out[i].value)
}
return out, nil
}
func dotnetWalk(n *yaml.Node, name string, out *[]configKey) {
join := func(child string) string {
if name == "" {
return child
}
return name + ":" + child
}
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
dotnetWalk(n.Content[i+1], join(n.Content[i].Value), out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
dotnetWalk(c, join(fmt.Sprint(i)), out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
}
}
}
+100
View File
@@ -0,0 +1,100 @@
package repo
import (
"context"
"maps"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsDotnet(t *testing.T) {
for f, want := range map[string]bool{
"appsettings.json": true,
"src/Api/appsettings.Production.json": true,
"appsettings.Example.json": true, // listed hidden
"appsettings.json.bak": false,
"settings.json": false,
"package.json": false,
} {
if IsDotnet(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func TestParseDotnet(t *testing.T) {
keys, err := parseDotnet("", []byte("\xef\xbb\xbf"+`{
"ConnectionStrings": { "Default": "Server=db;Password=hunter2" },
"AzureAd": { "ClientId": "abc", "ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad)" },
"Servers": [ { "Name": "a", "ApiKey": "k" } ],
"Logging": { "LogLevel": { "Default": "Information" } }
}`))
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{
"ConnectionStrings:Default": "Server=db;Password=hunter2",
"AzureAd:ClientId": "abc",
"AzureAd:ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad) (ref)",
"Servers:0:Name": "a",
"Servers:0:ApiKey": "k",
"Logging:LogLevel:Default": "Information",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestDotnetFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/Api/appsettings.json": `{
"Logging": { "LogLevel": { "Default": "Information" } },
"ConnectionStrings": { "Default": "Server=db;User Id=app;Password=hunter2" },
"AzureAd": { "ClientSecret": "@Microsoft.KeyVault(VaultName=kv;SecretName=ad)" }
}`,
"src/Api/appsettings.Development.json": `{ "Logging": { "LogLevel": { "Default": "Debug" } } }`,
})
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 != "dotnet · json" || c.Hidden != (c.Name == "src/Api/appsettings.Development.json") {
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
}
}
settings := cs[1]
keys, _ := p.Objects(ctx, settings, provider.TypeSecret)
got := map[string]string{}
for _, k := range shown(keys) {
got[k.Name] = k.Detail
}
// The connection string by its value, the client secret by its name.
if want := map[string]string{"ConnectionStrings:Default": "", "AzureAd:ClientSecret": "reference"}; !maps.Equal(got, want) {
t.Errorf("keys %v, want %v", got, want)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/Api/appsettings.json"}, Name: "ConnectionStrings:Default"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "Server=db;User Id=app;Password=hunter2" {
t.Errorf("value: %q %v", v.Reveal(), err)
}
if f, _ := p.File(settings, &link); f.Line != 3 {
t.Errorf("line %d", f.Line)
}
if d, plain := p.configDetails(dotnetType, settings); !plain || d[1].Value != "1, not matched by the secret patterns" {
t.Errorf("details: %+v %t", d, plain)
}
}
+3
View File
@@ -46,6 +46,9 @@ func jasyptPassword(file, name string) (password, from string, err error) {
}
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
if t, ok := configTypeOf(file); ok {
if t.jasyptSetting == nil {
return "", "", fmt.Errorf("jasypt: no password: set $%s", jasyptEnv)
}
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
}
v, err := read(file, setting)
+3 -2
View File
@@ -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
// config files: Spring Boot's and Docker Compose's (configfile.go).
// config files: Spring Boot's, Docker Compose's and .NET'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
@@ -640,6 +640,7 @@ type lazysecretsFile struct {
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
Spring FileType `yaml:"spring"`
Compose FileType `yaml:"compose"`
Dotnet FileType `yaml:"dotnet"`
Sharing struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
@@ -648,7 +649,7 @@ type lazysecretsFile struct {
}
func (f lazysecretsFile) types() map[string]FileType {
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
return map[string]FileType{"spring": f.Spring, "compose": f.Compose, "dotnet": f.Dotnet}
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
+4 -2
View File
@@ -145,8 +145,8 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
}
// fileIcon is a repo file's icon, in its colour: a lock for an encrypted
// one, a leaf for Spring config, a whale for compose, a file in the
// warning colour for plain text; "" for other vaults, or without icons.
// one, a leaf for Spring config, a whale for compose, .NET's for
// appsettings, a file in the warning colour for plain text; "" for other vaults, or without icons.
func (m Model) fileIcon(sty styles.Styles, c provider.Container) string {
if c.Source.Kind != provider.KindRepo {
return ""
@@ -157,6 +157,8 @@ func (m Model) fileIcon(sty styles.Styles, c provider.Container) string {
icon, style = m.icons.Spring, sty.Spring
case strings.HasPrefix(c.Detail, "compose"):
icon, style = m.icons.Compose, sty.Compose
case strings.HasPrefix(c.Detail, "dotnet"):
icon, style = m.icons.Dotnet, sty.Dotnet
case !strings.HasPrefix(c.Detail, "sops"):
icon, style = m.icons.PlainFile, sty.Warning
}
+2
View File
@@ -13,6 +13,7 @@ type Set struct {
PlainFile string // a plain text secrets file (.env)
Spring string // a Spring Boot config file
Compose string // a Docker Compose file
Dotnet string // a .NET appsettings file
Note string // has a note (lazysecrets list)
}
@@ -29,6 +30,7 @@ var NerdFont = Set{
PlainFile: "\U000F0214", // md-file
Spring: "\U000F032A", // md-leaf
Compose: "\U000F0868", // md-docker
Dotnet: "\U000F0AAE", // md-dot_net
Note: "\U000F09ED",
}
+1 -1
View File
@@ -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 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, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: config file keys (Spring, compose, .NET) 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."),
+2
View File
@@ -36,6 +36,7 @@ type Styles struct {
KindRepo lipgloss.Style
Spring lipgloss.Style // Spring Boot config files
Compose lipgloss.Style // Docker Compose files
Dotnet lipgloss.Style // .NET appsettings files
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
@@ -74,6 +75,7 @@ func Default() Styles {
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue),
Dotnet: lipgloss.NewStyle().Foreground(lipgloss.BrightMagenta),
}
}
+3
View File
@@ -44,6 +44,7 @@ var Roles = map[string]string{
"repoColor": "repos and their secret files (SOPS)",
"springColor": "Spring Boot config files",
"composeColor": "Docker Compose files",
"dotnetColor": ".NET appsettings files",
}
// FromTheme returns the default styles with the theme's roles applied, and
@@ -105,6 +106,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.Spring = v.fg(lipgloss.NewStyle())
case "composeColor":
st.Compose = v.fg(lipgloss.NewStyle())
case "dotnetColor":
st.Dotnet = v.fg(lipgloss.NewStyle())
}
}
return st, fg, nil