feat: Extract Wild Central as standalone Go service

Extract the Central networking functionality from wild-cloud/api into
a standalone service. Wild Central manages DNS (dnsmasq), gateway
(HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot),
security (CrowdSec), and DDNS — all the network appliance concerns.

All Cloud-specific code (instances, clusters, nodes, apps, backups,
operations, kubectl/talosctl tooling) has been removed. The API struct
and route registration contain only Central endpoints. Tests updated
to match the new API signature.

Builds and all tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:31:16 +00:00
commit beb643f76f
52 changed files with 12814 additions and 0 deletions

328
internal/config/config.go Normal file
View File

@@ -0,0 +1,328 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// ExtraPort is a TCP or UDP port that the firewall should allow on the WAN interface.
type ExtraPort struct {
Port int `yaml:"port" json:"port"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` // "tcp" (default) or "udp"
Label string `yaml:"label,omitempty" json:"label,omitempty"`
}
// UnmarshalYAML handles both plain integers (legacy: extraPorts: [22]) and structured entries
// (extraPorts: [{port: 22, protocol: tcp, label: SSH}]).
func (p *ExtraPort) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var port int
if err := value.Decode(&port); err != nil {
return fmt.Errorf("extraPort: expected integer, got %q", value.Value)
}
p.Port = port
return nil
}
type alias ExtraPort
return value.Decode((*alias)(p))
}
// UnmarshalJSON handles both plain integers (legacy: extraPorts: [22]) and structured entries.
func (p *ExtraPort) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] != '{' {
var port int
if err := json.Unmarshal(data, &port); err != nil {
return fmt.Errorf("extraPort: expected integer or object, got: %s", data)
}
p.Port = port
return nil
}
type alias ExtraPort
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*p = ExtraPort(a)
return nil
}
// SplitExtraPorts separates ExtraPorts into TCP and UDP slices.
// Ports with no Protocol or Protocol "tcp" are TCP; Protocol "udp" are UDP.
func SplitExtraPorts(ports []ExtraPort) (tcp []int, udp []int) {
for _, p := range ports {
if strings.EqualFold(p.Protocol, "udp") {
udp = append(udp, p.Port)
} else {
tcp = append(tcp, p.Port)
}
}
return
}
// HAProxyCustomRoute defines a custom TCP proxy rule for non-Wild Cloud services
type HAProxyCustomRoute struct {
Name string `yaml:"name" json:"name"`
Port int `yaml:"port" json:"port"`
Backend string `yaml:"backend" json:"backend"`
}
// DHCPStaticLease defines a static DHCP address assignment
type DHCPStaticLease struct {
MAC string `yaml:"mac" json:"mac"`
IP string `yaml:"ip" json:"ip"`
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
}
// GlobalConfig represents the main configuration structure
type GlobalConfig struct {
Operator struct {
Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"`
Cloud struct {
Central struct {
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io"
} `yaml:"central,omitempty" json:"central,omitempty"`
Router struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
DynamicDns string `yaml:"dynamicDns,omitempty" json:"dynamicDns,omitempty"`
} `yaml:"router,omitempty" json:"router,omitempty"`
Dnsmasq struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
DHCP struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
RangeStart string `yaml:"rangeStart,omitempty" json:"rangeStart,omitempty"`
RangeEnd string `yaml:"rangeEnd,omitempty" json:"rangeEnd,omitempty"`
LeaseTime string `yaml:"leaseTime,omitempty" json:"leaseTime,omitempty"`
Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"`
StaticLeases []DHCPStaticLease `yaml:"staticLeases,omitempty" json:"staticLeases,omitempty"`
} `yaml:"dhcp,omitempty" json:"dhcp,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
HAProxy struct {
CustomRoutes []HAProxyCustomRoute `yaml:"customRoutes,omitempty" json:"customRoutes,omitempty"`
} `yaml:"haproxy,omitempty" json:"haproxy,omitempty"`
Nftables struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
WANInterface string `yaml:"wanInterface,omitempty" json:"wanInterface,omitempty"`
ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"`
} `yaml:"nftables,omitempty" json:"nftables,omitempty"`
DDNS struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
Records []string `yaml:"records,omitempty" json:"records,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
}
// LoadGlobalConfig loads configuration from the specified path
func LoadGlobalConfig(configPath string) (*GlobalConfig, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &GlobalConfig{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
return config, nil
}
// SaveGlobalConfig saves the configuration to the specified path
func SaveGlobalConfig(config *GlobalConfig, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// IsEmpty checks if the configuration is empty or uninitialized
func (c *GlobalConfig) IsEmpty() bool {
if c == nil {
return true
}
// Check if essential fields are empty
return c.Cloud.Router.IP == "" && c.Operator.Email == ""
}
type NodeConfig struct {
Role string `yaml:"role" json:"role"`
Interface string `yaml:"interface" json:"interface"`
Disk string `yaml:"disk" json:"disk"`
CurrentIp string `yaml:"currentIp" json:"currentIp"`
}
type InstanceConfig struct {
Operator struct {
Email string `yaml:"email" json:"email"`
} `yaml:"operator" json:"operator"`
Cloud struct {
BaseDomain string `yaml:"baseDomain" json:"baseDomain"`
Domain string `yaml:"domain" json:"domain"`
InternalDomain string `yaml:"internalDomain" json:"internalDomain"`
DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"`
NFS struct {
Host string `yaml:"host" json:"host"`
MediaPath string `yaml:"mediaPath" json:"mediaPath"`
StorageCapacity string `yaml:"storageCapacity" json:"storageCapacity"`
} `yaml:"nfs" json:"nfs"`
DockerRegistryHost string `yaml:"dockerRegistryHost" json:"dockerRegistryHost"`
} `yaml:"cloud" json:"cloud"`
Cluster struct {
Name string `yaml:"name" json:"name"`
LoadBalancerIp string `yaml:"loadBalancerIp" json:"loadBalancerIp"`
IpAddressPool string `yaml:"ipAddressPool" json:"ipAddressPool"`
HostnamePrefix string `yaml:"hostnamePrefix" json:"hostnamePrefix"`
CertManager struct {
Cloudflare struct {
Domain string `yaml:"domain" json:"domain"`
} `yaml:"cloudflare" json:"cloudflare"`
} `yaml:"certManager" json:"certManager"`
ExternalDns struct {
OwnerId string `yaml:"ownerId" json:"ownerId"`
} `yaml:"externalDns" json:"externalDns"`
InternalDns struct {
ExternalResolver string `yaml:"externalResolver" json:"externalResolver"`
} `yaml:"internalDns" json:"internalDns"`
DockerRegistry struct {
Storage string `yaml:"storage" json:"storage"`
} `yaml:"dockerRegistry" json:"dockerRegistry"`
Nodes struct {
Talos struct {
Version string `yaml:"version" json:"version"`
SchematicId string `yaml:"schematicId" json:"schematicId"`
} `yaml:"talos" json:"talos"`
Control struct {
Vip string `yaml:"vip" json:"vip"`
} `yaml:"control" json:"control"`
Active map[string]NodeConfig `yaml:"active" json:"active"`
} `yaml:"nodes" json:"nodes"`
} `yaml:"cluster" json:"cluster"`
Apps map[string]any `yaml:"apps" json:"apps"`
}
func LoadCloudConfig(configPath string) (*InstanceConfig, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &InstanceConfig{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
return config, nil
}
func SaveCloudConfig(config *InstanceConfig, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// DeepMerge recursively merges src into dst, with src values taking precedence.
// Nested maps are merged recursively; all other types are overwritten by src.
func DeepMerge(dst, src map[string]any) map[string]any {
result := make(map[string]any)
for k, v := range dst {
result[k] = v
}
for k, v := range src {
if srcMap, ok := v.(map[string]any); ok {
if dstMap, ok := result[k].(map[string]any); ok {
result[k] = DeepMerge(dstMap, srcMap)
continue
}
}
result[k] = v
}
return result
}
// LoadMergedInstanceConfig loads the global config as a base and merges the
// instance config on top. Returns the merged result as an untyped map suitable
// for passing to gomplate as template context.
// If the global config is missing, returns the instance config alone.
// Assembles apps.* from config/apps/*/config.yaml files.
func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]any, error) {
instanceConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
globalConfigPath := filepath.Join(dataDir, "config.yaml")
instanceData, err := os.ReadFile(instanceConfigPath)
if err != nil {
return nil, fmt.Errorf("reading instance config %s: %w", instanceConfigPath, err)
}
var instanceMap map[string]any
if err := yaml.Unmarshal(instanceData, &instanceMap); err != nil {
return nil, fmt.Errorf("parsing instance config: %w", err)
}
if instanceMap == nil {
instanceMap = make(map[string]any)
}
// Assemble apps.* map from per-app config files
appsConfigDir := filepath.Join(dataDir, "instances", instanceName, "config", "apps")
if entries, err := os.ReadDir(appsConfigDir); err == nil {
appsMap := make(map[string]any)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
appName := entry.Name()
appConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "apps", appName, "config.yaml")
appData, err := os.ReadFile(appConfigPath)
if err != nil {
continue
}
var appConfig map[string]any
if err := yaml.Unmarshal(appData, &appConfig); err == nil && appConfig != nil {
appsMap[appName] = appConfig
}
}
if len(appsMap) > 0 {
instanceMap["apps"] = appsMap
}
}
globalData, err := os.ReadFile(globalConfigPath)
if err != nil {
if os.IsNotExist(err) {
slog.Warn("no global config found, using instance config only", "path", globalConfigPath)
return instanceMap, nil
}
return nil, fmt.Errorf("reading global config %s: %w", globalConfigPath, err)
}
var globalMap map[string]any
if err := yaml.Unmarshal(globalData, &globalMap); err != nil {
return nil, fmt.Errorf("parsing global config: %w", err)
}
return DeepMerge(globalMap, instanceMap), nil
}

