P1.2-P1.5: Complete Phase 1 core libraries
Four packages built in parallel: - P1.2 runtime: Container runtime abstraction with podman implementation. Interface (Pull/Run/Stop/Remove/Inspect/List), ContainerSpec/ContainerInfo types, CLI arg building, version extraction from image tags. 2 tests. - P1.3 servicedef: TOML service definition file parsing. Load/Write/LoadAll, validation (required fields, unique component names), proto conversion. 5 tests. - P1.4 config: CLI and agent config loading from TOML. Duration type for time fields, env var overrides (MCP_*/MCP_AGENT_*), required field validation, sensible defaults. 7 tests. - P1.5 auth: MCIAS integration. Token validator with 30s SHA-256 cache, gRPC unary interceptor (admin role enforcement, audit logging), Login/LoadToken/SaveToken for CLI. 9 tests. All packages pass build, vet, lint, and test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
internal/config/agent.go
Normal file
186
internal/config/agent.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// AgentConfig is the configuration for the mcp-agent daemon.
|
||||
type AgentConfig struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
MCIAS MCIASConfig `toml:"mcias"`
|
||||
Agent AgentSettings `toml:"agent"`
|
||||
Monitor MonitorConfig `toml:"monitor"`
|
||||
Log LogConfig `toml:"log"`
|
||||
}
|
||||
|
||||
// ServerConfig holds gRPC server listen address and TLS paths.
|
||||
type ServerConfig struct {
|
||||
GRPCAddr string `toml:"grpc_addr"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds the SQLite database path.
|
||||
type DatabaseConfig struct {
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
// AgentSettings holds agent-specific settings.
|
||||
type AgentSettings struct {
|
||||
NodeName string `toml:"node_name"`
|
||||
ContainerRuntime string `toml:"container_runtime"`
|
||||
}
|
||||
|
||||
// MonitorConfig holds monitoring loop parameters.
|
||||
type MonitorConfig struct {
|
||||
Interval Duration `toml:"interval"`
|
||||
AlertCommand []string `toml:"alert_command"`
|
||||
Cooldown Duration `toml:"cooldown"`
|
||||
FlapThreshold int `toml:"flap_threshold"`
|
||||
FlapWindow Duration `toml:"flap_window"`
|
||||
Retention Duration `toml:"retention"`
|
||||
}
|
||||
|
||||
// LogConfig holds logging settings.
|
||||
type LogConfig struct {
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
// Duration wraps time.Duration to support TOML string unmarshaling.
|
||||
// It accepts Go duration strings (e.g. "60s", "15m", "24h") plus a
|
||||
// "d" suffix for days (e.g. "30d" becomes 30*24h).
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler for TOML parsing.
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
s := string(text)
|
||||
if s == "" {
|
||||
d.Duration = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle "d" suffix for days.
|
||||
if len(s) > 1 && s[len(s)-1] == 'd' {
|
||||
var days float64
|
||||
if _, err := fmt.Sscanf(s[:len(s)-1], "%f", &days); err != nil {
|
||||
return fmt.Errorf("parse duration %q: %w", s, err)
|
||||
}
|
||||
d.Duration = time.Duration(days * float64(24*time.Hour))
|
||||
return nil
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse duration %q: %w", s, err)
|
||||
}
|
||||
d.Duration = dur
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalText implements encoding.TextMarshaler for TOML serialization.
|
||||
func (d Duration) MarshalText() ([]byte, error) {
|
||||
return []byte(d.String()), nil
|
||||
}
|
||||
|
||||
// LoadAgentConfig reads and validates an agent configuration file.
|
||||
// Environment variables override file values for select fields.
|
||||
func LoadAgentConfig(path string) (*AgentConfig, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // config path from trusted CLI flag
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg AgentConfig
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyAgentDefaults(&cfg)
|
||||
applyAgentEnvOverrides(&cfg)
|
||||
|
||||
if err := validateAgentConfig(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func applyAgentDefaults(cfg *AgentConfig) {
|
||||
if cfg.Monitor.Interval.Duration == 0 {
|
||||
cfg.Monitor.Interval.Duration = 60 * time.Second
|
||||
}
|
||||
if cfg.Monitor.Cooldown.Duration == 0 {
|
||||
cfg.Monitor.Cooldown.Duration = 15 * time.Minute
|
||||
}
|
||||
if cfg.Monitor.FlapThreshold == 0 {
|
||||
cfg.Monitor.FlapThreshold = 3
|
||||
}
|
||||
if cfg.Monitor.FlapWindow.Duration == 0 {
|
||||
cfg.Monitor.FlapWindow.Duration = 10 * time.Minute
|
||||
}
|
||||
if cfg.Monitor.Retention.Duration == 0 {
|
||||
cfg.Monitor.Retention.Duration = 30 * 24 * time.Hour // 30 days
|
||||
}
|
||||
if cfg.Log.Level == "" {
|
||||
cfg.Log.Level = "info"
|
||||
}
|
||||
if cfg.Agent.ContainerRuntime == "" {
|
||||
cfg.Agent.ContainerRuntime = "podman"
|
||||
}
|
||||
}
|
||||
|
||||
func applyAgentEnvOverrides(cfg *AgentConfig) {
|
||||
if v := os.Getenv("MCP_AGENT_SERVER_GRPC_ADDR"); v != "" {
|
||||
cfg.Server.GRPCAddr = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_SERVER_TLS_CERT"); v != "" {
|
||||
cfg.Server.TLSCert = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_SERVER_TLS_KEY"); v != "" {
|
||||
cfg.Server.TLSKey = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_DATABASE_PATH"); v != "" {
|
||||
cfg.Database.Path = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_NODE_NAME"); v != "" {
|
||||
cfg.Agent.NodeName = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_CONTAINER_RUNTIME"); v != "" {
|
||||
cfg.Agent.ContainerRuntime = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AGENT_LOG_LEVEL"); v != "" {
|
||||
cfg.Log.Level = v
|
||||
}
|
||||
}
|
||||
|
||||
func validateAgentConfig(cfg *AgentConfig) error {
|
||||
if cfg.Server.GRPCAddr == "" {
|
||||
return fmt.Errorf("server.grpc_addr is required")
|
||||
}
|
||||
if cfg.Server.TLSCert == "" {
|
||||
return fmt.Errorf("server.tls_cert is required")
|
||||
}
|
||||
if cfg.Server.TLSKey == "" {
|
||||
return fmt.Errorf("server.tls_key is required")
|
||||
}
|
||||
if cfg.Database.Path == "" {
|
||||
return fmt.Errorf("database.path is required")
|
||||
}
|
||||
if cfg.MCIAS.ServerURL == "" {
|
||||
return fmt.Errorf("mcias.server_url is required")
|
||||
}
|
||||
if cfg.MCIAS.ServiceName == "" {
|
||||
return fmt.Errorf("mcias.service_name is required")
|
||||
}
|
||||
if cfg.Agent.NodeName == "" {
|
||||
return fmt.Errorf("agent.node_name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
internal/config/cli.go
Normal file
97
internal/config/cli.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// CLIConfig is the configuration for the mcp CLI binary.
|
||||
type CLIConfig struct {
|
||||
Services ServicesConfig `toml:"services"`
|
||||
MCIAS MCIASConfig `toml:"mcias"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Nodes []NodeConfig `toml:"nodes"`
|
||||
}
|
||||
|
||||
// ServicesConfig defines where service definition files live.
|
||||
type ServicesConfig struct {
|
||||
Dir string `toml:"dir"`
|
||||
}
|
||||
|
||||
// MCIASConfig holds MCIAS connection settings, shared by CLI and agent.
|
||||
type MCIASConfig struct {
|
||||
ServerURL string `toml:"server_url"`
|
||||
CACert string `toml:"ca_cert"`
|
||||
ServiceName string `toml:"service_name"`
|
||||
}
|
||||
|
||||
// AuthConfig holds authentication settings for the CLI.
|
||||
type AuthConfig struct {
|
||||
TokenPath string `toml:"token_path"`
|
||||
Username string `toml:"username"` // optional, for unattended operation
|
||||
PasswordFile string `toml:"password_file"` // optional, for unattended operation
|
||||
}
|
||||
|
||||
// NodeConfig defines a managed node that the CLI connects to.
|
||||
type NodeConfig struct {
|
||||
Name string `toml:"name"`
|
||||
Address string `toml:"address"`
|
||||
}
|
||||
|
||||
// LoadCLIConfig reads and validates a CLI configuration file.
|
||||
// Environment variables override file values for select fields.
|
||||
func LoadCLIConfig(path string) (*CLIConfig, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // config path from trusted CLI flag
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg CLIConfig
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
|
||||
applyCLIEnvOverrides(&cfg)
|
||||
|
||||
if err := validateCLIConfig(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func applyCLIEnvOverrides(cfg *CLIConfig) {
|
||||
if v := os.Getenv("MCP_SERVICES_DIR"); v != "" {
|
||||
cfg.Services.Dir = v
|
||||
}
|
||||
if v := os.Getenv("MCP_MCIAS_SERVER_URL"); v != "" {
|
||||
cfg.MCIAS.ServerURL = v
|
||||
}
|
||||
if v := os.Getenv("MCP_MCIAS_CA_CERT"); v != "" {
|
||||
cfg.MCIAS.CACert = v
|
||||
}
|
||||
if v := os.Getenv("MCP_MCIAS_SERVICE_NAME"); v != "" {
|
||||
cfg.MCIAS.ServiceName = v
|
||||
}
|
||||
if v := os.Getenv("MCP_AUTH_TOKEN_PATH"); v != "" {
|
||||
cfg.Auth.TokenPath = v
|
||||
}
|
||||
}
|
||||
|
||||
func validateCLIConfig(cfg *CLIConfig) error {
|
||||
if cfg.Services.Dir == "" {
|
||||
return fmt.Errorf("services.dir is required")
|
||||
}
|
||||
if cfg.MCIAS.ServerURL == "" {
|
||||
return fmt.Errorf("mcias.server_url is required")
|
||||
}
|
||||
if cfg.MCIAS.ServiceName == "" {
|
||||
return fmt.Errorf("mcias.service_name is required")
|
||||
}
|
||||
if cfg.Auth.TokenPath == "" {
|
||||
return fmt.Errorf("auth.token_path is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
476
internal/config/config_test.go
Normal file
476
internal/config/config_test.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testCLIConfig = `
|
||||
[services]
|
||||
dir = "/home/kyle/.config/mcp/services"
|
||||
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
ca_cert = "/etc/mcp/ca.pem"
|
||||
service_name = "mcp"
|
||||
|
||||
[auth]
|
||||
token_path = "/home/kyle/.config/mcp/token"
|
||||
username = "kyle"
|
||||
password_file = "/home/kyle/.config/mcp/password"
|
||||
|
||||
[[nodes]]
|
||||
name = "rift"
|
||||
address = "100.95.252.120:9444"
|
||||
|
||||
[[nodes]]
|
||||
name = "cascade"
|
||||
address = "100.95.252.121:9444"
|
||||
`
|
||||
|
||||
const testAgentConfig = `
|
||||
[server]
|
||||
grpc_addr = "100.95.252.120:9444"
|
||||
tls_cert = "/srv/mcp/certs/cert.pem"
|
||||
tls_key = "/srv/mcp/certs/key.pem"
|
||||
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
ca_cert = "/etc/mcp/ca.pem"
|
||||
service_name = "mcp-agent"
|
||||
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
container_runtime = "podman"
|
||||
|
||||
[monitor]
|
||||
interval = "60s"
|
||||
alert_command = ["notify-send", "MCP Alert"]
|
||||
cooldown = "15m"
|
||||
flap_threshold = 3
|
||||
flap_window = "10m"
|
||||
retention = "30d"
|
||||
|
||||
[log]
|
||||
level = "debug"
|
||||
`
|
||||
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadCLIConfig(t *testing.T) {
|
||||
path := writeTempConfig(t, testCLIConfig)
|
||||
|
||||
cfg, err := LoadCLIConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Services.Dir != "/home/kyle/.config/mcp/services" {
|
||||
t.Fatalf("services.dir: got %q", cfg.Services.Dir)
|
||||
}
|
||||
if cfg.MCIAS.ServerURL != "https://mcias.metacircular.net:8443" {
|
||||
t.Fatalf("mcias.server_url: got %q", cfg.MCIAS.ServerURL)
|
||||
}
|
||||
if cfg.MCIAS.CACert != "/etc/mcp/ca.pem" {
|
||||
t.Fatalf("mcias.ca_cert: got %q", cfg.MCIAS.CACert)
|
||||
}
|
||||
if cfg.MCIAS.ServiceName != "mcp" {
|
||||
t.Fatalf("mcias.service_name: got %q", cfg.MCIAS.ServiceName)
|
||||
}
|
||||
if cfg.Auth.TokenPath != "/home/kyle/.config/mcp/token" {
|
||||
t.Fatalf("auth.token_path: got %q", cfg.Auth.TokenPath)
|
||||
}
|
||||
if cfg.Auth.Username != "kyle" {
|
||||
t.Fatalf("auth.username: got %q", cfg.Auth.Username)
|
||||
}
|
||||
if cfg.Auth.PasswordFile != "/home/kyle/.config/mcp/password" {
|
||||
t.Fatalf("auth.password_file: got %q", cfg.Auth.PasswordFile)
|
||||
}
|
||||
if len(cfg.Nodes) != 2 {
|
||||
t.Fatalf("nodes: got %d, want 2", len(cfg.Nodes))
|
||||
}
|
||||
if cfg.Nodes[0].Name != "rift" || cfg.Nodes[0].Address != "100.95.252.120:9444" {
|
||||
t.Fatalf("nodes[0]: got %+v", cfg.Nodes[0])
|
||||
}
|
||||
if cfg.Nodes[1].Name != "cascade" || cfg.Nodes[1].Address != "100.95.252.121:9444" {
|
||||
t.Fatalf("nodes[1]: got %+v", cfg.Nodes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentConfig(t *testing.T) {
|
||||
path := writeTempConfig(t, testAgentConfig)
|
||||
|
||||
cfg, err := LoadAgentConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.GRPCAddr != "100.95.252.120:9444" {
|
||||
t.Fatalf("server.grpc_addr: got %q", cfg.Server.GRPCAddr)
|
||||
}
|
||||
if cfg.Server.TLSCert != "/srv/mcp/certs/cert.pem" {
|
||||
t.Fatalf("server.tls_cert: got %q", cfg.Server.TLSCert)
|
||||
}
|
||||
if cfg.Server.TLSKey != "/srv/mcp/certs/key.pem" {
|
||||
t.Fatalf("server.tls_key: got %q", cfg.Server.TLSKey)
|
||||
}
|
||||
if cfg.Database.Path != "/srv/mcp/mcp.db" {
|
||||
t.Fatalf("database.path: got %q", cfg.Database.Path)
|
||||
}
|
||||
if cfg.MCIAS.ServerURL != "https://mcias.metacircular.net:8443" {
|
||||
t.Fatalf("mcias.server_url: got %q", cfg.MCIAS.ServerURL)
|
||||
}
|
||||
if cfg.MCIAS.ServiceName != "mcp-agent" {
|
||||
t.Fatalf("mcias.service_name: got %q", cfg.MCIAS.ServiceName)
|
||||
}
|
||||
if cfg.Agent.NodeName != "rift" {
|
||||
t.Fatalf("agent.node_name: got %q", cfg.Agent.NodeName)
|
||||
}
|
||||
if cfg.Agent.ContainerRuntime != "podman" {
|
||||
t.Fatalf("agent.container_runtime: got %q", cfg.Agent.ContainerRuntime)
|
||||
}
|
||||
if cfg.Monitor.Interval.Duration != 60*time.Second {
|
||||
t.Fatalf("monitor.interval: got %v", cfg.Monitor.Interval.Duration)
|
||||
}
|
||||
if len(cfg.Monitor.AlertCommand) != 2 || cfg.Monitor.AlertCommand[0] != "notify-send" {
|
||||
t.Fatalf("monitor.alert_command: got %v", cfg.Monitor.AlertCommand)
|
||||
}
|
||||
if cfg.Monitor.Cooldown.Duration != 15*time.Minute {
|
||||
t.Fatalf("monitor.cooldown: got %v", cfg.Monitor.Cooldown.Duration)
|
||||
}
|
||||
if cfg.Monitor.FlapThreshold != 3 {
|
||||
t.Fatalf("monitor.flap_threshold: got %d", cfg.Monitor.FlapThreshold)
|
||||
}
|
||||
if cfg.Monitor.FlapWindow.Duration != 10*time.Minute {
|
||||
t.Fatalf("monitor.flap_window: got %v", cfg.Monitor.FlapWindow.Duration)
|
||||
}
|
||||
if cfg.Monitor.Retention.Duration != 30*24*time.Hour {
|
||||
t.Fatalf("monitor.retention: got %v", cfg.Monitor.Retention.Duration)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Fatalf("log.level: got %q", cfg.Log.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing services.dir",
|
||||
config: `
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp"
|
||||
[auth]
|
||||
token_path = "/tmp/token"
|
||||
`,
|
||||
errMsg: "services.dir is required",
|
||||
},
|
||||
{
|
||||
name: "missing mcias.server_url",
|
||||
config: `
|
||||
[services]
|
||||
dir = "/tmp/services"
|
||||
[mcias]
|
||||
service_name = "mcp"
|
||||
[auth]
|
||||
token_path = "/tmp/token"
|
||||
`,
|
||||
errMsg: "mcias.server_url is required",
|
||||
},
|
||||
{
|
||||
name: "missing mcias.service_name",
|
||||
config: `
|
||||
[services]
|
||||
dir = "/tmp/services"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
[auth]
|
||||
token_path = "/tmp/token"
|
||||
`,
|
||||
errMsg: "mcias.service_name is required",
|
||||
},
|
||||
{
|
||||
name: "missing auth.token_path",
|
||||
config: `
|
||||
[services]
|
||||
dir = "/tmp/services"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp"
|
||||
`,
|
||||
errMsg: "auth.token_path is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempConfig(t, tt.config)
|
||||
_, err := LoadCLIConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, tt.errMsg) {
|
||||
t.Fatalf("error %q does not contain %q", got, tt.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfigValidation(t *testing.T) {
|
||||
// Minimal valid agent config to start from.
|
||||
base := `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing server.grpc_addr",
|
||||
config: `
|
||||
[server]
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
`,
|
||||
errMsg: "server.grpc_addr is required",
|
||||
},
|
||||
{
|
||||
name: "missing server.tls_cert",
|
||||
config: `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
`,
|
||||
errMsg: "server.tls_cert is required",
|
||||
},
|
||||
{
|
||||
name: "missing database.path",
|
||||
config: `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
`,
|
||||
errMsg: "database.path is required",
|
||||
},
|
||||
{
|
||||
name: "missing agent.node_name",
|
||||
config: `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
`,
|
||||
errMsg: "agent.node_name is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeTempConfig(t, tt.config)
|
||||
_, err := LoadAgentConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, tt.errMsg) {
|
||||
t.Fatalf("error %q does not contain %q", got, tt.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify base config actually loads fine.
|
||||
t.Run("valid base config", func(t *testing.T) {
|
||||
path := writeTempConfig(t, base)
|
||||
if _, err := LoadAgentConfig(path); err != nil {
|
||||
t.Fatalf("base config should be valid: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentConfigDefaults(t *testing.T) {
|
||||
// Only required fields, no monitor/log/runtime settings.
|
||||
minimal := `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
`
|
||||
|
||||
path := writeTempConfig(t, minimal)
|
||||
cfg, err := LoadAgentConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Monitor.Interval.Duration != 60*time.Second {
|
||||
t.Fatalf("default interval: got %v, want 60s", cfg.Monitor.Interval.Duration)
|
||||
}
|
||||
if cfg.Monitor.Cooldown.Duration != 15*time.Minute {
|
||||
t.Fatalf("default cooldown: got %v, want 15m", cfg.Monitor.Cooldown.Duration)
|
||||
}
|
||||
if cfg.Monitor.FlapThreshold != 3 {
|
||||
t.Fatalf("default flap_threshold: got %d, want 3", cfg.Monitor.FlapThreshold)
|
||||
}
|
||||
if cfg.Monitor.FlapWindow.Duration != 10*time.Minute {
|
||||
t.Fatalf("default flap_window: got %v, want 10m", cfg.Monitor.FlapWindow.Duration)
|
||||
}
|
||||
if cfg.Monitor.Retention.Duration != 30*24*time.Hour {
|
||||
t.Fatalf("default retention: got %v, want 720h", cfg.Monitor.Retention.Duration)
|
||||
}
|
||||
if cfg.Log.Level != "info" {
|
||||
t.Fatalf("default log level: got %q, want info", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Agent.ContainerRuntime != "podman" {
|
||||
t.Fatalf("default container_runtime: got %q, want podman", cfg.Agent.ContainerRuntime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarOverrides(t *testing.T) {
|
||||
// Test agent env var override.
|
||||
minimal := `
|
||||
[server]
|
||||
grpc_addr = "0.0.0.0:9444"
|
||||
tls_cert = "/srv/mcp/cert.pem"
|
||||
tls_key = "/srv/mcp/key.pem"
|
||||
[database]
|
||||
path = "/srv/mcp/mcp.db"
|
||||
[mcias]
|
||||
server_url = "https://mcias.metacircular.net:8443"
|
||||
service_name = "mcp-agent"
|
||||
[agent]
|
||||
node_name = "rift"
|
||||
[log]
|
||||
level = "info"
|
||||
`
|
||||
t.Run("agent log level override", func(t *testing.T) {
|
||||
t.Setenv("MCP_AGENT_LOG_LEVEL", "debug")
|
||||
path := writeTempConfig(t, minimal)
|
||||
cfg, err := LoadAgentConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Log.Level != "debug" {
|
||||
t.Fatalf("log level: got %q, want debug", cfg.Log.Level)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent node name override", func(t *testing.T) {
|
||||
t.Setenv("MCP_AGENT_NODE_NAME", "override-node")
|
||||
path := writeTempConfig(t, minimal)
|
||||
cfg, err := LoadAgentConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Agent.NodeName != "override-node" {
|
||||
t.Fatalf("node_name: got %q, want override-node", cfg.Agent.NodeName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI services dir override", func(t *testing.T) {
|
||||
t.Setenv("MCP_SERVICES_DIR", "/override/services")
|
||||
path := writeTempConfig(t, testCLIConfig)
|
||||
cfg, err := LoadCLIConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Services.Dir != "/override/services" {
|
||||
t.Fatalf("services.dir: got %q, want /override/services", cfg.Services.Dir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDurationParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
fail bool
|
||||
}{
|
||||
{input: "60s", want: 60 * time.Second},
|
||||
{input: "15m", want: 15 * time.Minute},
|
||||
{input: "24h", want: 24 * time.Hour},
|
||||
{input: "30d", want: 30 * 24 * time.Hour},
|
||||
{input: "1d", want: 24 * time.Hour},
|
||||
{input: "", want: 0},
|
||||
{input: "bogus", fail: true},
|
||||
{input: "notanumber-d", fail: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
var d Duration
|
||||
err := d.UnmarshalText([]byte(tt.input))
|
||||
if tt.fail {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tt.input, err)
|
||||
}
|
||||
if d.Duration != tt.want {
|
||||
t.Fatalf("for %q: got %v, want %v", tt.input, d.Duration, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user