Add file-backed environment secrets
This commit is contained in:
@@ -18,7 +18,7 @@ Precedence is:
|
|||||||
|
|
||||||
The implemented configuration overrides are `--units` and `--tz`. Output flags
|
The implemented configuration overrides are `--units` and `--tz`. Output flags
|
||||||
control report copies for the current command but do not change configuration
|
control report copies for the current command but do not change configuration
|
||||||
files. Environment-variable configuration is not implemented.
|
files. Environment variables do not override configuration fields.
|
||||||
|
|
||||||
## Minimal Config
|
## Minimal Config
|
||||||
|
|
||||||
@@ -64,6 +64,18 @@ multiple configured forecast locations.
|
|||||||
The prompt-facing location object also includes `timezone`, derived from the
|
The prompt-facing location object also includes `timezone`, derived from the
|
||||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||||
|
|
||||||
|
### `secrets`
|
||||||
|
|
||||||
|
- `directory`: optional directory of file-backed environment secrets. Default:
|
||||||
|
empty, which disables secret loading.
|
||||||
|
|
||||||
|
When configured, each regular file directly under `secrets.directory` is loaded
|
||||||
|
after config file parsing and CLI overrides. The file basename must be a valid
|
||||||
|
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
|
||||||
|
become the environment variable value and overwrite any existing value. One
|
||||||
|
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
|
||||||
|
missing directories, and unreadable files fail config loading.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||||
@@ -115,8 +127,9 @@ snapshot exists and a threshold is crossed.
|
|||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Configuration files should not contain secrets. The current Weather API and
|
Configuration files should not contain raw secrets. Use `secrets.directory` to
|
||||||
Scriptorium integration settings do not require secret fields.
|
load secret values from files into environment variables for integrations that
|
||||||
|
read credentials from the environment.
|
||||||
|
|
||||||
## Maintained Examples
|
## Maintained Examples
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ location:
|
|||||||
name: Brentwood
|
name: Brentwood
|
||||||
region: St. Louis Metro
|
region: St. Louis Metro
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
directory: ""
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
Location LocationConfig `yaml:"location"`
|
Location LocationConfig `yaml:"location"`
|
||||||
|
Secrets SecretsConfig `yaml:"secrets"`
|
||||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
@@ -37,6 +38,10 @@ type LocationConfig struct {
|
|||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretsConfig struct {
|
||||||
|
Directory string `yaml:"directory"`
|
||||||
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
Default MissingSourcePolicy `yaml:"default"`
|
Default MissingSourcePolicy `yaml:"default"`
|
||||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||||
}
|
}
|
||||||
|
if cfg.Secrets.Directory != "" {
|
||||||
|
t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory)
|
||||||
|
}
|
||||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||||
}
|
}
|
||||||
@@ -112,3 +115,167 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
|||||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
|
||||||
|
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
|
||||||
|
t.Fatalf("environment value = %q, want original", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileLoadsSecretsDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretsDir := filepath.Join(dir, "secrets")
|
||||||
|
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create secrets directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||||
|
if _, err := LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||||
|
t.Fatalf("environment value = %q, want from-file", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "existing")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||||
|
t.Fatalf("environment value = %q, want from-file", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "LF", input: "value\n", want: "value"},
|
||||||
|
{name: "CRLF", input: "value\r\n", want: "value"},
|
||||||
|
{name: "TwoLF", input: "value\n\n", want: "value\n"},
|
||||||
|
{name: "LoneCR", input: "value\r", want: "value\r"},
|
||||||
|
{name: "NoNewline", input: "value", want: "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
|
||||||
|
t.Fatalf("environment value = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T, dir string)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "InvalidFilename",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write invalid secret: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "invalid environment variable name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdirectory",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil {
|
||||||
|
t.Fatalf("create subdirectory: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "not a directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Symlink",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
target := filepath.Join(dir, "TARGET")
|
||||||
|
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
||||||
|
t.Fatalf("create symlink: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "not a symlink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unreadable",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
path := filepath.Join(dir, "UNREADABLE")
|
||||||
|
if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write unreadable secret: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o000); err != nil {
|
||||||
|
t.Fatalf("chmod unreadable secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chmod(path, 0o600)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
wantErr: "read secret file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tt.setup(t, dir)
|
||||||
|
|
||||||
|
err := loadSecrets(SecretsConfig{Directory: dir})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("loadSecrets() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-value") {
|
||||||
|
t.Fatalf("error = %q, want no secret value", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
|
||||||
|
err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("loadSecrets() error = nil, want missing directory error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read secrets directory") {
|
||||||
|
t.Fatalf("error = %q, want read secrets directory context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ func Defaults() Config {
|
|||||||
Name: "Brentwood",
|
Name: "Brentwood",
|
||||||
Region: "St. Louis Metro",
|
Region: "St. Louis Metro",
|
||||||
},
|
},
|
||||||
|
Secrets: SecretsConfig{
|
||||||
|
Directory: "",
|
||||||
|
},
|
||||||
MissingSource: MissingSourceConfig{
|
MissingSource: MissingSourceConfig{
|
||||||
Default: MissingSourceWarn,
|
Default: MissingSourceWarn,
|
||||||
Sources: map[string]MissingSourcePolicy{},
|
Sources: map[string]MissingSourcePolicy{},
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func Load(opts LoadOptions) (Config, error) {
|
|||||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := loadSecrets(cfg.Secrets); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
63
internal/config/secrets.go
Normal file
63
internal/config/secrets.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
func loadSecrets(cfg SecretsConfig) error {
|
||||||
|
if cfg.Directory == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(cfg.Directory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory)
|
||||||
|
}
|
||||||
|
if !secretNamePattern.MatchString(name) {
|
||||||
|
return fmt.Errorf("secret file %q has invalid environment variable name", name)
|
||||||
|
}
|
||||||
|
if entry.Type()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file, not a symlink", name)
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file, not a directory", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(cfg.Directory, name)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
value := string(data)
|
||||||
|
if strings.HasSuffix(value, "\r\n") {
|
||||||
|
value = strings.TrimSuffix(value, "\r\n")
|
||||||
|
} else {
|
||||||
|
value = strings.TrimSuffix(value, "\n")
|
||||||
|
}
|
||||||
|
if err := os.Setenv(name, value); err != nil {
|
||||||
|
return fmt.Errorf("set environment variable from secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user