View File

@@ -0,0 +1,699 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// Test: LoadGlobalConfig loads valid configuration
func TestLoadGlobalConfig(t *testing.T) {
tests := []struct {
name string
configYAML string
verify func(t *testing.T, config *GlobalConfig)
wantErr bool
}{
{
name: "loads complete configuration",
configYAML: `operator:
email: "admin@example.com"
cloud:
router:
ip: "192.168.1.254"
dynamicDns: "example.dyndns.org"
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Operator.Email != "admin@example.com" {
t.Error("operator email not loaded correctly")
}
if config.Cloud.Router.IP != "192.168.1.254" {
t.Error("router IP not loaded correctly")
}
},
wantErr: false,
},
{
name: "loads minimal configuration",
configYAML: `operator:
email: "admin@example.com"
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Operator.Email != "admin@example.com" {
t.Error("operator email not loaded correctly")
}
},
wantErr: false,
},
{
name: "loads empty configuration",
configYAML: `{}
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Operator.Email != "" {
t.Error("expected empty operator email")
}
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
config, err := LoadGlobalConfig(configPath)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if config == nil {
t.Fatal("config is nil")
}
if tt.verify != nil {
tt.verify(t, config)
}
})
}
}
// Test: LoadGlobalConfig error cases
func TestLoadGlobalConfig_Errors(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) string
errContains string
}{
{
name: "non-existent file",
setupFunc: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "nonexistent.yaml")
},
errContains: "reading config file",
},
{
name: "invalid yaml",
setupFunc: func(t *testing.T) string {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
content := `invalid: yaml: [[[`
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
return configPath
},
errContains: "parsing config file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := tt.setupFunc(t)
_, err := LoadGlobalConfig(configPath)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
})
}
}
// Test: SaveGlobalConfig saves configuration correctly
func TestSaveGlobalConfig(t *testing.T) {
tests := []struct {
name string
config *GlobalConfig
verify func(t *testing.T, configPath string)
}{
{
name: "saves complete configuration",
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Operator.Email = "admin@example.com"
cfg.Cloud.Router.IP = "192.168.1.254"
cfg.Cloud.Router.DynamicDns = "example.dyndns.org"
return cfg
}(),
verify: func(t *testing.T, configPath string) {
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read saved config: %v", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, "admin@example.com") {
t.Error("saved config missing operator email")
}
if !strings.Contains(contentStr, "192.168.1.254") {
t.Error("saved config missing router IP")
}
},
},
{
name: "saves empty configuration",
config: &GlobalConfig{},
verify: func(t *testing.T, configPath string) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Error("config file not created")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "subdir", "config.yaml")
err := SaveGlobalConfig(tt.config, configPath)
if err != nil {
t.Errorf("SaveGlobalConfig failed: %v", err)
return
}
// Verify file exists
if _, err := os.Stat(configPath); err != nil {
t.Errorf("config file not created: %v", err)
return
}
// Verify file permissions
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("failed to stat config file: %v", err)
}
if info.Mode().Perm() != 0644 {
t.Errorf("expected permissions 0644, got %v", info.Mode().Perm())
}
// Verify content can be loaded back
loadedConfig, err := LoadGlobalConfig(configPath)
if err != nil {
t.Errorf("failed to reload saved config: %v", err)
} else if loadedConfig == nil {
t.Error("loaded config is nil")
}
if tt.verify != nil {
tt.verify(t, configPath)
}
})
}
}
// Test: SaveGlobalConfig creates directory
func TestSaveGlobalConfig_CreatesDirectory(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "nested", "dirs", "config.yaml")
config := &GlobalConfig{}
err := SaveGlobalConfig(config, configPath)
if err != nil {
t.Fatalf("SaveGlobalConfig failed: %v", err)
}
// Verify nested directories were created
if _, err := os.Stat(filepath.Dir(configPath)); err != nil {
t.Errorf("directory not created: %v", err)
}
// Verify file exists
if _, err := os.Stat(configPath); err != nil {
t.Errorf("config file not created: %v", err)
}
}
// Test: GlobalConfig.IsEmpty checks if config is empty
func TestGlobalConfig_IsEmpty(t *testing.T) {
tests := []struct {
name string
config *GlobalConfig
want bool
}{
{
name: "nil config is empty",
config: nil,
want: true,
},
{
name: "default config is empty",
config: &GlobalConfig{},
want: true,
},
{
name: "config with only router IP is not empty",
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Cloud.Router.IP = "192.168.1.254"
return cfg
}(),
want: false,
},
{
name: "config with only operator email is not empty",
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Operator.Email = "admin@example.com"
return cfg
}(),
want: false,
},
{
name: "config with all fields is not empty",
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Cloud.Router.IP = "192.168.1.254"
cfg.Operator.Email = "admin@example.com"
return cfg
}(),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.IsEmpty()
if got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
// Test: LoadCloudConfig loads instance configuration
func TestLoadCloudConfig(t *testing.T) {
tests := []struct {
name string
configYAML string
verify func(t *testing.T, config *InstanceConfig)
wantErr bool
}{
{
name: "loads complete instance configuration",
configYAML: `cloud:
dhcpRange: "192.168.1.100,192.168.1.200"
baseDomain: "example.com"
domain: "home"
internalDomain: "internal.example.com"
cluster:
name: "my-cluster"
loadBalancerIp: "192.168.1.10"
nodes:
talos:
version: "v1.8.0"
activeNodes:
- node1:
role: "control"
interface: "eth0"
disk: "/dev/sda"
`,
verify: func(t *testing.T, config *InstanceConfig) {
if config.Cloud.BaseDomain != "example.com" {
t.Error("base domain not loaded correctly")
}
if config.Cloud.DHCPRange != "192.168.1.100,192.168.1.200" {
t.Error("DHCP range not loaded correctly")
}
if config.Cluster.Name != "my-cluster" {
t.Error("cluster name not loaded correctly")
}
if config.Cluster.Nodes.Talos.Version != "v1.8.0" {
t.Error("talos version not loaded correctly")
}
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
config, err := LoadCloudConfig(configPath)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if config == nil {
t.Fatal("config is nil")
}
if tt.verify != nil {
tt.verify(t, config)
}
})
}
}
// Test: LoadCloudConfig error cases
func TestLoadCloudConfig_Errors(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) string
errContains string
}{
{
name: "non-existent file",
setupFunc: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "nonexistent.yaml")
},
errContains: "reading config file",
},
{
name: "invalid yaml",
setupFunc: func(t *testing.T) string {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
content := `invalid: yaml: [[[`
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
return configPath
},
errContains: "parsing config file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := tt.setupFunc(t)
_, err := LoadCloudConfig(configPath)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
})
}
}
// Test: SaveCloudConfig saves instance configuration
func TestSaveCloudConfig(t *testing.T) {
tests := []struct {
name string
config *InstanceConfig
verify func(t *testing.T, configPath string)
}{
{
name: "saves instance configuration",
config: func() *InstanceConfig {
cfg := &InstanceConfig{}
cfg.Cloud.BaseDomain = "example.com"
cfg.Cloud.Domain = "home"
return cfg
}(),
verify: func(t *testing.T, configPath string) {
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read saved config: %v", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, "example.com") {
t.Error("saved config missing base domain")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "subdir", "config.yaml")
err := SaveCloudConfig(tt.config, configPath)
if err != nil {
t.Errorf("SaveCloudConfig failed: %v", err)
return
}
// Verify file exists
if _, err := os.Stat(configPath); err != nil {
t.Errorf("config file not created: %v", err)
return
}
// Verify content can be loaded back
loadedConfig, err := LoadCloudConfig(configPath)
if err != nil {
t.Errorf("failed to reload saved config: %v", err)
} else if loadedConfig == nil {
t.Error("loaded config is nil")
}
if tt.verify != nil {
tt.verify(t, configPath)
}
})
}
}
// Test: Round-trip save and load preserves data
func TestGlobalConfig_RoundTrip(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
// Create config with all fields
original := &GlobalConfig{}
original.Operator.Email = "admin@example.com"
original.Cloud.Router.IP = "192.168.1.254"
original.Cloud.Router.DynamicDns = "example.dyndns.org"
// Save config
if err := SaveGlobalConfig(original, configPath); err != nil {
t.Fatalf("SaveGlobalConfig failed: %v", err)
}
// Load config
loaded, err := LoadGlobalConfig(configPath)
if err != nil {
t.Fatalf("LoadGlobalConfig failed: %v", err)
}
// Verify all fields match
if loaded.Operator.Email != original.Operator.Email {
t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email)
}
if loaded.Cloud.Router.IP != original.Cloud.Router.IP {
t.Errorf("router IP mismatch: got %q, want %q", loaded.Cloud.Router.IP, original.Cloud.Router.IP)
}
if loaded.Cloud.Router.DynamicDns != original.Cloud.Router.DynamicDns {
t.Errorf("dynamic DNS mismatch: got %q, want %q", loaded.Cloud.Router.DynamicDns, original.Cloud.Router.DynamicDns)
}
}
// Test: Round-trip save and load for instance config
func TestInstanceConfig_RoundTrip(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
// Create instance config
original := &InstanceConfig{}
original.Cloud.BaseDomain = "example.com"
original.Cloud.Domain = "home"
original.Cluster.Name = "my-cluster"
// Save config
if err := SaveCloudConfig(original, configPath); err != nil {
t.Fatalf("SaveCloudConfig failed: %v", err)
}
// Load config
loaded, err := LoadCloudConfig(configPath)
if err != nil {
t.Fatalf("LoadCloudConfig failed: %v", err)
}
// Verify fields match
if loaded.Cloud.BaseDomain != original.Cloud.BaseDomain {
t.Errorf("base domain mismatch: got %q, want %q", loaded.Cloud.BaseDomain, original.Cloud.BaseDomain)
}
if loaded.Cluster.Name != original.Cluster.Name {
t.Errorf("cluster name mismatch: got %q, want %q", loaded.Cluster.Name, original.Cluster.Name)
}
}
func TestDeepMerge(t *testing.T) {
tests := []struct {
name string
dst map[string]any
src map[string]any
expected map[string]any
}{
{
name: "src overrides dst flat keys",
dst: map[string]any{"a": "1", "b": "2"},
src: map[string]any{"b": "3", "c": "4"},
expected: map[string]any{"a": "1", "b": "3", "c": "4"},
},
{
name: "nested maps merge recursively",
dst: map[string]any{
"cloud": map[string]any{
"router": map[string]any{"ip": "192.168.1.1"},
},
},
src: map[string]any{
"cloud": map[string]any{
"domain": "example.com",
},
},
expected: map[string]any{
"cloud": map[string]any{
"router": map[string]any{"ip": "192.168.1.1"},
"domain": "example.com",
},
},
},
{
name: "src nested key overrides dst nested key",
dst: map[string]any{
"cloud": map[string]any{"domain": "old.com"},
},
src: map[string]any{
"cloud": map[string]any{"domain": "new.com"},
},
expected: map[string]any{
"cloud": map[string]any{"domain": "new.com"},
},
},
{
name: "empty src returns dst",
dst: map[string]any{"a": "1"},
src: map[string]any{},
expected: map[string]any{"a": "1"},
},
{
name: "empty dst returns src",
dst: map[string]any{},
src: map[string]any{"a": "1"},
expected: map[string]any{"a": "1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DeepMerge(tt.dst, tt.src)
resultYAML, _ := yaml.Marshal(result)
expectedYAML, _ := yaml.Marshal(tt.expected)
if string(resultYAML) != string(expectedYAML) {
t.Errorf("DeepMerge() =\n%s\nwant:\n%s", resultYAML, expectedYAML)
}
})
}
}
func TestLoadMergedInstanceConfig(t *testing.T) {
dataDir := t.TempDir()
instanceName := "test"
instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config")
os.MkdirAll(instanceConfigDir, 0755)
globalConfig := `operator:
email: test@example.com
cloud:
router:
ip: 192.168.1.1
`
instanceConfig := `cluster:
name: test
nodes:
control:
vip: 192.168.1.100
cloud:
domain: cloud.example.com
`
os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte(globalConfig), 0644)
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte(instanceConfig), 0644)
merged, err := LoadMergedInstanceConfig(dataDir, instanceName)
if err != nil {
t.Fatalf("LoadMergedInstanceConfig() error: %v", err)
}
cloud, ok := merged["cloud"].(map[string]any)
if !ok {
t.Fatal("missing cloud key in merged config")
}
router, ok := cloud["router"].(map[string]any)
if !ok {
t.Fatal("missing cloud.router key — global config not merged")
}
if router["ip"] != "192.168.1.1" {
t.Errorf("cloud.router.ip = %v, want 192.168.1.1", router["ip"])
}
if cloud["domain"] != "cloud.example.com" {
t.Errorf("cloud.domain = %v, want cloud.example.com", cloud["domain"])
}
cluster, ok := merged["cluster"].(map[string]any)
if !ok {
t.Fatal("missing cluster key — instance config not merged")
}
if cluster["name"] != "test" {
t.Errorf("cluster.name = %v, want test", cluster["name"])
}
operator, ok := merged["operator"].(map[string]any)
if !ok {
t.Fatal("missing operator key — global config not merged")
}
if operator["email"] != "test@example.com" {
t.Errorf("operator.email = %v, want test@example.com", operator["email"])
}
}
func TestLoadMergedInstanceConfig_NoGlobalConfig(t *testing.T) {
dataDir := t.TempDir()
instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config")
os.MkdirAll(instanceConfigDir, 0755)
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte("cluster:\n name: test\n"), 0644)
merged, err := LoadMergedInstanceConfig(dataDir, "test")
if err != nil {
t.Fatalf("LoadMergedInstanceConfig() error: %v", err)
}
cluster, ok := merged["cluster"].(map[string]any)
if !ok {
t.Fatal("missing cluster key")
}
if cluster["name"] != "test" {
t.Errorf("cluster.name = %v, want test", cluster["name"])
}
}

View File

@@ -0,0 +1,114 @@
package config
import (
"encoding/json"
"testing"
"gopkg.in/yaml.v3"
)
func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
// Legacy format: plain integer array
input := `{"cloud":{"nftables":{"extraPorts":[22]}}}`
var cfg GlobalConfig
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Cloud.Nftables.ExtraPorts) != 1 || cfg.Cloud.Nftables.ExtraPorts[0].Port != 22 {
t.Errorf("after JSON decode: expected [{Port:22}], got %v", cfg.Cloud.Nftables.ExtraPorts)
}
}
func TestExtraPortsJSONDecode_Structured(t *testing.T) {
// New structured format
input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}`
var cfg GlobalConfig
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Cloud.Nftables.ExtraPorts) != 2 {
t.Fatalf("expected 2 ports, got %d", len(cfg.Cloud.Nftables.ExtraPorts))
}
if cfg.Cloud.Nftables.ExtraPorts[0].Port != 22 || cfg.Cloud.Nftables.ExtraPorts[0].Protocol != "tcp" || cfg.Cloud.Nftables.ExtraPorts[0].Label != "SSH" {
t.Errorf("port 0: expected {22,tcp,SSH}, got %+v", cfg.Cloud.Nftables.ExtraPorts[0])
}
if cfg.Cloud.Nftables.ExtraPorts[1].Port != 5353 || cfg.Cloud.Nftables.ExtraPorts[1].Protocol != "udp" {
t.Errorf("port 1: expected {5353,udp}, got %+v", cfg.Cloud.Nftables.ExtraPorts[1])
}
}
func TestExtraPortsJSONEncode(t *testing.T) {
var cfg GlobalConfig
cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}}
out, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
json.Unmarshal(out, &decoded)
cloud := decoded["cloud"].(map[string]any)
nft := cloud["nftables"].(map[string]any)
ports, ok := nft["extraPorts"]
if !ok {
t.Errorf("extraPorts missing from JSON output: %s", out)
}
arr := ports.([]any)
if len(arr) != 1 {
t.Fatalf("expected 1 port, got %d", len(arr))
}
entry := arr[0].(map[string]any)
if entry["port"].(float64) != 22 {
t.Errorf("expected port 22, got %v", entry["port"])
}
if entry["label"].(string) != "SSH" {
t.Errorf("expected label SSH, got %v", entry["label"])
}
}
func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
// Legacy YAML format with plain integers
input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n"
var cfg GlobalConfig
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("yaml unmarshal: %v", err)
}
if len(cfg.Cloud.Nftables.ExtraPorts) != 2 {
t.Fatalf("expected 2 ports, got %d", len(cfg.Cloud.Nftables.ExtraPorts))
}
if cfg.Cloud.Nftables.ExtraPorts[0].Port != 22 || cfg.Cloud.Nftables.ExtraPorts[1].Port != 8080 {
t.Errorf("expected [22, 8080], got %v", cfg.Cloud.Nftables.ExtraPorts)
}
}
func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) {
input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n"
var cfg GlobalConfig
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("yaml unmarshal: %v", err)
}
if len(cfg.Cloud.Nftables.ExtraPorts) != 2 {
t.Fatalf("expected 2 ports, got %d", len(cfg.Cloud.Nftables.ExtraPorts))
}
if cfg.Cloud.Nftables.ExtraPorts[0].Label != "SSH" {
t.Errorf("expected label SSH, got %q", cfg.Cloud.Nftables.ExtraPorts[0].Label)
}
if cfg.Cloud.Nftables.ExtraPorts[1].Protocol != "udp" {
t.Errorf("expected protocol udp, got %q", cfg.Cloud.Nftables.ExtraPorts[1].Protocol)
}
}
func TestSplitExtraPorts(t *testing.T) {
ports := []ExtraPort{
{Port: 22, Protocol: "tcp"},
{Port: 80}, // default = tcp
{Port: 5353, Protocol: "udp"},
{Port: 123, Protocol: "UDP"}, // case-insensitive
}
tcp, udp := SplitExtraPorts(ports)
if len(tcp) != 2 || tcp[0] != 22 || tcp[1] != 80 {
t.Errorf("expected tcp=[22,80], got %v", tcp)
}
if len(udp) != 2 || udp[0] != 5353 || udp[1] != 123 {
t.Errorf("expected udp=[5353,123], got %v", udp)
}
}

