diff --git a/internal/config/config.go b/internal/config/config.go index 52dad42..0a1ef44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,39 +1,145 @@ package config import ( + "fmt" "os" + "strconv" "strings" ) +// Environment represents the deployment environment. +type Environment string + +const ( + EnvDev Environment = "dev" + EnvStaging Environment = "staging" + EnvProd Environment = "prod" +) + // TemporalConfig holds Temporal cluster configuration. type TemporalConfig struct { - HostPort string // default: 127.0.0.1:7233 - Namespace string // default: production - TLSCert string // env: TEMPORAL_TLS_CERT (file path) - TLSKey string // env: TEMPORAL_TLS_KEY (file path) + HostPort string // env: TEMPORAL_HOSTPORT + Namespace string // env: TEMPORAL_NAMESPACE + TLSCert string // env: TEMPORAL_TLS_CERT (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 { - Temporal TemporalConfig - AnthropicAPIKey string + Env Environment + Temporal TemporalConfig + 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) { cfg := AppConfig{ + Env: parseEnv(getEnvOrDefault("APP_ENV", "dev")), Temporal: TemporalConfig{ - HostPort: addDefaultPort(getEnvOrDefault("TEMPORAL_HOSTPORT", "127.0.0.1:7233")), - Namespace: getEnvOrDefault("TEMPORAL_NAMESPACE", "poimen-harness"), - TLSCert: os.Getenv("TEMPORAL_TLS_CERT"), - TLSKey: os.Getenv("TEMPORAL_TLS_KEY"), + HostPort: addDefaultPort(getEnvOrDefault("TEMPORAL_HOSTPORT", defaultTemporalHost())), + Namespace: getEnvOrDefault("TEMPORAL_NAMESPACE", "poimen-harness"), + TLSCert: os.Getenv("TEMPORAL_TLS_CERT"), + 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 } +// 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 { if val := os.Getenv(key); val != "" { return val @@ -41,8 +147,16 @@ func getEnvOrDefault(key, defaultVal string) string { 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 { - // If no port specified, add default port 7233 if !strings.Contains(hostPort, ":") { return hostPort + ":7233" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..cb143a2 --- /dev/null +++ b/internal/config/config_test.go @@ -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)) +} diff --git a/k8s/secrets.enc.yaml b/k8s/secrets.enc.yaml new file mode 100644 index 0000000..4317df9 --- /dev/null +++ b/k8s/secrets.enc.yaml @@ -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