Files
kandev/apps/backend/internal/common/config/source.go
T
2026-08-21 19:28:21 +01:00

509 lines
16 KiB
Go

package config
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/kandev/kandev/internal/profiles"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
// InternalConfigFileEnv pins a managed child process to the configuration
// file selected by its launcher. It is process wiring, not an operator-facing
// configuration variable, and is intentionally excluded from the catalog.
const (
InternalConfigFileEnv = "KANDEV_INTERNAL_CONFIG_FILE"
InternalConfigHomeFileEnv = "KANDEV_INTERNAL_CONFIG_HOME_FILE"
windowsOS = "windows"
)
// ConfigSource records configuration provenance without retaining any decoded
// secret values.
type ConfigSource struct {
FilePath string
HomeFile bool
Values map[string]SettingSource
Warnings []string
}
// Value returns the source for key. Unknown keys are treated as built-in
// defaults so callers can safely use the result in source-reporting APIs.
func (s ConfigSource) Value(key string) SettingSource {
if source, ok := s.Values[key]; ok {
return source
}
return SourceDefault
}
// SourceFor returns the source for a canonical YAML key.
func (c *Config) SourceFor(key string) SettingSource {
if c == nil {
return SourceDefault
}
return c.Source.Value(key)
}
type configCandidate struct {
path string
isHome bool
}
type configSelection struct {
configCandidate
}
// yamlOnlyStartupKeys are parsed from config.yaml and resolved from the
// compatible environment aliases below. They intentionally do not use Viper's
// AutomaticEnv during Unmarshal: legacy invalid environment values must retain
// their fallback behavior instead of turning into typed-config startup errors.
var yamlOnlyStartupKeys = map[string]struct{}{
"server.trustedProxies": {},
"tasks.preparationTimeout": {},
"credentials.file": {},
"limits.ghMaxConcurrent": {},
"limits.gitMaxConcurrent": {},
"limits.lspMaxConnections": {},
"messageQueue.maxPerSession": {},
"agentctl.idleTimeout": {},
"agentctl.idleReaperInterval": {},
"agentctl.notificationQueueCapacity": {},
"planning.coalesceWindowMs": {},
"office.schedulerTickMs": {},
"observability.otlpEndpoint": {},
"launcher.webPort": {},
"launcher.healthTimeoutMs": {},
"launcher.noBrowser": {},
}
func isYAMLOnlyStartupKey(key string) bool {
_, ok := yamlOnlyStartupKeys[key]
return ok
}
func configCandidates(configPath, homeDir string) ([]configCandidate, error) {
if configPath != "" {
path := explicitConfigFile(configPath)
return []configCandidate{{path: path, isHome: isHomeConfigFile(path, homeDir) || os.Getenv(InternalConfigHomeFileEnv) == "1"}}, nil
}
workingDir, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve config working directory: %w", err)
}
homeDir = bootstrapHomeDirFor(homeDir)
return []configCandidate{
{path: filepath.Join(workingDir, "config.yaml")},
{path: filepath.Join(homeDir, "config.yaml"), isHome: true},
{path: "/etc/kandev/config.yaml"},
}, nil
}
func isHomeConfigFile(path, homeDir string) bool {
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absHome, err := filepath.Abs(bootstrapHomeDirFor(homeDir))
if err != nil {
return false
}
return filepath.Clean(absPath) == filepath.Join(filepath.Clean(absHome), "config.yaml")
}
func explicitConfigFile(path string) string {
info, err := os.Stat(path)
if err == nil && info.IsDir() {
return filepath.Join(path, "config.yaml")
}
if filepath.Base(path) == "config.yaml" || strings.HasSuffix(strings.ToLower(path), ".yaml") {
return path
}
return filepath.Join(path, "config.yaml")
}
func bootstrapHomeDir() string {
if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" {
return expandTilde(raw)
}
home, err := os.UserHomeDir()
if err != nil {
return kandevHomeSubdir
}
return filepath.Join(home, kandevHomeSubdir)
}
func bootstrapHomeDirFor(homeDir string) string {
if raw := strings.TrimSpace(homeDir); raw != "" {
return expandTilde(raw)
}
return bootstrapHomeDir()
}
func selectConfigFile(configPath, homeDir string) (configSelection, bool, error) {
candidates, err := configCandidates(configPath, homeDir)
if err != nil {
return configSelection{}, false, err
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
// The candidate exists from the operator's point of view, but
// cannot be inspected. Select it so the read error names it
// instead of silently falling through to a lower-priority file.
return configSelection{configCandidate: candidate}, true, nil
}
return configSelection{configCandidate: candidate}, true, nil
}
return configSelection{}, false, nil
}
func readSelectedConfig(v *viper.Viper, selection configSelection) error {
v.SetConfigFile(selection.path)
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("read configuration file %q: %w", selection.path, err)
}
if selection.isHome && v.InConfig("homeDir") {
return fmt.Errorf("configuration file %q cannot set homeDir because a home configuration file cannot relocate itself", selection.path)
}
return nil
}
func bindCatalogEnvironment(v *viper.Viper) {
for _, entry := range startupCatalog {
if isYAMLOnlyStartupKey(entry.Key) || len(entry.EnvVars) == 0 {
continue
}
args := append([]string{entry.Key}, entry.EnvVars...)
_ = v.BindEnv(args...)
}
}
func setProfileDefaults(v *viper.Viper, profileDefaults map[string]string) {
for _, entry := range startupCatalog {
if isYAMLOnlyStartupKey(entry.Key) {
continue
}
for _, envVar := range entry.EnvVars {
if value := profileDefaults[envVar]; value != "" {
v.SetDefault(entry.Key, value)
break
}
}
}
}
func environmentSnapshot() map[string]string {
out := make(map[string]string)
for _, entry := range startupCatalog {
for _, envVar := range entry.EnvVars {
if value, ok := os.LookupEnv(envVar); ok {
out[envVar] = value
}
}
}
return out
}
func nonEmptyEnv(snapshot map[string]string, names ...string) (string, bool) {
for _, name := range names {
if value, ok := snapshot[name]; ok && strings.TrimSpace(value) != "" {
return value, true
}
}
return "", false
}
func envValue(snapshot map[string]string, names ...string) (string, bool) {
for _, name := range names {
if value, ok := snapshot[name]; ok {
return value, true
}
}
return "", false
}
func applyStartupDefaultsAndEnvironment(
cfg *Config,
yamlKeys map[string]bool,
profileDefaults map[string]string,
envSnapshot map[string]string,
) map[string]SettingSource {
sources := make(map[string]SettingSource)
for _, entry := range startupCatalog {
sources[entry.Key] = sourceForEntry(entry, yamlKeys, profileDefaults, envSnapshot)
}
applyStartupDefaults(cfg, yamlKeys, profileDefaults, sources)
applyStartupEnvironment(cfg, envSnapshot, sources)
return sources
}
func applyStartupDefaults(cfg *Config, yamlKeys map[string]bool, profileDefaults map[string]string, sources map[string]SettingSource) {
setDefaultDuration := func(key string, target *time.Duration, builtin time.Duration) {
if yamlKeys[key] {
return
}
*target = builtin
if entry, ok := CatalogEntryForKey(key); ok {
if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
if parsed, err := time.ParseDuration(raw); err == nil {
*target = parsed
sources[key] = SourceProfile
}
}
}
}
setDefaultInt := func(key string, target *int, builtin int) {
if yamlKeys[key] {
return
}
*target = builtin
if entry, ok := CatalogEntryForKey(key); ok {
if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
*target = parsed
sources[key] = SourceProfile
}
}
}
}
if !yamlKeys["server.trustedProxies"] {
cfg.Server.TrustedProxies = nil
}
setDefaultDuration("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute)
if !yamlKeys["credentials.file"] {
cfg.Credentials.File = ""
}
setDefaultInt("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8)
setDefaultInt("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12)
setDefaultInt("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8)
setDefaultInt("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10)
setDefaultDuration("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour)
setDefaultDuration("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute)
setDefaultInt("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072)
setDefaultInt("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000)
setDefaultInt("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000)
if !yamlKeys["observability.otlpEndpoint"] {
cfg.Observability.OTLPEndpoint = ""
}
setDefaultInt("launcher.webPort", &cfg.Launcher.WebPort, 0)
setDefaultInt("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault())
if !yamlKeys["launcher.noBrowser"] {
cfg.Launcher.NoBrowser = false
}
}
func applyStartupEnvironment(cfg *Config, envSnapshot map[string]string, sources map[string]SettingSource) {
applyTrustedProxiesEnv(cfg, envSnapshot, sources)
applyDurationEnv("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute, envSnapshot, sources)
applyStringEnvAllowEmpty("credentials.file", &cfg.Credentials.File, envSnapshot, sources)
applyPositiveIntEnv("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8, envSnapshot, sources)
applyPositiveIntEnv("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12, envSnapshot, sources)
applyPositiveIntEnv("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8, envSnapshot, sources)
applyNonNegativeIntEnv("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10, envSnapshot, sources)
applyNonNegativeDurationEnv("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour, envSnapshot, sources)
applyDurationEnv("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute, envSnapshot, sources)
applyBoundedIntEnv("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072, 1024, 131072, envSnapshot, sources)
applyNonNegativeIntEnv("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000, envSnapshot, sources)
applyPositiveIntEnv("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000, envSnapshot, sources)
applyStringEnvAllowEmpty("observability.otlpEndpoint", &cfg.Observability.OTLPEndpoint, envSnapshot, sources)
applyBoundedIntEnv("launcher.webPort", &cfg.Launcher.WebPort, 0, 1, 65535, envSnapshot, sources)
applyPositiveIntEnv("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault(), envSnapshot, sources)
applyBoolEnv("launcher.noBrowser", &cfg.Launcher.NoBrowser, false, envSnapshot, sources)
}
func launcherHealthTimeoutDefault() int {
if profiles.DetectEnvironment() == profiles.EnvDev || profiles.DetectEnvironment() == profiles.EnvE2E {
return 600000
}
return 45000
}
func sourceForEntry(entry CatalogEntry, yamlKeys map[string]bool, profileDefaults, envSnapshot map[string]string) SettingSource {
if _, found := nonEmptyEnv(envSnapshot, entry.EnvVars...); found {
return SourceEnvironment
}
if yamlKeys[entry.Key] {
return SourceConfiguration
}
if _, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found {
return SourceProfile
}
return SourceDefault
}
func applyTrustedProxiesEnv(cfg *Config, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey("server.trustedProxies")
if !ok {
return
}
raw, found := nonEmptyEnv(env, entry.EnvVars...)
if !found {
return
}
parts := strings.Split(raw, ",")
cfg.Server.TrustedProxies = make([]string, 0, len(parts))
for _, part := range parts {
cfg.Server.TrustedProxies = append(cfg.Server.TrustedProxies, strings.TrimSpace(part))
}
sources[entry.Key] = SourceEnvironment
}
func applyDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey(key)
if !ok {
return
}
raw, found := nonEmptyEnv(env, entry.EnvVars...)
if !found {
return
}
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
if err != nil || parsed <= 0 {
*target = fallback
sources[key] = SourceDefault
return
}
*target = parsed
sources[key] = SourceEnvironment
}
func applyNonNegativeDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey(key)
if !ok {
return
}
raw, found := nonEmptyEnv(env, entry.EnvVars...)
if !found {
return
}
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
if err != nil || parsed < 0 {
*target = fallback
sources[key] = SourceDefault
return
}
*target = parsed
sources[key] = SourceEnvironment
}
func applyStringEnvAllowEmpty(key string, target *string, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey(key)
if !ok {
return
}
if raw, found := envValue(env, entry.EnvVars...); found {
*target = strings.TrimSpace(raw)
sources[key] = SourceEnvironment
}
}
func applyPositiveIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) {
applyBoundedIntEnv(key, target, fallback, 1, int(^uint(0)>>1), env, sources)
}
func applyNonNegativeIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) {
applyBoundedIntEnv(key, target, fallback, 0, int(^uint(0)>>1), env, sources)
}
func applyBoundedIntEnv(key string, target *int, fallback, minimum, maximum int, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey(key)
if !ok {
return
}
raw, found := nonEmptyEnv(env, entry.EnvVars...)
if !found {
return
}
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || parsed < minimum || parsed > maximum {
*target = fallback
sources[key] = SourceDefault
return
}
*target = parsed
sources[key] = SourceEnvironment
}
func applyBoolEnv(key string, target *bool, fallback bool, env map[string]string, sources map[string]SettingSource) {
entry, ok := CatalogEntryForKey(key)
if !ok {
return
}
raw, found := nonEmptyEnv(env, entry.EnvVars...)
if !found {
return
}
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
*target = fallback
sources[key] = SourceDefault
return
}
*target = parsed
sources[key] = SourceEnvironment
}
func configKeys(v *viper.Viper) map[string]bool {
keys := make(map[string]bool)
for _, entry := range startupCatalog {
keys[entry.Key] = v.InConfig(entry.Key)
}
return keys
}
func buildConfigSource(selection configSelection, v *viper.Viper, sources map[string]SettingSource, warnings []string) ConfigSource {
if sources == nil {
sources = make(map[string]SettingSource)
}
for _, entry := range startupCatalog {
if _, ok := sources[entry.Key]; !ok {
if v.InConfig(entry.Key) {
sources[entry.Key] = SourceConfiguration
} else {
sources[entry.Key] = SourceDefault
}
}
}
path := ""
if selection.path != "" {
path = selection.path
}
return ConfigSource{FilePath: path, HomeFile: selection.isHome, Values: sources, Warnings: warnings}
}
func inspectSecretPermissions(selection configSelection, v *viper.Viper) []string {
if selection.path == "" || runtime.GOOS == windowsOS {
return nil
}
info, err := os.Stat(selection.path)
if err != nil || info.Mode().Perm()&0o077 == 0 {
return nil
}
for _, entry := range startupCatalog {
if entry.Sensitive && v.InConfig(entry.Key) {
warning := fmt.Sprintf("configuration file %q contains a secret setting readable by group or other users; use mode 0600", selection.path)
log.Print(warning)
return []string{warning}
}
}
return nil
}
func decodeConfig(v *viper.Viper, cfg *Config) error {
return v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
)))
}