239
internal/config/manager.go Normal file
View File

@@ -0,0 +1,239 @@
package config
import (
"bytes"
"fmt"
"log/slog"
"os/exec"
"path/filepath"
"strings"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/storage"
)
// YQ provides a wrapper around the yq command-line tool
type YQ struct {
yqPath string
}
// NewYQ creates a new YQ wrapper
func NewYQ() *YQ {
path, err := exec.LookPath("yq")
if err != nil {
path = "yq"
}
return &YQ{yqPath: path}
}
// Get retrieves a value from a YAML file using a yq expression
func (y *YQ) Get(filePath, expression string) (string, error) {
cmd := exec.Command(y.yqPath, expression, filePath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// Set sets a value in a YAML file using a yq expression
func (y *YQ) Set(filePath, expression, value string) error {
if !strings.HasPrefix(expression, ".") {
expression = "." + expression
}
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Delete removes a key from a YAML file
func (y *YQ) Delete(filePath, expression string) error {
delExpr := fmt.Sprintf("del(%s)", expression)
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Validate checks if a YAML file is valid
func (y *YQ) Validate(filePath string) error {
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Manager handles configuration file operations with idempotency
type Manager struct {
yq *YQ
}
// NewManager creates a new config manager
func NewManager() *Manager {
return &Manager{
yq: NewYQ(),
}
}
// EnsureGlobalConfig ensures a global config file exists with proper structure
func (m *Manager) EnsureGlobalConfig(dataDir string) error {
configPath := filepath.Join(dataDir, "config.yaml")
// Check if config already exists
if storage.FileExists(configPath) {
// Validate existing config
if err := m.yq.Validate(configPath); err != nil {
return fmt.Errorf("invalid config file: %w", err)
}
return nil
}
// Create config structure with detected defaults
initialConfig := &GlobalConfig{}
// Detect network configuration
netInfo, err := network.DetectNetworkInfo()
if err != nil {
slog.Info("network detection failed, using defaults", "component", "config", "error", err)
} else {
// Set detected values
initialConfig.Cloud.Router.IP = netInfo.Gateway
slog.Info("detected network", "component", "config", "gateway", netInfo.Gateway, "interface", netInfo.PrimaryInterface)
}
// Ensure data directory exists
if err := storage.EnsureDir(dataDir, 0755); err != nil {
return err
}
// Save config using the model's save function
return SaveGlobalConfig(initialConfig, configPath)
}
// EnsureInstanceConfig ensures an instance config file exists with proper structure
func (m *Manager) EnsureInstanceConfig(name string, dataDir string) error {
configPath := filepath.Join(dataDir, "instances", name, "config", "instance.yaml")
// Check if config already exists
if storage.FileExists(configPath) {
// Validate existing config
if err := m.yq.Validate(configPath); err != nil {
return fmt.Errorf("invalid config file: %w", err)
}
return nil
}
// Ensure config directory exists
if err := storage.EnsureDir(filepath.Join(dataDir, "instances", name, "config"), 0755); err != nil {
return err
}
initialConfig := &InstanceConfig{}
initialConfig.Cluster.Name = name
initialConfig.Cluster.Nodes.Active = make(map[string]NodeConfig)
initialConfig.Apps = make(map[string]any)
return SaveCloudConfig(initialConfig, configPath)
}
// GetConfigValue retrieves a value from a config file
func (m *Manager) GetConfigValue(configPath, key string) (string, error) {
if !storage.FileExists(configPath) {
return "", fmt.Errorf("config file not found: %s", configPath)
}
value, err := m.yq.Get(configPath, fmt.Sprintf(".%s", key))
if err != nil {
return "", fmt.Errorf("getting config value %s: %w", key, err)
}
return value, nil
}
// SetConfigValue sets a value in a config file
func (m *Manager) SetConfigValue(configPath, key, value string) error {
if !storage.FileExists(configPath) {
return fmt.Errorf("config file not found: %s", configPath)
}
// Acquire lock before modifying
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return m.yq.Set(configPath, fmt.Sprintf(".%s", key), value)
})
}
// EnsureConfigValue sets a value only if it's not already set (idempotent)
func (m *Manager) EnsureConfigValue(configPath, key, value string) error {
if !storage.FileExists(configPath) {
return fmt.Errorf("config file not found: %s", configPath)
}
// Check if value already set
currentValue, err := m.GetConfigValue(configPath, key)
if err == nil && currentValue != "" && currentValue != "null" {
// Value already set, skip
return nil
}
// Set the value
return m.SetConfigValue(configPath, key, value)
}
// ValidateConfig validates a config file
func (m *Manager) ValidateConfig(configPath string) error {
if !storage.FileExists(configPath) {
return fmt.Errorf("config file not found: %s", configPath)
}
return m.yq.Validate(configPath)
}
// CopyConfig copies a config file to a new location
func (m *Manager) CopyConfig(srcPath, dstPath string) error {
if !storage.FileExists(srcPath) {
return fmt.Errorf("source config file not found: %s", srcPath)
}
// Read source
content, err := storage.ReadFile(srcPath)
if err != nil {
return err
}
// Ensure destination directory exists
if err := storage.EnsureDir(filepath.Dir(dstPath), 0755); err != nil {
return err
}
// Write destination
return storage.WriteFile(dstPath, content, 0644)
}
// GetInstanceConfigPath returns the path to an instance's config file
func GetInstanceConfigPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
}
// GetInstanceSecretsPath returns the path to an instance's secrets file
func GetInstanceSecretsPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
}
// GetInstancePath returns the path to an instance directory
func GetInstancePath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName)
}

