feat(phase-1.3): config validation, env-specific loading, SOPS secrets
CI / CI (pull_request) Successful in 4m38s
CI / CI (pull_request) Successful in 4m38s
config.go: - Environment enum (dev/staging/prod) with auto-detect - MemoryServiceConfig + LLMConfig structs - Validate(): TLS pair check, file existence, prod LLM key requirement - In-cluster default host detection via KUBERNETES_SERVICE_HOST - TaskQueue + WorkerCount from env config_test.go: 11 tests - defaults, env override, TLS mismatch, TLS file not found - prod validation (requires LLM key), env parsing - in-cluster detection, port defaults, int parsing k8s/secrets.enc.yaml: SOPS-encrypted Secret - anthropic-api-key, memory-service-jwt, temporal-postgres-password - Age-encrypted, KSOPS-compatible Build: clean, full test suite passes
This commit is contained in:
+122
-8
@@ -1,39 +1,145 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Environment represents the deployment environment.
|
||||||
|
type Environment string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnvDev Environment = "dev"
|
||||||
|
EnvStaging Environment = "staging"
|
||||||
|
EnvProd Environment = "prod"
|
||||||
|
)
|
||||||
|
|
||||||
// TemporalConfig holds Temporal cluster configuration.
|
// TemporalConfig holds Temporal cluster configuration.
|
||||||
type TemporalConfig struct {
|
type TemporalConfig struct {
|
||||||
HostPort string // default: 127.0.0.1:7233
|
HostPort string // env: TEMPORAL_HOSTPORT
|
||||||
Namespace string // default: production
|
Namespace string // env: TEMPORAL_NAMESPACE
|
||||||
TLSCert string // env: TEMPORAL_TLS_CERT (file path)
|
TLSCert string // env: TEMPORAL_TLS_CERT (file path)
|
||||||
TLSKey string // env: TEMPORAL_TLS_KEY (file path)
|
TLSKey string // env: TEMPORAL_TLS_KEY (file path)
|
||||||
|
TaskQueue string // env: TEMPORAL_TASK_QUEUE
|
||||||
|
WorkerCount int // env: TEMPORAL_WORKER_COUNT
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppConfig holds application configuration.
|
// MemoryServiceConfig holds memory service connection settings.
|
||||||
|
type MemoryServiceConfig struct {
|
||||||
|
URL string // env: MEMORY_SERVICE_URL
|
||||||
|
JWTToken string // env: MEMORY_SERVICE_JWT_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMConfig holds LLM provider settings.
|
||||||
|
type LLMConfig struct {
|
||||||
|
BaseURL string // env: LOCAL_LLM_BASE_URL
|
||||||
|
AnthropicKey string // env: ANTHROPIC_API_KEY
|
||||||
|
AuthToken string // env: LLM_AUTH_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppConfig holds all application configuration.
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
|
Env Environment
|
||||||
Temporal TemporalConfig
|
Temporal TemporalConfig
|
||||||
AnthropicAPIKey string
|
MemoryService MemoryServiceConfig
|
||||||
|
LLM LLMConfig
|
||||||
|
LogLevel string // env: LOG_LEVEL
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads application configuration from environment variables.
|
// LoadConfig loads configuration from environment variables with validation.
|
||||||
func LoadConfig() (AppConfig, error) {
|
func LoadConfig() (AppConfig, error) {
|
||||||
cfg := AppConfig{
|
cfg := AppConfig{
|
||||||
|
Env: parseEnv(getEnvOrDefault("APP_ENV", "dev")),
|
||||||
Temporal: TemporalConfig{
|
Temporal: TemporalConfig{
|
||||||
HostPort: addDefaultPort(getEnvOrDefault("TEMPORAL_HOSTPORT", "127.0.0.1:7233")),
|
HostPort: addDefaultPort(getEnvOrDefault("TEMPORAL_HOSTPORT", defaultTemporalHost())),
|
||||||
Namespace: getEnvOrDefault("TEMPORAL_NAMESPACE", "poimen-harness"),
|
Namespace: getEnvOrDefault("TEMPORAL_NAMESPACE", "poimen-harness"),
|
||||||
TLSCert: os.Getenv("TEMPORAL_TLS_CERT"),
|
TLSCert: os.Getenv("TEMPORAL_TLS_CERT"),
|
||||||
TLSKey: os.Getenv("TEMPORAL_TLS_KEY"),
|
TLSKey: os.Getenv("TEMPORAL_TLS_KEY"),
|
||||||
|
TaskQueue: getEnvOrDefault("TEMPORAL_TASK_QUEUE", "poimen-taskqueue"),
|
||||||
|
WorkerCount: getEnvIntOrDefault("TEMPORAL_WORKER_COUNT", 10),
|
||||||
},
|
},
|
||||||
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
MemoryService: MemoryServiceConfig{
|
||||||
|
URL: os.Getenv("MEMORY_SERVICE_URL"),
|
||||||
|
JWTToken: os.Getenv("MEMORY_SERVICE_JWT_TOKEN"),
|
||||||
|
},
|
||||||
|
LLM: LLMConfig{
|
||||||
|
BaseURL: os.Getenv("LOCAL_LLM_BASE_URL"),
|
||||||
|
AnthropicKey: os.Getenv("ANTHROPIC_API_KEY"),
|
||||||
|
AuthToken: os.Getenv("LLM_AUTH_TOKEN"),
|
||||||
|
},
|
||||||
|
LogLevel: getEnvOrDefault("LOG_LEVEL", "info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return AppConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate checks required fields and consistency.
|
||||||
|
func (c *AppConfig) Validate() error {
|
||||||
|
if c.Temporal.HostPort == "" {
|
||||||
|
return fmt.Errorf("TEMPORAL_HOSTPORT is required")
|
||||||
|
}
|
||||||
|
if c.Temporal.Namespace == "" {
|
||||||
|
return fmt.Errorf("TEMPORAL_NAMESPACE is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS: both or neither
|
||||||
|
hasCert := c.Temporal.TLSCert != ""
|
||||||
|
hasKey := c.Temporal.TLSKey != ""
|
||||||
|
if hasCert != hasKey {
|
||||||
|
return fmt.Errorf("TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must both be set or both empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TLS files exist if specified
|
||||||
|
if hasCert {
|
||||||
|
if _, err := os.Stat(c.Temporal.TLSCert); err != nil {
|
||||||
|
return fmt.Errorf("TEMPORAL_TLS_CERT file not found: %s", c.Temporal.TLSCert)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(c.Temporal.TLSKey); err != nil {
|
||||||
|
return fmt.Errorf("TEMPORAL_TLS_KEY file not found: %s", c.Temporal.TLSKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prod requires LLM key
|
||||||
|
if c.Env == EnvProd {
|
||||||
|
if c.LLM.AnthropicKey == "" && c.LLM.AuthToken == "" {
|
||||||
|
return fmt.Errorf("prod requires ANTHROPIC_API_KEY or LLM_AUTH_TOKEN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProd returns true if running in production.
|
||||||
|
func (c *AppConfig) IsProd() bool { return c.Env == EnvProd }
|
||||||
|
|
||||||
|
// IsDevOrStaging returns true if running in dev or staging.
|
||||||
|
func (c *AppConfig) IsDevOrStaging() bool { return c.Env == EnvDev || c.Env == EnvStaging }
|
||||||
|
|
||||||
|
func defaultTemporalHost() string {
|
||||||
|
// In-cluster default vs local
|
||||||
|
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
||||||
|
return "temporal-frontend.temporal.svc.cluster.local:7233"
|
||||||
|
}
|
||||||
|
return "127.0.0.1:7233"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnv(s string) Environment {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "prod", "production":
|
||||||
|
return EnvProd
|
||||||
|
case "staging", "stage":
|
||||||
|
return EnvStaging
|
||||||
|
default:
|
||||||
|
return EnvDev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getEnvOrDefault(key, defaultVal string) string {
|
func getEnvOrDefault(key, defaultVal string) string {
|
||||||
if val := os.Getenv(key); val != "" {
|
if val := os.Getenv(key); val != "" {
|
||||||
return val
|
return val
|
||||||
@@ -41,8 +147,16 @@ func getEnvOrDefault(key, defaultVal string) string {
|
|||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvIntOrDefault(key string, defaultVal int) int {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
if i, err := strconv.Atoi(val); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
func addDefaultPort(hostPort string) string {
|
func addDefaultPort(hostPort string) string {
|
||||||
// If no port specified, add default port 7233
|
|
||||||
if !strings.Contains(hostPort, ":") {
|
if !strings.Contains(hostPort, ":") {
|
||||||
return hostPort + ":7233"
|
return hostPort + ":7233"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clearEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, key := range []string{
|
||||||
|
"APP_ENV", "TEMPORAL_HOSTPORT", "TEMPORAL_NAMESPACE",
|
||||||
|
"TEMPORAL_TLS_CERT", "TEMPORAL_TLS_KEY", "TEMPORAL_TASK_QUEUE",
|
||||||
|
"TEMPORAL_WORKER_COUNT", "MEMORY_SERVICE_URL", "MEMORY_SERVICE_JWT_TOKEN",
|
||||||
|
"LOCAL_LLM_BASE_URL", "ANTHROPIC_API_KEY", "LLM_AUTH_TOKEN",
|
||||||
|
"LOG_LEVEL", "KUBERNETES_SERVICE_HOST",
|
||||||
|
} {
|
||||||
|
os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigDefaults(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, EnvDev, cfg.Env)
|
||||||
|
assert.Equal(t, "127.0.0.1:7233", cfg.Temporal.HostPort)
|
||||||
|
assert.Equal(t, "poimen-harness", cfg.Temporal.Namespace)
|
||||||
|
assert.Equal(t, "poimen-taskqueue", cfg.Temporal.TaskQueue)
|
||||||
|
assert.Equal(t, 10, cfg.Temporal.WorkerCount)
|
||||||
|
assert.Equal(t, "info", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigFromEnv(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("APP_ENV", "staging")
|
||||||
|
os.Setenv("TEMPORAL_HOSTPORT", "temporal:7233")
|
||||||
|
os.Setenv("TEMPORAL_NAMESPACE", "test-ns")
|
||||||
|
os.Setenv("TEMPORAL_TASK_QUEUE", "test-queue")
|
||||||
|
os.Setenv("TEMPORAL_WORKER_COUNT", "5")
|
||||||
|
os.Setenv("MEMORY_SERVICE_URL", "http://memory:8080")
|
||||||
|
os.Setenv("ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
os.Setenv("LOG_LEVEL", "debug")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, EnvStaging, cfg.Env)
|
||||||
|
assert.Equal(t, "temporal:7233", cfg.Temporal.HostPort)
|
||||||
|
assert.Equal(t, "test-ns", cfg.Temporal.Namespace)
|
||||||
|
assert.Equal(t, "test-queue", cfg.Temporal.TaskQueue)
|
||||||
|
assert.Equal(t, 5, cfg.Temporal.WorkerCount)
|
||||||
|
assert.Equal(t, "http://memory:8080", cfg.MemoryService.URL)
|
||||||
|
assert.Equal(t, "sk-test", cfg.LLM.AnthropicKey)
|
||||||
|
assert.Equal(t, "debug", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTLSMismatch(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("TEMPORAL_TLS_CERT", "/tmp/cert.pem")
|
||||||
|
// Missing TLS_KEY
|
||||||
|
|
||||||
|
_, err := LoadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must both be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTLSFileNotFound(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("TEMPORAL_TLS_CERT", "/nonexistent/cert.pem")
|
||||||
|
os.Setenv("TEMPORAL_TLS_KEY", "/nonexistent/key.pem")
|
||||||
|
|
||||||
|
_, err := LoadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProdRequiresLLMKey(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("APP_ENV", "prod")
|
||||||
|
|
||||||
|
_, err := LoadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "prod requires ANTHROPIC_API_KEY or LLM_AUTH_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProdWithAnthropicKey(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("APP_ENV", "prod")
|
||||||
|
os.Setenv("ANTHROPIC_API_KEY", "sk-prod")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.IsProd())
|
||||||
|
assert.False(t, cfg.IsDevOrStaging())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateProdWithAuthToken(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("APP_ENV", "prod")
|
||||||
|
os.Setenv("LLM_AUTH_TOKEN", "token-prod")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.IsProd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEnv(t *testing.T) {
|
||||||
|
assert.Equal(t, EnvDev, parseEnv("dev"))
|
||||||
|
assert.Equal(t, EnvDev, parseEnv("unknown"))
|
||||||
|
assert.Equal(t, EnvStaging, parseEnv("staging"))
|
||||||
|
assert.Equal(t, EnvStaging, parseEnv("stage"))
|
||||||
|
assert.Equal(t, EnvProd, parseEnv("prod"))
|
||||||
|
assert.Equal(t, EnvProd, parseEnv("production"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultTemporalHostInCluster(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
os.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "temporal-frontend.temporal.svc.cluster.local:7233", cfg.Temporal.HostPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddDefaultPort(t *testing.T) {
|
||||||
|
assert.Equal(t, "host:7233", addDefaultPort("host"))
|
||||||
|
assert.Equal(t, "host:9090", addDefaultPort("host:9090"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvIntOrDefault(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
assert.Equal(t, 10, getEnvIntOrDefault("TEMPORAL_WORKER_COUNT", 10))
|
||||||
|
|
||||||
|
os.Setenv("TEMPORAL_WORKER_COUNT", "abc")
|
||||||
|
assert.Equal(t, 10, getEnvIntOrDefault("TEMPORAL_WORKER_COUNT", 10))
|
||||||
|
|
||||||
|
os.Setenv("TEMPORAL_WORKER_COUNT", "20")
|
||||||
|
assert.Equal(t, 20, getEnvIntOrDefault("TEMPORAL_WORKER_COUNT", 10))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: ENC[AES256_GCM,data:9JQ=,iv:ugaPXZZ0mwj9ub3AOBbevh3Eej0ik9IRGh6my37euxk=,tag:4TNha8uQeB9RP5sFWZCEug==,type:str]
|
||||||
|
kind: ENC[AES256_GCM,data:Sb+P4zNR,iv:pwzIwcXjgKfCFPi63E77QE2zaFFuthtMNLNU+CvXoJQ=,tag:xg48gnnKBGWbJWEnTm9T9w==,type:str]
|
||||||
|
metadata:
|
||||||
|
name: ENC[AES256_GCM,data:7T4kCUDf0RaWwitBPaE=,iv:tS7l6FSejcYl7MobbBtVmVn0CBFTCx0BaMkPILJy49s=,tag:huZp3TnSPK6dOtWjmrssSA==,type:str]
|
||||||
|
namespace: ENC[AES256_GCM,data:WWuEZ7Ro,iv:c00ZiQgABdg9Rs0VibYaOSWZ/k2ErDb/dELLjABx8yA=,tag:3ltoqvNuZilxGtdGhNftJg==,type:str]
|
||||||
|
type: ENC[AES256_GCM,data:hYyckkSD,iv:0VXD2fV21xgVKxYeZ8hetgpqLpwz5e9yyrImTiYj6w8=,tag:JJtmFuY4rtv77ZyqwEIsmw==,type:str]
|
||||||
|
stringData:
|
||||||
|
anthropic-api-key: ENC[AES256_GCM,data:1SMZxO2HcLCmXkTVfvl50pPyRqWDKw==,iv:t4AD4rM7th1fcQJcY4SflV1xTMoVjYjq1zmduKlCkjA=,tag:vhAVGG9jq7c5TqOxEx1sKg==,type:str]
|
||||||
|
memory-service-jwt: ENC[AES256_GCM,data:hqW1u5OROqPlEX4DhoMWCzK0Mw==,iv:/SdcHGNm3yTpPFZI648kVKQT4TDVJ2hbc807QLUvlx4=,tag:KTWn/DQ7qE4wdsHR+Giefw==,type:str]
|
||||||
|
temporal-postgres-password: ENC[AES256_GCM,data:WvyP8a28+Q1u7DRPHN9mPdfoVkl4a0nUSYM=,iv:tASr/phQdN/VoG0u6NDClOBhmb9kJvvhrWo+06oNQnQ=,tag:DyAT8E0CiyxiPnnmJ/wsYQ==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUcVR6V3hsL1BaMUJrNVpV
|
||||||
|
cThZdVg5RFNhYjlUZkVoMkQwYURYd2dhWVRJCkFWbm5FVHVKOE9pdlg1TUlXMDl3
|
||||||
|
UlBsODF5eU5PamFXU3BoMzZoTFNSQ2MKLS0tIGtSQm54S3dqSDJzYUVpNTd1bkI1
|
||||||
|
Z2dZZ1FDU0tRN1JvVURSMHNua1U2L1kKjFGbdNJxguRYJe5ral3BsFTbopfkvrQC
|
||||||
|
8DCMLl9GaRlyh2k0jJab7/0iCzcLNfOwZJRZHVXA5EjtC0fQLxRqgA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
|
lastmodified: "2026-09-08T23:32:05Z"
|
||||||
|
mac: ENC[AES256_GCM,data:rKsgwD3eTfMTZWXZxmSNfj8A/yAvfc+uC/7XrWU1yMjUxj4/V9MovvKGGhR8KCjFTuYcu0x9JdTtET5QtuOXo//Ly08mwhfqaOX09Fn09V906O+Sx4e+zCNwQItz6VE+yqTRiSepKzE8DQhFmYFwuY/QMXrP1BLHpvm0kkhnFaU=,iv:h3AtYk0hqoFCj+rTmMbM4+a4WMXdMIgW94ysXZ3eJZ0=,tag:q0hLai5tTGNR7/2xdUHkug==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.2
|
||||||
Reference in New Issue
Block a user