Files
orca/internal/config/config.go
T
Jon Chery cd07e435d9 feat(P02): HCL config file parsing — internal/config package (REQ-054)
New internal/config package: Config struct (HCL tags), Load(paths...),
MergeOverrides(flags, env) with flag>env>file>default precedence (D-039).
No package-level state (AD-023). --config persistent flag on root command;
daemon uses cfg.ListenAddr when flag at default. 11 config tests + 2 cli tests.

---ci---
project: orca
phase: 2
milestone: v0.7
status: verify
requirements:
  covered: [REQ-054]
  partial: []
---/ci---
2026-08-04 00:09:33 +00:00

128 lines
2.9 KiB
Go

package config
import (
"fmt"
"os"
"github.com/hashicorp/hcl/v2/hclsimple"
)
type CapacityConfig struct {
CPU int `hcl:"cpu,optional"`
MemoryMB int `hcl:"memory_mb,optional"`
}
type Config struct {
DBPath string `hcl:"db_path,optional"`
ListenAddr string `hcl:"listen_addr,optional"`
CAPath string `hcl:"ca_path,optional"`
ServerCertPath string `hcl:"server_cert_path,optional"`
ServerKeyPath string `hcl:"server_key_path,optional"`
NodeCapacity *CapacityConfig `hcl:"node_capacity,block"`
}
type Flags struct {
DBPath *string
ListenAddr *string
CAPath *string
ServerCertPath *string
ServerKeyPath *string
CPU *int
MemoryMB *int
}
type Environ map[string]string
func Load(paths ...string) (*Config, error) {
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
continue
}
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", p, err)
}
var cfg Config
if err := hclsimple.Decode(p, data, nil, &cfg); err != nil {
return nil, fmt.Errorf("decode config %s: %w", p, err)
}
return &cfg, nil
}
return &Config{}, nil
}
func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
out := &Config{
DBPath: c.DBPath,
ListenAddr: c.ListenAddr,
CAPath: c.CAPath,
ServerCertPath: c.ServerCertPath,
ServerKeyPath: c.ServerKeyPath,
NodeCapacity: c.NodeCapacity,
}
applyStr := func(flag *string, envKey, fileVal string) string {
if flag != nil {
return *flag
}
if v, ok := env[envKey]; ok && v != "" {
return v
}
return fileVal
}
out.DBPath = applyStr(flags.DBPath, "ORCA_DB", out.DBPath)
out.ListenAddr = applyStr(flags.ListenAddr, "ORCA_LISTEN_ADDR", out.ListenAddr)
out.CAPath = applyStr(flags.CAPath, "ORCA_CA_PATH", out.CAPath)
out.ServerCertPath = applyStr(flags.ServerCertPath, "ORCA_SERVER_CERT_PATH", out.ServerCertPath)
out.ServerKeyPath = applyStr(flags.ServerKeyPath, "ORCA_SERVER_KEY_PATH", out.ServerKeyPath)
if out.NodeCapacity == nil {
out.NodeCapacity = &CapacityConfig{}
} else {
nc := *out.NodeCapacity
out.NodeCapacity = &nc
}
if flags.CPU != nil {
out.NodeCapacity.CPU = *flags.CPU
} else if v, ok := env["ORCA_NODE_CPU"]; ok && v != "" {
if n, err := atoi(v); err == nil {
out.NodeCapacity.CPU = n
}
}
if flags.MemoryMB != nil {
out.NodeCapacity.MemoryMB = *flags.MemoryMB
} else if v, ok := env["ORCA_NODE_MEMORY_MB"]; ok && v != "" {
if n, err := atoi(v); err == nil {
out.NodeCapacity.MemoryMB = n
}
}
return out
}
func atoi(s string) (int, error) {
n := 0
if s == "" {
return 0, fmt.Errorf("empty")
}
neg := false
i := 0
if s[0] == '-' {
neg = true
i = 1
}
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, fmt.Errorf("bad")
}
n = n*10 + int(s[i]-'0')
}
if neg {
n = -n
}
return n, nil
}