View File

@@ -0,0 +1,922 @@
package config
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/wild-cloud/wild-central/internal/storage"
)
// Test: NewManager creates manager successfully
func TestNewManager(t *testing.T) {
m := NewManager()
if m == nil || m.yq == nil {
t.Fatal("NewManager returned nil or Manager.yq is nil")
}
}
// Test: EnsureInstanceConfig creates config file with proper structure
func TestEnsureInstanceConfig(t *testing.T) {
const instanceName = "test-instance"
tests := []struct {
name string
setupFunc func(t *testing.T, dataDir string)
wantErr bool
errContains string
}{
{
name: "creates config when not exists",
setupFunc: nil,
wantErr: false,
},
{
name: "returns nil when config exists",
setupFunc: func(t *testing.T, dataDir string) {
configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatalf("setup mkdir failed: %v", err)
}
content := `operator:
email: ""
cloud:
baseDomain: "test.local"
domain: "test"
internalDomain: "internal.test"
dhcpRange: ""
dns:
ip: ""
router:
ip: ""
dnsmasq:
interface: ""
nfs:
host: ""
mediaPath: ""
storageCapacity: ""
dockerRegistryHost: ""
cluster:
name: ""
loadBalancerIp: ""
ipAddressPool: ""
hostnamePrefix: ""
certManager:
cloudflare:
domain: ""
externalDns:
ownerId: ""
dockerRegistry:
storage: ""
nodes:
talos:
version: ""
schematicId: ""
control:
vip: ""
active: {}
apps: {}
`
if err := storage.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
},
wantErr: false,
},
{
name: "returns error when config is invalid yaml",
setupFunc: func(t *testing.T, dataDir string) {
configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatalf("setup mkdir failed: %v", err)
}
if err := storage.WriteFile(configPath, []byte(`invalid: yaml: content: [[[`), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
},
wantErr: true,
errContains: "invalid config file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dataDir := t.TempDir()
m := NewManager()
if tt.setupFunc != nil {
tt.setupFunc(t, dataDir)
}
err := m.EnsureInstanceConfig(instanceName, dataDir)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Verify config file exists
configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
if !storage.FileExists(configPath) {
t.Error("config file not created")
}
// Verify config is valid YAML
if err := m.ValidateConfig(configPath); err != nil {
t.Errorf("config validation failed: %v", err)
}
// Verify config has expected structure (canonical nested format)
content, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
contentStr := string(content)
requiredFields := []string{"operator:", "cloud:", "cluster:", "apps:"}
for _, field := range requiredFields {
if !strings.Contains(contentStr, field) {
t.Errorf("config missing required field: %s", field)
}
}
})
}
}
// Test: GetConfigValue retrieves values correctly
func TestGetConfigValue(t *testing.T) {
tests := []struct {
name string
configYAML string
key string
want string
wantErr bool
errContains string
}{
{
name: "get simple string value",
configYAML: `baseDomain: "example.com"
domain: "test"
`,
key: "baseDomain",
want: "example.com",
wantErr: false,
},
{
name: "get nested value with dot notation",
configYAML: `cluster:
name: "my-cluster"
nodes:
talos:
version: "v1.8.0"
`,
key: "cluster.nodes.talos.version",
want: "v1.8.0",
wantErr: false,
},
{
name: "get empty string value",
configYAML: `baseDomain: ""
`,
key: "baseDomain",
want: "",
wantErr: false,
},
{
name: "get non-existent key returns null",
configYAML: `baseDomain: "example.com"
`,
key: "nonexistent",
want: "null",
wantErr: false,
},
{
name: "get from array",
configYAML: `cluster:
nodes:
activeNodes:
- "node1"
- "node2"
`,
key: "cluster.nodes.activeNodes.[0]",
want: "node1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := storage.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
m := NewManager()
got, err := m.GetConfigValue(configPath, tt.key)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
// Test: GetConfigValue error cases
func TestGetConfigValue_Errors(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) string
key string
errContains string
}{
{
name: "non-existent file",
setupFunc: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "nonexistent.yaml")
},
key: "baseDomain",
errContains: "config file not found",
},
{
name: "malformed yaml",
setupFunc: func(t *testing.T) string {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
content := `invalid: yaml: [[[`
if err := storage.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
return configPath
},
key: "baseDomain",
errContains: "getting config value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := tt.setupFunc(t)
m := NewManager()
_, err := m.GetConfigValue(configPath, tt.key)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
})
}
}
// Test: SetConfigValue sets values correctly
func TestSetConfigValue(t *testing.T) {
tests := []struct {
name string
initialYAML string
key string
value string
verifyFunc func(t *testing.T, configPath string)
}{
{
name: "set simple value",
initialYAML: `baseDomain: ""
domain: ""
`,
key: "baseDomain",
value: "example.com",
verifyFunc: func(t *testing.T, configPath string) {
m := NewManager()
got, err := m.GetConfigValue(configPath, "baseDomain")
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != "example.com" {
t.Errorf("got %q, want %q", got, "example.com")
}
},
},
{
name: "set nested value",
initialYAML: `cluster:
name: ""
nodes:
talos:
version: ""
`,
key: "cluster.nodes.talos.version",
value: "v1.8.0",
verifyFunc: func(t *testing.T, configPath string) {
m := NewManager()
got, err := m.GetConfigValue(configPath, "cluster.nodes.talos.version")
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != "v1.8.0" {
t.Errorf("got %q, want %q", got, "v1.8.0")
}
},
},
{
name: "update existing value",
initialYAML: `baseDomain: "old.com"
`,
key: "baseDomain",
value: "new.com",
verifyFunc: func(t *testing.T, configPath string) {
m := NewManager()
got, err := m.GetConfigValue(configPath, "baseDomain")
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != "new.com" {
t.Errorf("got %q, want %q", got, "new.com")
}
},
},
{
name: "create new nested path",
initialYAML: `cluster: {}
`,
key: "cluster.newField",
value: "newValue",
verifyFunc: func(t *testing.T, configPath string) {
m := NewManager()
got, err := m.GetConfigValue(configPath, "cluster.newField")
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != "newValue" {
t.Errorf("got %q, want %q", got, "newValue")
}
},
},
{
name: "set value with special characters",
initialYAML: `baseDomain: ""
`,
key: "baseDomain",
value: `special"quotes'and\backslashes`,
verifyFunc: func(t *testing.T, configPath string) {
m := NewManager()
got, err := m.GetConfigValue(configPath, "baseDomain")
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if got != `special"quotes'and\backslashes` {
t.Errorf("got %q, want %q", got, `special"quotes'and\backslashes`)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := storage.WriteFile(configPath, []byte(tt.initialYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
m := NewManager()
if err := m.SetConfigValue(configPath, tt.key, tt.value); err != nil {
t.Errorf("SetConfigValue failed: %v", err)
return
}
// Verify the value was set correctly
tt.verifyFunc(t, configPath)
// Verify config is still valid YAML
if err := m.ValidateConfig(configPath); err != nil {
t.Errorf("config validation failed after set: %v", err)
}
})
}
}
// Test: SetConfigValue error cases
func TestSetConfigValue_Errors(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T) string
key string
value string
errContains string
}{
{
name: "non-existent file",
setupFunc: func(t *testing.T) string {
return filepath.Join(t.TempDir(), "nonexistent.yaml")
},
key: "baseDomain",
value: "example.com",
errContains: "config file not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configPath := tt.setupFunc(t)
m := NewManager()
err := m.SetConfigValue(configPath, tt.key, tt.value)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
})
}
}
// Test: SetConfigValue with concurrent access
func TestSetConfigValue_ConcurrentAccess(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
initialYAML := `counter: "0"
`
if err := storage.WriteFile(configPath, []byte(initialYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
m := NewManager()
const numGoroutines = 10
var wg sync.WaitGroup
errors := make(chan error, numGoroutines)
// Launch multiple goroutines trying to write different values
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
key := "counter"
value := string(rune('0' + val))
if err := m.SetConfigValue(configPath, key, value); err != nil {
errors <- err
}
}(i)
}
wg.Wait()
close(errors)
// Check if any errors occurred
for err := range errors {
t.Errorf("concurrent write error: %v", err)
}
// Verify config is still valid after concurrent access
if err := m.ValidateConfig(configPath); err != nil {
t.Errorf("config validation failed after concurrent writes: %v", err)
}
// Verify we can read the value (should be one of the written values)
value, err := m.GetConfigValue(configPath, "counter")
if err != nil {
t.Errorf("failed to read value after concurrent writes: %v", err)
}
if value == "" || value == "null" {
t.Error("counter value is empty after concurrent writes")
}
}
// Test: EnsureConfigValue sets value only when not set
func TestEnsureConfigValue(t *testing.T) {
tests := []struct {
name string
initialYAML string
key string
value string
expectSet bool
}{
{
name: "sets value when empty string",
initialYAML: `baseDomain: ""
`,
key: "baseDomain",
value: "example.com",
expectSet: true,
},
{
name: "sets value when null",
initialYAML: `baseDomain: null
`,
key: "baseDomain",
value: "example.com",
expectSet: true,
},
{
name: "does not set value when already set",
initialYAML: `baseDomain: "existing.com"
`,
key: "baseDomain",
value: "new.com",
expectSet: false,
},
{
name: "sets value when key does not exist",
initialYAML: `domain: "test"
`,
key: "baseDomain",
value: "example.com",
expectSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := storage.WriteFile(configPath, []byte(tt.initialYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
m := NewManager()
// Get initial value
initialVal, _ := m.GetConfigValue(configPath, tt.key)
// Call EnsureConfigValue
if err := m.EnsureConfigValue(configPath, tt.key, tt.value); err != nil {
t.Errorf("EnsureConfigValue failed: %v", err)
return
}
// Get final value
finalVal, err := m.GetConfigValue(configPath, tt.key)
if err != nil {
t.Fatalf("GetConfigValue failed: %v", err)
}
if tt.expectSet {
if finalVal != tt.value {
t.Errorf("expected value to be set to %q, got %q", tt.value, finalVal)
}
} else {
if finalVal != initialVal {
t.Errorf("expected value to remain %q, got %q", initialVal, finalVal)
}
}
// Call EnsureConfigValue again - should be idempotent
if err := m.EnsureConfigValue(configPath, tt.key, "different.com"); err != nil {
t.Errorf("second EnsureConfigValue failed: %v", err)
return
}
// Value should not change on second call
secondVal, err := m.GetConfigValue(configPath, tt.key)
if err != nil {
t.Fatalf("GetConfigValue failed: %v", err)
}
if secondVal != finalVal {
t.Errorf("value changed on second ensure: %q -> %q", finalVal, secondVal)
}
})
}
}
// Test: ValidateConfig validates YAML correctly
func TestValidateConfig(t *testing.T) {
tests := []struct {
name string
configYAML string
wantErr bool
errContains string
}{
{
name: "valid yaml",
configYAML: `baseDomain: "example.com"
domain: "test"
cluster:
name: "my-cluster"
`,
wantErr: false,
},
{
name: "invalid yaml - bad indentation",
configYAML: `baseDomain: "example.com"\n domain: "test"`,
wantErr: true,
errContains: "yaml validation failed",
},
{
name: "invalid yaml - unclosed bracket",
configYAML: `cluster: { name: "test"`,
wantErr: true,
errContains: "yaml validation failed",
},
{
name: "empty file",
configYAML: "",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
if err := storage.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
m := NewManager()
err := m.ValidateConfig(configPath)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
// Test: ValidateConfig error cases
func TestValidateConfig_Errors(t *testing.T) {
t.Run("non-existent file", func(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "nonexistent.yaml")
m := NewManager()
err := m.ValidateConfig(configPath)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), "config file not found") {
t.Errorf("error %q does not contain 'config file not found'", err.Error())
}
})
}
// Test: CopyConfig copies configuration correctly
func TestCopyConfig(t *testing.T) {
tests := []struct {
name string
srcYAML string
setupDst func(t *testing.T, dstPath string)
wantErr bool
errContains string
}{
{
name: "copies config successfully",
srcYAML: `baseDomain: "example.com"
domain: "test"
cluster:
name: "my-cluster"
`,
setupDst: nil,
wantErr: false,
},
{
name: "creates destination directory",
srcYAML: `baseDomain: "example.com"`,
setupDst: nil,
wantErr: false,
},
{
name: "overwrites existing destination",
srcYAML: `baseDomain: "new.com"
`,
setupDst: func(t *testing.T, dstPath string) {
oldContent := `baseDomain: "old.com"`
if err := storage.WriteFile(dstPath, []byte(oldContent), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "source.yaml")
dstPath := filepath.Join(tempDir, "subdir", "dest.yaml")
// Create source file
if err := storage.WriteFile(srcPath, []byte(tt.srcYAML), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
// Setup destination if needed
if tt.setupDst != nil {
if err := storage.EnsureDir(filepath.Dir(dstPath), 0755); err != nil {
t.Fatalf("setup failed: %v", err)
}
tt.setupDst(t, dstPath)
}
m := NewManager()
err := m.CopyConfig(srcPath, dstPath)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Verify destination file exists
if !storage.FileExists(dstPath) {
t.Error("destination file not created")
}
// Verify content matches source
srcContent, err := storage.ReadFile(srcPath)
if err != nil {
t.Fatalf("failed to read source: %v", err)
}
dstContent, err := storage.ReadFile(dstPath)
if err != nil {
t.Fatalf("failed to read destination: %v", err)
}
if string(srcContent) != string(dstContent) {
t.Error("destination content does not match source")
}
// Verify destination is valid YAML
if err := m.ValidateConfig(dstPath); err != nil {
t.Errorf("destination config validation failed: %v", err)
}
})
}
}
// Test: CopyConfig error cases
func TestCopyConfig_Errors(t *testing.T) {
tests := []struct {
name string
setupFunc func(t *testing.T, tempDir string) (srcPath, dstPath string)
errContains string
}{
{
name: "source file does not exist",
setupFunc: func(t *testing.T, tempDir string) (string, string) {
return filepath.Join(tempDir, "nonexistent.yaml"),
filepath.Join(tempDir, "dest.yaml")
},
errContains: "source config file not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
srcPath, dstPath := tt.setupFunc(t, tempDir)
m := NewManager()
err := m.CopyConfig(srcPath, dstPath)
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
})
}
}
// Test: File permissions are preserved
func TestEnsureInstanceConfig_FilePermissions(t *testing.T) {
tempDir := t.TempDir()
m := NewManager()
if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil {
t.Fatalf("EnsureInstanceConfig failed: %v", err)
}
configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml")
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("failed to stat config file: %v", err)
}
// Verify file has 0644 permissions
if info.Mode().Perm() != 0644 {
t.Errorf("expected permissions 0644, got %v", info.Mode().Perm())
}
}
// Test: Idempotent config creation
func TestEnsureInstanceConfig_Idempotent(t *testing.T) {
tempDir := t.TempDir()
m := NewManager()
// First call creates config
if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil {
t.Fatalf("first EnsureInstanceConfig failed: %v", err)
}
configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml")
firstContent, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
// Second call should not modify config
if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil {
t.Fatalf("second EnsureInstanceConfig failed: %v", err)
}
secondContent, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
if string(firstContent) != string(secondContent) {
t.Error("config content changed on second call")
}
}
// Test: Config structure contains all required fields
func TestEnsureInstanceConfig_RequiredFields(t *testing.T) {
tempDir := t.TempDir()
m := NewManager()
if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil {
t.Fatalf("EnsureInstanceConfig failed: %v", err)
}
configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml")
content, err := storage.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
contentStr := string(content)
requiredFields := []string{
"operator:",
"cloud:",
"baseDomain:",
"domain:",
"internalDomain:",
"dhcpRange:",
"nfs:",
"cluster:",
"loadBalancerIp:",
"ipAddressPool:",
"hostnamePrefix:",
"certManager:",
"externalDns:",
"dockerRegistry:",
"nodes:",
"talos:",
"version:",
"schematicId:",
"control:",
"vip:",
"active:",
"apps:",
}
for _, field := range requiredFields {
if !strings.Contains(contentStr, field) {
t.Errorf("config missing required field: %s", field)
}
}
}