Enhance configuration management by adding global config validation and network detection; refactor tests to align with updated config structure

This commit is contained in:
2026-02-01 09:00:03 +00:00
parent c20006192e
commit 4e49c435a9
7 changed files with 144 additions and 240 deletions

View File

@@ -39,6 +39,14 @@ type API struct {
// NewAPI creates a new API handler with all dependencies
// Note: Setup files (cluster-services, cluster-nodes, etc.) are now embedded in the binary
func NewAPI(dataDir, appsDir string) (*API, error) {
// Initialize config manager
configMgr := config.NewManager()
// Ensure global config exists
if err := configMgr.EnsureGlobalConfig(dataDir); err != nil {
return nil, fmt.Errorf("failed to ensure global config: %w", err)
}
// Ensure base directories exist
instancesDir := tools.GetInstancesPath(dataDir)
if err := os.MkdirAll(instancesDir, 0755); err != nil {
@@ -55,7 +63,7 @@ func NewAPI(dataDir, appsDir string) (*API, error) {
return &API{
dataDir: dataDir,
appsDir: appsDir,
config: config.NewManager(),
config: configMgr,
secrets: secrets.NewManager(),
context: context.NewManager(dataDir),
instance: instance.NewManager(dataDir),

View File

@@ -10,15 +10,6 @@ import (
// GlobalConfig represents the main configuration structure
type GlobalConfig struct {
Wildcloud struct {
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
CurrentPhase string `yaml:"currentPhase,omitempty" json:"currentPhase,omitempty"`
CompletedPhases []string `yaml:"completedPhases,omitempty" json:"completedPhases,omitempty"`
} `yaml:"wildcloud,omitempty" json:"wildcloud,omitempty"`
Server struct {
Port int `yaml:"port,omitempty" json:"port,omitempty"`
Host string `yaml:"host,omitempty" json:"host,omitempty"`
} `yaml:"server,omitempty" json:"server,omitempty"`
Operator struct {
Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"`
@@ -35,14 +26,6 @@ type GlobalConfig struct {
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
Cluster struct {
EndpointIP string `yaml:"endpointIp,omitempty" json:"endpointIp,omitempty"`
Nodes struct {
Talos struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
} `yaml:"nodes,omitempty" json:"nodes,omitempty"`
} `yaml:"cluster,omitempty" json:"cluster,omitempty"`
}
// LoadGlobalConfig loads configuration from the specified path
@@ -57,14 +40,6 @@ func LoadGlobalConfig(configPath string) (*GlobalConfig, error) {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Set defaults
if config.Server.Port == 0 {
config.Server.Port = 5055
}
if config.Server.Host == "" {
config.Server.Host = "0.0.0.0"
}
return config, nil
}
@@ -89,8 +64,8 @@ func (c *GlobalConfig) IsEmpty() bool {
return true
}
// Check if any essential fields are empty
return c.Cloud.DNS.IP == "" || c.Cluster.Nodes.Talos.Version == ""
// Check if essential fields are empty
return c.Cloud.DNS.IP == "" && c.Cloud.Router.IP == "" && c.Operator.Email == ""
}
type NodeConfig struct {
@@ -109,18 +84,7 @@ type InstanceConfig struct {
Domain string `yaml:"domain" json:"domain"`
InternalDomain string `yaml:"internalDomain" json:"internalDomain"`
DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"`
DNS struct {
IP string `yaml:"ip" json:"ip"`
ExternalResolver string `yaml:"externalResolver" json:"externalResolver"`
} `yaml:"dns" json:"dns"`
Router struct {
IP string `yaml:"ip" json:"ip"`
DynamicDns string `yaml:"dynamicDns,omitempty" json:"dynamicDns,omitempty"`
} `yaml:"router" json:"router"`
Dnsmasq struct {
Interface string `yaml:"interface" json:"interface"`
} `yaml:"dnsmasq" json:"dnsmasq"`
NFS struct {
NFS struct {
Host string `yaml:"host" json:"host"`
MediaPath string `yaml:"mediaPath" json:"mediaPath"`
StorageCapacity string `yaml:"storageCapacity" json:"storageCapacity"`

View File

@@ -17,16 +17,7 @@ func TestLoadGlobalConfig(t *testing.T) {
}{
{
name: "loads complete configuration",
configYAML: `wildcloud:
repository: "https://github.com/example/repo"
currentPhase: "setup"
completedPhases:
- "phase1"
- "phase2"
server:
port: 8080
host: "localhost"
operator:
configYAML: `operator:
email: "admin@example.com"
cloud:
dns:
@@ -37,67 +28,43 @@ cloud:
dynamicDns: "example.dyndns.org"
dnsmasq:
interface: "eth0"
cluster:
endpointIp: "192.168.1.100"
nodes:
talos:
version: "v1.8.0"
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Wildcloud.Repository != "https://github.com/example/repo" {
t.Error("repository not loaded correctly")
}
if config.Server.Port != 8080 {
t.Error("port not loaded correctly")
if config.Operator.Email != "admin@example.com" {
t.Error("operator email not loaded correctly")
}
if config.Cloud.DNS.IP != "192.168.1.1" {
t.Error("DNS IP not loaded correctly")
}
if config.Cluster.EndpointIP != "192.168.1.100" {
t.Error("endpoint IP not loaded correctly")
if config.Cloud.Router.IP != "192.168.1.254" {
t.Error("router IP not loaded correctly")
}
if config.Cloud.Dnsmasq.Interface != "eth0" {
t.Error("dnsmasq interface not loaded correctly")
}
},
wantErr: false,
},
{
name: "applies default values",
name: "loads minimal configuration",
configYAML: `cloud:
dns:
ip: "192.168.1.1"
cluster:
nodes:
talos:
version: "v1.8.0"
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Server.Port != 5055 {
t.Errorf("default port not applied, got %d, want 5055", config.Server.Port)
}
if config.Server.Host != "0.0.0.0" {
t.Errorf("default host not applied, got %q, want %q", config.Server.Host, "0.0.0.0")
if config.Cloud.DNS.IP != "192.168.1.1" {
t.Error("DNS IP not loaded correctly")
}
},
wantErr: false,
},
{
name: "preserves custom port and host",
configYAML: `server:
port: 9000
host: "127.0.0.1"
cloud:
dns:
ip: "192.168.1.1"
cluster:
nodes:
talos:
version: "v1.8.0"
name: "loads empty configuration",
configYAML: `{}
`,
verify: func(t *testing.T, config *GlobalConfig) {
if config.Server.Port != 9000 {
t.Errorf("custom port not preserved, got %d, want 9000", config.Server.Port)
}
if config.Server.Host != "127.0.0.1" {
t.Errorf("custom host not preserved, got %q, want %q", config.Server.Host, "127.0.0.1")
if config.Cloud.DNS.IP != "" {
t.Error("expected empty DNS IP")
}
},
wantErr: false,
@@ -189,35 +156,25 @@ func TestSaveGlobalConfig(t *testing.T) {
}{
{
name: "saves complete configuration",
config: &GlobalConfig{
Wildcloud: struct {
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
CurrentPhase string `yaml:"currentPhase,omitempty" json:"currentPhase,omitempty"`
CompletedPhases []string `yaml:"completedPhases,omitempty" json:"completedPhases,omitempty"`
}{
Repository: "https://github.com/example/repo",
CurrentPhase: "setup",
CompletedPhases: []string{"phase1", "phase2"},
},
Server: struct {
Port int `yaml:"port,omitempty" json:"port,omitempty"`
Host string `yaml:"host,omitempty" json:"host,omitempty"`
}{
Port: 8080,
Host: "localhost",
},
},
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Operator.Email = "admin@example.com"
cfg.Cloud.DNS.IP = "192.168.1.1"
cfg.Cloud.Router.IP = "192.168.1.254"
cfg.Cloud.Dnsmasq.Interface = "eth0"
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, "repository") {
t.Error("saved config missing repository field")
if !strings.Contains(contentStr, "admin@example.com") {
t.Error("saved config missing operator email")
}
if !strings.Contains(contentStr, "8080") {
t.Error("saved config missing port value")
if !strings.Contains(contentStr, "192.168.1.1") {
t.Error("saved config missing DNS IP")
}
},
},
@@ -313,101 +270,41 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
want: true,
},
{
name: "config with only DNS IP is empty",
config: &GlobalConfig{
Cloud: struct {
DNS struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
ExternalResolver string `yaml:"externalResolver,omitempty" json:"externalResolver,omitempty"`
} `yaml:"dns,omitempty" json:"dns,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 {
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
}{
DNS: struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
ExternalResolver string `yaml:"externalResolver,omitempty" json:"externalResolver,omitempty"`
}{
IP: "192.168.1.1",
},
},
},
want: true,
name: "config with only DNS IP is not empty",
config: func() *GlobalConfig {
cfg := &GlobalConfig{}
cfg.Cloud.DNS.IP = "192.168.1.1"
return cfg
}(),
want: false,
},
{
name: "config with only Talos version is empty",
config: &GlobalConfig{
Cluster: struct {
EndpointIP string `yaml:"endpointIp,omitempty" json:"endpointIp,omitempty"`
Nodes struct {
Talos struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
} `yaml:"nodes,omitempty" json:"nodes,omitempty"`
}{
Nodes: struct {
Talos struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
}{
Talos: struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
}{
Version: "v1.8.0",
},
},
},
},
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 both DNS IP and Talos version is not empty",
config: &GlobalConfig{
Cloud: struct {
DNS struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
ExternalResolver string `yaml:"externalResolver,omitempty" json:"externalResolver,omitempty"`
} `yaml:"dns,omitempty" json:"dns,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 {
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
}{
DNS: struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
ExternalResolver string `yaml:"externalResolver,omitempty" json:"externalResolver,omitempty"`
}{
IP: "192.168.1.1",
},
},
Cluster: struct {
EndpointIP string `yaml:"endpointIp,omitempty" json:"endpointIp,omitempty"`
Nodes struct {
Talos struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
} `yaml:"nodes,omitempty" json:"nodes,omitempty"`
}{
Nodes: struct {
Talos struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
}{
Talos: struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"`
}{
Version: "v1.8.0",
},
},
},
},
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.DNS.IP = "192.168.1.1"
cfg.Cloud.Router.IP = "192.168.1.254"
cfg.Operator.Email = "admin@example.com"
return cfg
}(),
want: false,
},
}
@@ -433,11 +330,6 @@ func TestLoadCloudConfig(t *testing.T) {
{
name: "loads complete instance configuration",
configYAML: `cloud:
router:
ip: "192.168.1.254"
dns:
ip: "192.168.1.1"
externalResolver: "8.8.8.8"
dhcpRange: "192.168.1.100,192.168.1.200"
baseDomain: "example.com"
domain: "home"
@@ -458,6 +350,9 @@ cluster:
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")
}
@@ -611,29 +506,13 @@ func TestGlobalConfig_RoundTrip(t *testing.T) {
configPath := filepath.Join(tempDir, "config.yaml")
// Create config with all fields
original := &GlobalConfig{
Wildcloud: struct {
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
CurrentPhase string `yaml:"currentPhase,omitempty" json:"currentPhase,omitempty"`
CompletedPhases []string `yaml:"completedPhases,omitempty" json:"completedPhases,omitempty"`
}{
Repository: "https://github.com/example/repo",
CurrentPhase: "setup",
CompletedPhases: []string{"phase1", "phase2"},
},
Server: struct {
Port int `yaml:"port,omitempty" json:"port,omitempty"`
Host string `yaml:"host,omitempty" json:"host,omitempty"`
}{
Port: 8080,
Host: "localhost",
},
Operator: struct {
Email string `yaml:"email,omitempty" json:"email,omitempty"`
}{
Email: "admin@example.com",
},
}
original := &GlobalConfig{}
original.Operator.Email = "admin@example.com"
original.Cloud.DNS.IP = "192.168.1.1"
original.Cloud.DNS.ExternalResolver = "8.8.8.8"
original.Cloud.Router.IP = "192.168.1.254"
original.Cloud.Router.DynamicDns = "example.dyndns.org"
original.Cloud.Dnsmasq.Interface = "eth0"
// Save config
if err := SaveGlobalConfig(original, configPath); err != nil {
@@ -647,15 +526,18 @@ func TestGlobalConfig_RoundTrip(t *testing.T) {
}
// Verify all fields match
if loaded.Wildcloud.Repository != original.Wildcloud.Repository {
t.Errorf("repository mismatch: got %q, want %q", loaded.Wildcloud.Repository, original.Wildcloud.Repository)
}
if loaded.Server.Port != original.Server.Port {
t.Errorf("port mismatch: got %d, want %d", loaded.Server.Port, original.Server.Port)
}
if loaded.Operator.Email != original.Operator.Email {
t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email)
}
if loaded.Cloud.DNS.IP != original.Cloud.DNS.IP {
t.Errorf("DNS IP mismatch: got %q, want %q", loaded.Cloud.DNS.IP, original.Cloud.DNS.IP)
}
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.Dnsmasq.Interface != original.Cloud.Dnsmasq.Interface {
t.Errorf("dnsmasq interface mismatch: got %q, want %q", loaded.Cloud.Dnsmasq.Interface, original.Cloud.Dnsmasq.Interface)
}
}
// Test: Round-trip save and load for instance config

View File

@@ -2,8 +2,10 @@ package config
import (
"fmt"
"log"
"path/filepath"
"github.com/wild-cloud/wild-central/daemon/internal/network"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
"github.com/wild-cloud/wild-central/daemon/internal/tools"
)
@@ -20,6 +22,45 @@ func NewManager() *Manager {
}
}
// 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 {
log.Printf("Warning: Could not detect network info, using empty defaults: %v", err)
} else {
// Set detected values
initialConfig.Cloud.DNS.IP = netInfo.PrimaryIP
initialConfig.Cloud.DNS.ExternalResolver = "1.1.1.1" // Default external resolver
initialConfig.Cloud.Router.IP = netInfo.Gateway
initialConfig.Cloud.Dnsmasq.Interface = netInfo.PrimaryInterface
log.Printf("Detected network: IP=%s, Gateway=%s, Interface=%s",
netInfo.PrimaryIP, netInfo.Gateway, 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(instancePath string) error {
configPath := filepath.Join(instancePath, "config.yaml")

View File

@@ -901,9 +901,6 @@ func TestEnsureInstanceConfig_RequiredFields(t *testing.T) {
"domain:",
"internalDomain:",
"dhcpRange:",
"dns:",
"router:",
"dnsmasq:",
"nfs:",
"smtp:",
"cluster:",

View File

@@ -11,6 +11,7 @@ import (
type NetworkInfo struct {
PrimaryIP string `json:"primary_ip"`
PrimaryInterface string `json:"primary_interface"`
Gateway string `json:"gateway"`
}
// DetectNetworkInfo detects the machine's primary network configuration
@@ -28,6 +29,16 @@ func DetectNetworkInfo() (*NetworkInfo, error) {
line := string(output)
// Example output: "8.8.8.8 via 192.168.8.1 dev wlan0 src 192.168.8.152 uid 1000"
// Extract gateway (after "via")
gateway := ""
if idx := strings.Index(line, "via "); idx != -1 {
rest := line[idx+4:]
parts := strings.Fields(rest)
if len(parts) > 0 {
gateway = parts[0]
}
}
// Extract interface (after "dev")
iface := ""
if idx := strings.Index(line, "dev "); idx != -1 {
@@ -56,6 +67,7 @@ func DetectNetworkInfo() (*NetworkInfo, error) {
return &NetworkInfo{
PrimaryIP: ip,
PrimaryInterface: iface,
Gateway: gateway,
}, nil
}

View File

@@ -236,8 +236,8 @@ func fileExists(path string) bool {
}
func checkDNSConfigured(instanceConfig *config.InstanceConfig, globalConfig *config.GlobalConfig) bool {
// DNS is configured if we have DNS IP and DHCP range
return instanceConfig.Cloud.DNS.IP != "" &&
// DNS is configured if we have DNS IP (from global config) and DHCP range (from instance config)
return globalConfig.Cloud.DNS.IP != "" &&
instanceConfig.Cloud.DHCPRange != ""
}