Replace InstanceConfig with DNSEntry for dnsmasq config generation
InstanceConfig was a ~50-field struct designed for Wild Cloud k8s instances,
but only 3 fields were ever read by dnsmasq (the sole consumer). The
reconciliation bridge constructed fake InstanceConfig objects from registered
domains just to satisfy this interface.
Replace with DNSEntry{Domain, IP} — a 2-field struct purpose-built for
dnsmasq. All domains get local=/ directives to prevent AAAA queries from
leaking to upstream DNS (Happy Eyeballs / RFC 8305 latency on LAN).
Removed dead code: InstanceConfig, NodeConfig, LoadCloudConfig,
SaveCloudConfig, DeepMerge, LoadMergedInstanceConfig, EnsureInstanceConfig,
instance path helpers, instanceLoadBalancerIP, GenerateInstanceConfig,
and all modular per-instance dnsmasq methods.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
"github.com/wild-cloud/wild-central/internal/domains"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
)
|
)
|
||||||
@@ -205,14 +206,14 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dnsmasq instance configs the same way reconcileNetworking does
|
// Build dnsmasq entries the same way reconcileNetworking does
|
||||||
doms, err := api.domains.List()
|
doms, err := api.domains.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List failed: %v", err)
|
t.Fatalf("List failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
var instanceConfigs []config.InstanceConfig
|
var dnsEntries []dnsmasq.DNSEntry
|
||||||
|
|
||||||
for _, dom := range doms {
|
for _, dom := range doms {
|
||||||
if dom.DomainName == "" {
|
if dom.DomainName == "" {
|
||||||
@@ -224,19 +225,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
|||||||
dnsIP = extractHost(dom.Backend.Address)
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
Domain: dom.DomainName,
|
||||||
|
IP: dnsIP,
|
||||||
if !dom.Public {
|
})
|
||||||
ic.Cloud.InternalDomain = dom.DomainName
|
|
||||||
} else {
|
|
||||||
ic.Cloud.Domain = dom.DomainName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
}
|
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
|
|
||||||
|
|
||||||
// --- TCP passthrough (public) → backend IP ---
|
// --- TCP passthrough (public) → backend IP ---
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
@@ -251,17 +246,15 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
|
|||||||
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- internal → local=/ present ---
|
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
|
||||||
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
|
||||||
t.Errorf("internal domain must have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
||||||
// --- public → NO local=/ ---
|
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
||||||
if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
|
|
||||||
t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
|
||||||
t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
|
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,24 +338,19 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
|||||||
doms, _ := api.domains.List()
|
doms, _ := api.domains.List()
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
var instanceConfigs []config.InstanceConfig
|
var dnsEntries []dnsmasq.DNSEntry
|
||||||
for _, dom := range doms {
|
for _, dom := range doms {
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
if dom.Backend.Type == domains.BackendTCPPassthrough {
|
if dom.Backend.Type == domains.BackendTCPPassthrough {
|
||||||
dnsIP = extractHost(dom.Backend.Address)
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
}
|
}
|
||||||
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
ic := config.InstanceConfig{}
|
Domain: dom.DomainName,
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
IP: dnsIP,
|
||||||
if !dom.Public {
|
})
|
||||||
ic.Cloud.InternalDomain = dom.DomainName
|
|
||||||
} else {
|
|
||||||
ic.Cloud.Domain = dom.DomainName
|
|
||||||
}
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
|
|
||||||
// TCP passthrough → DNS points to backend (k8s LB), NOT central
|
// TCP passthrough → DNS points to backend (k8s LB), NOT central
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
@@ -440,7 +428,7 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
|
|||||||
|
|
||||||
// Build DNS config — dns-only should point to its backend IP
|
// Build DNS config — dns-only should point to its backend IP
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
var instanceConfigs []config.InstanceConfig
|
var dnsEntries []dnsmasq.DNSEntry
|
||||||
|
|
||||||
for _, dom := range doms {
|
for _, dom := range doms {
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
@@ -448,18 +436,13 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
|
|||||||
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||||
dnsIP = extractHost(dom.Backend.Address)
|
dnsIP = extractHost(dom.Backend.Address)
|
||||||
}
|
}
|
||||||
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
ic := config.InstanceConfig{}
|
Domain: dom.DomainName,
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
IP: dnsIP,
|
||||||
if !dom.Public {
|
})
|
||||||
ic.Cloud.InternalDomain = dom.DomainName
|
|
||||||
} else {
|
|
||||||
ic.Cloud.Domain = dom.DomainName
|
|
||||||
}
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
|
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
|
||||||
|
|
||||||
// dns-only domain should resolve to its backend IP (192.168.8.222)
|
// dns-only domain should resolve to its backend IP (192.168.8.222)
|
||||||
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
|
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
"github.com/wild-cloud/wild-central/internal/domains"
|
"github.com/wild-cloud/wild-central/internal/domains"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
@@ -111,8 +113,22 @@ func (api *API) reconcileNetworking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include L7 HTTP routes for domains that have a cert.
|
// Clean up any 0-byte cert files that would poison HAProxy validation.
|
||||||
|
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
|
||||||
|
// to fail, taking down ALL L7 routes — not just the broken domain.
|
||||||
certsDir := "/etc/haproxy/certs/"
|
certsDir := "/etc/haproxy/certs/"
|
||||||
|
if entries, err := os.ReadDir(certsDir); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".pem") {
|
||||||
|
if info, err := e.Info(); err == nil && info.Size() == 0 {
|
||||||
|
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
|
||||||
|
_ = os.Remove(filepath.Join(certsDir, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include L7 HTTP routes for domains that have a valid cert.
|
||||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||||
for _, route := range httpRoutes {
|
for _, route := range httpRoutes {
|
||||||
if hasCertForDomain(certsDir, route.Domain) {
|
if hasCertForDomain(certsDir, route.Domain) {
|
||||||
@@ -176,10 +192,10 @@ func (api *API) reconcileNetworking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate dnsmasq DNS entries from registered domains.
|
// Build dnsmasq DNS entries from registered domains.
|
||||||
// DNS target IP depends on domain type:
|
// DNS target IP depends on backend type:
|
||||||
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
|
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
|
||||||
// http/static: DNS → Central IP (HAProxy terminates TLS)
|
// http → Central IP (HAProxy terminates TLS)
|
||||||
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
||||||
// (auto-detect can pick wlan0 if that's the default route).
|
// (auto-detect can pick wlan0 if that's the default route).
|
||||||
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
||||||
@@ -190,34 +206,25 @@ func (api *API) reconcileNetworking() {
|
|||||||
centralIP = "127.0.0.1"
|
centralIP = "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
var instanceConfigs []config.InstanceConfig
|
var dnsEntries []dnsmasq.DNSEntry
|
||||||
for _, dom := range doms {
|
for _, dom := range doms {
|
||||||
if dom.DomainName == "" {
|
if dom.DomainName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose the DNS target IP based on domain type
|
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
bt := dom.EffectiveBackendType()
|
bt := dom.EffectiveBackendType()
|
||||||
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||||
// tcp-passthrough/dns-only: DNS resolves to the backend IP directly
|
|
||||||
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
||||||
ic.Cluster.LoadBalancerIp = dnsIP
|
Domain: dom.DomainName,
|
||||||
|
IP: dnsIP,
|
||||||
if !dom.Public {
|
})
|
||||||
// Internal-only: local=/ prevents upstream DNS forwarding
|
|
||||||
ic.Cloud.InternalDomain = dom.DomainName
|
|
||||||
} else {
|
|
||||||
ic.Cloud.Domain = dom.DomainName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceConfigs = append(instanceConfigs, ic)
|
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
|
|
||||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
||||||
} else {
|
} else {
|
||||||
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
||||||
@@ -273,35 +280,30 @@ func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasCertForDomain checks if a cert exists for a domain — either an individual
|
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
||||||
// cert (<domain>.pem) or a wildcard cert that covers it.
|
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
||||||
func hasCertForDomain(certsDir, domain string) bool {
|
func hasCertForDomain(_, domain string) bool {
|
||||||
// Check individual cert
|
// Check individual cert
|
||||||
if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil {
|
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check wildcard certs — a *.example.com cert covers foo.example.com
|
// Check wildcard certs — a *.example.com cert covers foo.example.com
|
||||||
parts := strings.SplitN(domain, ".", 2)
|
parts := strings.SplitN(domain, ".", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
// Wildcard cert might be stored as the base domain PEM
|
|
||||||
wildcardBase := parts[1]
|
wildcardBase := parts[1]
|
||||||
if _, err := os.Stat(certbot.HAProxyCertPath(wildcardBase)); err == nil {
|
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check if the certs directory has ANY .pem files (HAProxy needs at least one)
|
|
||||||
entries, err := os.ReadDir(certsDir)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if strings.HasSuffix(e.Name(), ".pem") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isValidCertFile checks that a cert file exists and is non-empty.
|
||||||
|
func isValidCertFile(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
// extractHost gets the host part from a host:port string
|
// extractHost gets the host part from a host:port string
|
||||||
func extractHost(addr string) string {
|
func extractHost(addr string) string {
|
||||||
for i := len(addr) - 1; i >= 0; i-- {
|
for i := len(addr) - 1; i >= 0; i-- {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -158,168 +157,3 @@ func (c *State) IsEmpty() bool {
|
|||||||
return c.Cloud.Central.Domain == "" && c.Operator.Email == ""
|
return c.Cloud.Central.Domain == "" && 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, "state.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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test: LoadState loads valid state
|
// Test: LoadState loads valid state
|
||||||
@@ -295,187 +293,6 @@ func TestState_IsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Test: Round-trip save and load preserves data
|
||||||
func TestState_RoundTrip(t *testing.T) {
|
func TestState_RoundTrip(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -506,187 +323,3 @@ func TestState_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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{
|
|
||||||
"central": map[string]any{"domain": "central.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
src: map[string]any{
|
|
||||||
"cloud": map[string]any{
|
|
||||||
"domain": "example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: map[string]any{
|
|
||||||
"cloud": map[string]any{
|
|
||||||
"central": map[string]any{"domain": "central.example.com"},
|
|
||||||
"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)
|
|
||||||
|
|
||||||
globalState := `operator:
|
|
||||||
email: test@example.com
|
|
||||||
cloud:
|
|
||||||
central:
|
|
||||||
domain: central.example.com
|
|
||||||
`
|
|
||||||
instanceConfig := `cluster:
|
|
||||||
name: test
|
|
||||||
nodes:
|
|
||||||
control:
|
|
||||||
vip: 192.168.1.100
|
|
||||||
cloud:
|
|
||||||
domain: cloud.example.com
|
|
||||||
`
|
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(dataDir, "state.yaml"), []byte(globalState), 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
central, ok := cloud["central"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("missing cloud.central key — global state not merged")
|
|
||||||
}
|
|
||||||
if central["domain"] != "central.example.com" {
|
|
||||||
t.Errorf("cloud.central.domain = %v, want central.example.com", central["domain"])
|
|
||||||
}
|
|
||||||
|
|
||||||
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 state not merged")
|
|
||||||
}
|
|
||||||
if operator["email"] != "test@example.com" {
|
|
||||||
t.Errorf("operator.email = %v, want test@example.com", operator["email"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadMergedInstanceConfig_NoGlobalState(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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -87,31 +87,6 @@ func NewManager() *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// GetConfigValue retrieves a value from a config file
|
||||||
func (m *Manager) GetConfigValue(configPath, key string) (string, error) {
|
func (m *Manager) GetConfigValue(configPath, key string) (string, error) {
|
||||||
if !storage.FileExists(configPath) {
|
if !storage.FileExists(configPath) {
|
||||||
@@ -186,17 +161,3 @@ func (m *Manager) CopyConfig(srcPath, dstPath string) error {
|
|||||||
return storage.WriteFile(dstPath, content, 0644)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,139 +17,6 @@ func TestNewManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Test: GetConfigValue retrieves values correctly
|
||||||
func TestGetConfigValue(t *testing.T) {
|
func TestGetConfigValue(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -821,102 +687,3 @@ func TestCopyConfig_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
|
||||||
|
// Each entry produces both local=/ and address=/ directives.
|
||||||
|
// local=/ makes dnsmasq authoritative for the domain, which prevents
|
||||||
|
// AAAA queries from leaking to upstream DNS. Without it, upstream could
|
||||||
|
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
|
||||||
|
// would try the unreachable IPv6 path first, adding latency on LAN.
|
||||||
|
type DNSEntry struct {
|
||||||
|
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
||||||
|
IP string // target IPv4 address
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigGenerator handles dnsmasq configuration generation
|
// ConfigGenerator handles dnsmasq configuration generation
|
||||||
type ConfigGenerator struct {
|
type ConfigGenerator struct {
|
||||||
configPath string
|
configPath string
|
||||||
@@ -33,9 +44,9 @@ func (g *ConfigGenerator) GetConfigPath() string {
|
|||||||
return g.configPath
|
return g.configPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates a dnsmasq configuration from the app config
|
// Generate creates a dnsmasq configuration from registered domain entries.
|
||||||
// It auto-detects the Wild Central server's IP address
|
// It auto-detects the Wild Central server's IP address for the listen directive.
|
||||||
func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string {
|
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
|
||||||
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
||||||
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
||||||
// when dnsmasq is only listening on eth0.
|
// when dnsmasq is only listening on eth0.
|
||||||
@@ -53,30 +64,16 @@ func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolution_section := ""
|
resolution_section := ""
|
||||||
for _, cloud := range clouds {
|
for _, entry := range entries {
|
||||||
// Point cloud domains to the cluster load balancer IP
|
if entry.Domain == "" || entry.IP == "" {
|
||||||
loadBalancerIP := instanceLoadBalancerIP(cloud)
|
|
||||||
if loadBalancerIP == "" {
|
|
||||||
slog.Info("no load balancer IP configured, adding commented DNS config", "component", "dnsmasq", "instance", cloud.Cluster.Name)
|
|
||||||
// Add commented out entries for instances without load balancer
|
|
||||||
resolution_section += fmt.Sprintf("# No load balancer IP configured for instance %s\n", cloud.Cluster.Name)
|
|
||||||
if cloud.Cloud.InternalDomain != "" {
|
|
||||||
resolution_section += fmt.Sprintf("# local=/%s/\n# address=/%s/<load-balancer-ip>\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain)
|
|
||||||
}
|
|
||||||
if cloud.Cloud.Domain != "" {
|
|
||||||
resolution_section += fmt.Sprintf("# address=/%s/<load-balancer-ip>\n", cloud.Cloud.Domain)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
|
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
|
||||||
if cloud.Cloud.InternalDomain != "" {
|
// return empty instead of leaking to upstream DNS. Without this,
|
||||||
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP)
|
// upstream could return public IPv6 records and browsers using Happy
|
||||||
}
|
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
|
||||||
|
// adding 250ms-2s latency on LAN.
|
||||||
// External/primary domain - resolve to backend IP
|
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
|
||||||
if cloud.Cloud.Domain != "" {
|
|
||||||
resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
template := `# Configuration file for dnsmasq.
|
template := `# Configuration file for dnsmasq.
|
||||||
@@ -104,8 +101,8 @@ log-dhcp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteConfig writes the dnsmasq configuration to the specified path
|
// WriteConfig writes the dnsmasq configuration to the specified path
|
||||||
func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error {
|
func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error {
|
||||||
configContent := g.Generate(cfg, clouds)
|
configContent := g.Generate(cfg, entries)
|
||||||
|
|
||||||
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
||||||
|
|
||||||
@@ -134,7 +131,7 @@ type ServiceStatus struct {
|
|||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
ConfigFile string `json:"config_file"`
|
ConfigFile string `json:"config_file"`
|
||||||
InstancesConfigured int `json:"instances_configured"`
|
DomainsConfigured int `json:"domains_configured"`
|
||||||
LastRestart time.Time `json:"last_restart"`
|
LastRestart time.Time `json:"last_restart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,13 +187,9 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count instances in config (both active and commented)
|
// Count domains in config by counting local=/ directives
|
||||||
if data, err := os.ReadFile(g.configPath); err == nil {
|
if data, err := os.ReadFile(g.configPath); err == nil {
|
||||||
// Count both "local=/" and "# local=/" occurrences
|
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
|
||||||
activeCount := strings.Count(string(data), "\nlocal=/")
|
|
||||||
commentedCount := strings.Count(string(data), "\n# local=/")
|
|
||||||
// Each instance creates 1 "local=/" entry (internal domain)
|
|
||||||
status.InstancesConfigured = activeCount + commentedCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return status, nil
|
return status, nil
|
||||||
@@ -211,10 +204,10 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
|
// UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts.
|
||||||
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error {
|
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
|
||||||
// Generate fresh config from scratch
|
// Generate fresh config from scratch
|
||||||
configContent := g.Generate(cfg, instances)
|
configContent := g.Generate(cfg, entries)
|
||||||
|
|
||||||
// Write config
|
// Write config
|
||||||
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)
|
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)
|
||||||
|
|||||||
@@ -3,21 +3,15 @@ package dnsmasq
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
instanceConfigDir = "/etc/dnsmasq.d/wild-cloud-instances"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
||||||
// and a conf-dir directive to include per-instance configs
|
// and a conf-dir directive to include per-domain configs
|
||||||
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
||||||
// Get the Wild Central IP address
|
// Get the Wild Central IP address
|
||||||
dnsIP, err := network.GetWildCentralIP()
|
dnsIP, err := network.GetWildCentralIP()
|
||||||
@@ -29,8 +23,8 @@ func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
||||||
# This file contains global settings. Instance-specific DNS entries are in:
|
# This file contains global settings. Domain-specific DNS entries are
|
||||||
# /etc/dnsmasq.d/wild-cloud-instances/*.conf
|
# generated by reconciliation into the monolithic config.
|
||||||
|
|
||||||
# Basic Settings
|
# Basic Settings
|
||||||
listen-address=%s
|
listen-address=%s
|
||||||
@@ -39,9 +33,6 @@ domain-needed
|
|||||||
bogus-priv
|
bogus-priv
|
||||||
no-resolv
|
no-resolv
|
||||||
|
|
||||||
# Include per-instance DNS configurations
|
|
||||||
conf-dir=%s,*.conf
|
|
||||||
|
|
||||||
# Upstream DNS servers
|
# Upstream DNS servers
|
||||||
server=1.1.1.1
|
server=1.1.1.1
|
||||||
server=8.8.8.8
|
server=8.8.8.8
|
||||||
@@ -49,7 +40,7 @@ server=8.8.8.8
|
|||||||
# Logging
|
# Logging
|
||||||
log-queries
|
log-queries
|
||||||
log-dhcp
|
log-dhcp
|
||||||
`, dnsIP, instanceConfigDir)
|
`, dnsIP)
|
||||||
|
|
||||||
// DHCP section (only when explicitly enabled)
|
// DHCP section (only when explicitly enabled)
|
||||||
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
||||||
@@ -82,57 +73,6 @@ log-dhcp
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanceLoadBalancerIP returns the load balancer IP for an instance.
|
|
||||||
// It checks cluster.loadBalancerIp first, then falls back to apps.metallb.loadBalancerIp,
|
|
||||||
// which is where the normal app install flow writes it.
|
|
||||||
func instanceLoadBalancerIP(instance config.InstanceConfig) string {
|
|
||||||
if instance.Cluster.LoadBalancerIp != "" {
|
|
||||||
return instance.Cluster.LoadBalancerIp
|
|
||||||
}
|
|
||||||
if metallb, ok := instance.Apps["metallb"].(map[string]any); ok {
|
|
||||||
if ip, ok := metallb["loadBalancerIp"].(string); ok {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateInstanceConfig creates a DNS configuration for a single instance
|
|
||||||
func (g *ConfigGenerator) GenerateInstanceConfig(instance config.InstanceConfig) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "# DNS configuration for instance: %s\n", instance.Cluster.Name)
|
|
||||||
sb.WriteString("# Generated by Wild Cloud\n\n")
|
|
||||||
|
|
||||||
loadBalancerIP := instanceLoadBalancerIP(instance)
|
|
||||||
if loadBalancerIP == "" {
|
|
||||||
sb.WriteString("# WARNING: No load balancer IP configured for this instance\n")
|
|
||||||
sb.WriteString("# DNS entries are commented out until load balancer IP is configured\n\n")
|
|
||||||
if instance.Cloud.InternalDomain != "" {
|
|
||||||
fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain)
|
|
||||||
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n\n", instance.Cloud.InternalDomain)
|
|
||||||
}
|
|
||||||
if instance.Cloud.Domain != "" {
|
|
||||||
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n", instance.Cloud.Domain)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
|
|
||||||
if instance.Cloud.InternalDomain != "" {
|
|
||||||
sb.WriteString("# Internal domain (LAN-only)\n")
|
|
||||||
fmt.Fprintf(&sb, "local=/%s/\n", instance.Cloud.InternalDomain)
|
|
||||||
fmt.Fprintf(&sb, "address=/%s/%s\n\n", instance.Cloud.InternalDomain, loadBalancerIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// External/primary domain - resolve to backend IP
|
|
||||||
if instance.Cloud.Domain != "" {
|
|
||||||
sb.WriteString("# Public domain (resolved locally to avoid external DNS)\n")
|
|
||||||
fmt.Fprintf(&sb, "address=/%s/%s\n", instance.Cloud.Domain, loadBalancerIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
||||||
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||||
// Use dnsmasq --test to validate the configuration
|
// Use dnsmasq --test to validate the configuration
|
||||||
@@ -150,104 +90,6 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteInstanceConfig writes the DNS configuration for a single instance
|
|
||||||
func (g *ConfigGenerator) WriteInstanceConfig(instanceName string, instance config.InstanceConfig) error {
|
|
||||||
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
|
|
||||||
configContent := g.GenerateInstanceConfig(instance)
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating instance config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to temp file first
|
|
||||||
tempFile := instanceFile + ".tmp"
|
|
||||||
if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing temp instance config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the temp config along with main config
|
|
||||||
if err := g.ValidateWithInstance(tempFile); err != nil {
|
|
||||||
os.Remove(tempFile) // Clean up temp file
|
|
||||||
return fmt.Errorf("instance config validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move temp file to final location (atomic operation)
|
|
||||||
if err := os.Rename(tempFile, instanceFile); err != nil {
|
|
||||||
os.Remove(tempFile) // Clean up temp file
|
|
||||||
return fmt.Errorf("installing instance config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("wrote instance DNS config", "component", "dnsmasq", "path", instanceFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateWithInstance validates the main config along with a specific instance config
|
|
||||||
func (g *ConfigGenerator) ValidateWithInstance(instanceConfigPath string) error {
|
|
||||||
// Create a temporary test directory
|
|
||||||
tempDir, err := os.MkdirTemp("", "dnsmasq-test-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating temp dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Copy main config to temp
|
|
||||||
mainContent, err := os.ReadFile(g.configPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading main config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempMainConfig := filepath.Join(tempDir, "main.conf")
|
|
||||||
// Modify the conf-dir line to point to our temp instance dir
|
|
||||||
tempInstanceDir := filepath.Join(tempDir, "instances")
|
|
||||||
if err := os.MkdirAll(tempInstanceDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating temp instance dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modifiedContent := strings.ReplaceAll(
|
|
||||||
string(mainContent),
|
|
||||||
fmt.Sprintf("conf-dir=%s,*.conf", instanceConfigDir),
|
|
||||||
fmt.Sprintf("conf-dir=%s,*.conf", tempInstanceDir),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := os.WriteFile(tempMainConfig, []byte(modifiedContent), 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing temp main config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy instance config to temp
|
|
||||||
instanceContent, err := os.ReadFile(instanceConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading instance config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tempInstanceConfig := filepath.Join(tempInstanceDir, filepath.Base(instanceConfigPath))
|
|
||||||
if err := os.WriteFile(tempInstanceConfig, instanceContent, 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing temp instance config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the combined configuration
|
|
||||||
return g.ValidateConfig(tempMainConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveInstanceConfig removes the DNS configuration for an instance
|
|
||||||
func (g *ConfigGenerator) RemoveInstanceConfig(instanceName string) error {
|
|
||||||
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if _, err := os.Stat(instanceFile); os.IsNotExist(err) {
|
|
||||||
slog.Info("instance DNS config does not exist", "component", "dnsmasq", "path", instanceFile)
|
|
||||||
return nil // Not an error, already removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the file
|
|
||||||
if err := os.Remove(instanceFile); err != nil {
|
|
||||||
return fmt.Errorf("removing instance config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("removed instance DNS config", "component", "dnsmasq", "path", instanceFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration
|
// ReloadService sends a SIGHUP to dnsmasq to reload configuration
|
||||||
// This is lighter weight than a full restart
|
// This is lighter weight than a full restart
|
||||||
func (g *ConfigGenerator) ReloadService() error {
|
func (g *ConfigGenerator) ReloadService() error {
|
||||||
@@ -262,81 +104,3 @@ func (g *ConfigGenerator) ReloadService() error {
|
|||||||
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateToModularConfig migrates from monolithic to modular configuration
|
|
||||||
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.State, instanceNames []string, instances []config.InstanceConfig) error {
|
|
||||||
slog.Info("migrating to modular configuration", "component", "dnsmasq")
|
|
||||||
|
|
||||||
// Ensure instance directory exists
|
|
||||||
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating instance config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate and write instance configs first (but don't reload yet)
|
|
||||||
for i, instance := range instances {
|
|
||||||
instanceName := instanceNames[i]
|
|
||||||
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
|
|
||||||
slog.Error("failed to write instance config", "component", "dnsmasq", "instance", instanceName, "error", err)
|
|
||||||
// Continue with other instances
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new main config with conf-dir
|
|
||||||
mainConfig := g.GenerateMainConfig(cfg)
|
|
||||||
|
|
||||||
// Write to temp file first
|
|
||||||
tempFile := g.configPath + ".tmp"
|
|
||||||
if err := os.WriteFile(tempFile, []byte(mainConfig), 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing temp main config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the new config
|
|
||||||
if err := g.ValidateConfig(tempFile); err != nil {
|
|
||||||
os.Remove(tempFile)
|
|
||||||
return fmt.Errorf("main config validation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup current config
|
|
||||||
backupFile := g.configPath + ".pre-modular"
|
|
||||||
if err := os.Rename(g.configPath, backupFile); err != nil {
|
|
||||||
os.Remove(tempFile)
|
|
||||||
return fmt.Errorf("backing up current config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install new config
|
|
||||||
if err := os.Rename(tempFile, g.configPath); err != nil {
|
|
||||||
// Try to restore backup
|
|
||||||
_ = os.Rename(backupFile, g.configPath)
|
|
||||||
return fmt.Errorf("installing new config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload dnsmasq
|
|
||||||
if err := g.ReloadService(); err != nil {
|
|
||||||
// Try to restore backup and reload
|
|
||||||
slog.Error("reload failed, restoring backup", "component", "dnsmasq", "error", err)
|
|
||||||
os.Remove(g.configPath)
|
|
||||||
_ = os.Rename(backupFile, g.configPath)
|
|
||||||
_ = g.ReloadService()
|
|
||||||
return fmt.Errorf("reloading with new config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("migrated to modular configuration", "component", "dnsmasq")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateInstanceDNS updates DNS configuration for a single instance
|
|
||||||
// This is called when instance configuration changes (e.g., domain names)
|
|
||||||
func (g *ConfigGenerator) UpdateInstanceDNS(instanceName string, instance config.InstanceConfig) error {
|
|
||||||
// Write the new instance config
|
|
||||||
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
|
|
||||||
return fmt.Errorf("writing instance DNS config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload dnsmasq to pick up changes
|
|
||||||
if err := g.ReloadService(); err != nil {
|
|
||||||
return fmt.Errorf("reloading dnsmasq: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("DNS updated for instance", "component", "dnsmasq", "instance", instanceName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,141 +8,42 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig {
|
func entry(domain, ip string) DNSEntry {
|
||||||
var inst config.InstanceConfig
|
return DNSEntry{Domain: domain, IP: ip}
|
||||||
inst.Cluster.Name = "test"
|
|
||||||
inst.Cloud.Domain = domain
|
|
||||||
inst.Cloud.InternalDomain = internalDomain
|
|
||||||
inst.Cluster.LoadBalancerIp = lbIP
|
|
||||||
return inst
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp
|
// Test: Generate with entries produces expected DNS entries
|
||||||
func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) {
|
func TestGenerate_WithEntries(t *testing.T) {
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
|
||||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
|
||||||
t.Errorf("got %q, want %q", got, "10.0.0.5")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: instanceLoadBalancerIP falls back to apps.metallb.loadBalancerIp
|
|
||||||
func TestInstanceLoadBalancerIP_MetalLBFallback(t *testing.T) {
|
|
||||||
var inst config.InstanceConfig
|
|
||||||
inst.Apps = map[string]any{
|
|
||||||
"metallb": map[string]any{
|
|
||||||
"loadBalancerIp": "10.0.0.9",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.9" {
|
|
||||||
t.Errorf("got %q, want %q", got, "10.0.0.9")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: instanceLoadBalancerIP returns empty string when nothing is set
|
|
||||||
func TestInstanceLoadBalancerIP_Empty(t *testing.T) {
|
|
||||||
var inst config.InstanceConfig
|
|
||||||
if got := instanceLoadBalancerIP(inst); got != "" {
|
|
||||||
t.Errorf("got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: cluster.loadBalancerIp takes precedence over metallb
|
|
||||||
func TestInstanceLoadBalancerIP_ClusterTakesPrecedence(t *testing.T) {
|
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
|
||||||
inst.Apps = map[string]any{
|
|
||||||
"metallb": map[string]any{
|
|
||||||
"loadBalancerIp": "10.0.0.9",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
|
||||||
t.Errorf("got %q, want cluster IP %q", got, "10.0.0.5")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: GenerateInstanceConfig with load balancer IP produces active DNS entries
|
|
||||||
func TestGenerateInstanceConfig_WithLBIP(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
entries := []DNSEntry{
|
||||||
|
entry("cloud.example.com", "192.168.1.10"),
|
||||||
out := g.GenerateInstanceConfig(inst)
|
entry("internal.example.com", "192.168.1.10"),
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
|
||||||
t.Errorf("expected local=/ directive for internal domain, got:\n%s", out)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
|
||||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
out := g.Generate(nil, entries)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "local=/cloud.example.com/") {
|
||||||
|
t.Errorf("expected local=/ for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
||||||
t.Errorf("expected address entry for external domain, got:\n%s", out)
|
t.Errorf("expected address entry for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "WARNING") {
|
|
||||||
t.Errorf("unexpected WARNING in output:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: GenerateInstanceConfig without load balancer IP produces commented entries
|
|
||||||
func TestGenerateInstanceConfig_NoLBIP(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
|
||||||
|
|
||||||
out := g.GenerateInstanceConfig(inst)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "WARNING") {
|
|
||||||
t.Errorf("expected WARNING in output when no LB IP, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "# local=/internal.example.com/") {
|
|
||||||
t.Errorf("expected commented local=/ directive, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "# address=/internal.example.com/<load-balancer-ip>") {
|
|
||||||
t.Errorf("expected commented address entry for internal domain, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "# address=/cloud.example.com/<load-balancer-ip>") {
|
|
||||||
t.Errorf("expected commented address entry for external domain, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if strings.Contains(out, "\nlocal=/") {
|
|
||||||
t.Errorf("unexpected active local=/ directive when no LB IP, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: GenerateInstanceConfig includes instance name in header comment
|
|
||||||
func TestGenerateInstanceConfig_IncludesInstanceName(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
|
||||||
inst.Cluster.Name = "my-instance"
|
|
||||||
|
|
||||||
out := g.GenerateInstanceConfig(inst)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "my-instance") {
|
|
||||||
t.Errorf("expected instance name in config header, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: Generate (monolithic) with instances produces expected DNS entries
|
|
||||||
func TestGenerate_WithInstances(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
|
||||||
|
|
||||||
out := g.Generate(nil, []config.InstanceConfig{inst})
|
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
if !strings.Contains(out, "local=/internal.example.com/") {
|
||||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
||||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
|
||||||
t.Errorf("expected address entry for external domain, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "server=1.1.1.1") {
|
if !strings.Contains(out, "server=1.1.1.1") {
|
||||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Generate with no instances still produces valid config skeleton
|
// Test: Generate with no entries still produces valid config skeleton
|
||||||
func TestGenerate_NoInstances(t *testing.T) {
|
func TestGenerate_NoEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
|
||||||
out := g.Generate(nil, []config.InstanceConfig{})
|
out := g.Generate(nil, []DNSEntry{})
|
||||||
|
|
||||||
if !strings.Contains(out, "bind-interfaces") {
|
if !strings.Contains(out, "bind-interfaces") {
|
||||||
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
|
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
|
||||||
@@ -155,31 +56,23 @@ func TestGenerate_NoInstances(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Generate with instance missing LB IP produces commented entries
|
// Test: Generate skips entries with empty IP
|
||||||
func TestGenerate_InstanceWithoutLBIP(t *testing.T) {
|
func TestGenerate_EntryWithoutIP(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
|
||||||
inst.Cluster.Name = "no-lb-instance"
|
|
||||||
|
|
||||||
out := g.Generate(nil, []config.InstanceConfig{inst})
|
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
|
||||||
|
|
||||||
if !strings.Contains(out, "# No load balancer IP configured for instance no-lb-instance") {
|
if strings.Contains(out, "address=/") {
|
||||||
t.Errorf("expected comment about missing LB IP, got:\n%s", out)
|
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: GenerateMainConfig produces conf-dir directive
|
// Test: GenerateMainConfig produces valid base config
|
||||||
func TestGenerateMainConfig(t *testing.T) {
|
func TestGenerateMainConfig(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
|
||||||
out := g.GenerateMainConfig(nil)
|
out := g.GenerateMainConfig(nil)
|
||||||
|
|
||||||
if !strings.Contains(out, "conf-dir=") {
|
|
||||||
t.Errorf("expected conf-dir directive in main config, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, instanceConfigDir) {
|
|
||||||
t.Errorf("expected instance config dir %q in conf-dir, got:\n%s", instanceConfigDir, out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "bind-interfaces") {
|
if !strings.Contains(out, "bind-interfaces") {
|
||||||
t.Errorf("expected bind-interfaces in main config, got:\n%s", out)
|
t.Errorf("expected bind-interfaces in main config, got:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -228,7 +121,6 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
|||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
// No gateway set
|
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
@@ -282,7 +174,6 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
|||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
// LeaseTime not set
|
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
@@ -307,181 +198,117 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Service registration — domain set but no internal domain
|
// Test: Domain-only entry (no internal domain pair)
|
||||||
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
|
func TestGenerate_DomainOnly(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Service registration: only domain + backend IP, no internal domain
|
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
|
||||||
inst := instanceWith("my-api.payne.io", "", "192.168.8.151")
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
|
|
||||||
|
|
||||||
// Should have address entry for the domain
|
|
||||||
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
|
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
|
||||||
t.Errorf("expected address entry for service domain, got:\n%s", out)
|
t.Errorf("expected address entry for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// Must NOT have empty local=// entry
|
|
||||||
if strings.Contains(out, "local=//") {
|
|
||||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
|
||||||
}
|
|
||||||
// Must NOT have empty address=// entry
|
|
||||||
if strings.Contains(out, "address=//") {
|
|
||||||
t.Errorf("unexpected empty address=// in output:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: GenerateInstanceConfig — service without internal domain
|
|
||||||
func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
inst := instanceWith("central.payne.io", "", "192.168.8.151")
|
|
||||||
|
|
||||||
out := g.GenerateInstanceConfig(inst)
|
|
||||||
|
|
||||||
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
|
|
||||||
t.Errorf("expected address entry, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if strings.Contains(out, "local=//") {
|
|
||||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
|
||||||
}
|
|
||||||
if strings.Contains(out, "address=//") {
|
|
||||||
t.Errorf("unexpected empty address=// in output:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
|
|
||||||
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
globalCfg := &config.State{}
|
|
||||||
|
|
||||||
// Simulate reconciliation for an internal-reach service:
|
|
||||||
// InternalDomain is set (reach:internal), Domain is empty (reach:public)
|
|
||||||
inst := instanceWith("", "my-api.payne.io", "192.168.8.151")
|
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
|
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/my-api.payne.io/") {
|
if !strings.Contains(out, "local=/my-api.payne.io/") {
|
||||||
t.Errorf("reach:internal must produce local=/ directive, got:\n%s", out)
|
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
|
// Must NOT have empty entries
|
||||||
t.Errorf("reach:internal must produce address entry, got:\n%s", out)
|
if strings.Contains(out, "local=//") {
|
||||||
|
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "address=//") {
|
||||||
|
t.Errorf("unexpected empty address=// in output:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: reach:public → no local=/ directive (allows upstream DNS forwarding)
|
// Test: All domains get local=/ to prevent AAAA leaking upstream
|
||||||
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
|
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
// Simulate reconciliation for a public-reach service:
|
// Both "internal" and "public" domains should get local=/ —
|
||||||
// Domain is set (reach:public), InternalDomain is empty
|
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
|
||||||
inst := instanceWith("cloud.payne.io", "", "192.168.8.240")
|
entries := []DNSEntry{
|
||||||
|
entry("internal-api.payne.io", "192.168.8.151"),
|
||||||
|
entry("cloud.payne.io", "192.168.8.240"),
|
||||||
|
}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "local=/internal-api.payne.io/") {
|
||||||
|
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "local=/cloud.payne.io/") {
|
||||||
|
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Mix of domains each get their own entries
|
||||||
|
func TestGenerate_MixedDomains(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
|
entries := []DNSEntry{
|
||||||
|
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
|
||||||
|
entry("central.payne.io", "192.168.8.151"), // Central HTTP
|
||||||
|
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
|
||||||
t.Errorf("reach:public must produce address entry, got:\n%s", out)
|
t.Errorf("expected address for cloud domain, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "local=/cloud.payne.io/") {
|
|
||||||
t.Errorf("reach:public must NOT produce local=/ directive, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: InternalDomain field → local=/ entry for LAN-only resolution
|
|
||||||
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
globalCfg := &config.State{}
|
|
||||||
|
|
||||||
// Instance with only an internal domain (no external domain)
|
|
||||||
inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240")
|
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
|
|
||||||
|
|
||||||
if !strings.Contains(out, "local=/internal.cloud.payne.io/") {
|
|
||||||
t.Errorf("InternalDomain must produce local=/ entry, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "address=/internal.cloud.payne.io/192.168.8.240") {
|
|
||||||
t.Errorf("InternalDomain must produce address entry, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// No external domain → no address for empty domain
|
|
||||||
if strings.Contains(out, "address=//") {
|
|
||||||
t.Errorf("no external domain must not produce empty address=// entry, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: Mix of instances with and without internal domains
|
|
||||||
func TestGenerate_MixedServicesAndInstances(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
globalCfg := &config.State{}
|
|
||||||
|
|
||||||
instances := []config.InstanceConfig{
|
|
||||||
instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance
|
|
||||||
instanceWith("central.payne.io", "", "192.168.8.151"), // service (no internal domain)
|
|
||||||
instanceWith("wild-cloud.payne.io", "", "192.168.8.151"), // service (no internal domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := g.Generate(globalCfg, instances)
|
|
||||||
|
|
||||||
// k8s instance should have both local= and address= for internal domain
|
|
||||||
if !strings.Contains(out, "local=/internal.cloud.payne.io/") {
|
|
||||||
t.Errorf("expected local=/ for k8s internal domain, got:\n%s", out)
|
|
||||||
}
|
|
||||||
// Services should have address= entries
|
|
||||||
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
|
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
|
||||||
t.Errorf("expected address for central, got:\n%s", out)
|
t.Errorf("expected address for central, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// No broken empty entries
|
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
|
||||||
|
t.Errorf("expected address for wild-cloud, got:\n%s", out)
|
||||||
|
}
|
||||||
if strings.Contains(out, "local=//") {
|
if strings.Contains(out, "local=//") {
|
||||||
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
t.Errorf("unexpected empty local=// in output:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: empty instances list produces base config with no address= entries
|
// Test: empty entries list produces base config with no address= entries
|
||||||
func TestGenerate_EmptyInstances(t *testing.T) {
|
func TestGenerate_EmptyEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, []config.InstanceConfig{})
|
out := g.Generate(globalCfg, []DNSEntry{})
|
||||||
|
|
||||||
// Should have base config skeleton
|
|
||||||
if !strings.Contains(out, "bind-interfaces") {
|
if !strings.Contains(out, "bind-interfaces") {
|
||||||
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
|
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "server=1.1.1.1") {
|
if !strings.Contains(out, "server=1.1.1.1") {
|
||||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// Should not have any address= entries
|
|
||||||
if strings.Contains(out, "address=/") {
|
if strings.Contains(out, "address=/") {
|
||||||
t.Errorf("expected no address= entries with empty instances, got:\n%s", out)
|
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// Should not have any local=/ entries
|
|
||||||
if strings.Contains(out, "local=/") {
|
if strings.Contains(out, "local=/") {
|
||||||
t.Errorf("expected no local=/ entries with empty instances, got:\n%s", out)
|
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: multiple instances each get their own entries
|
// Test: multiple entries each get their own DNS records
|
||||||
func TestGenerate_MultipleInstances(t *testing.T) {
|
func TestGenerate_MultipleEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
globalCfg := &config.State{}
|
globalCfg := &config.State{}
|
||||||
|
|
||||||
instances := []config.InstanceConfig{
|
entries := []DNSEntry{
|
||||||
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"),
|
entry("cloud1.example.com", "10.0.0.1"),
|
||||||
instanceWith("cloud2.example.com", "internal2.example.com", "10.0.0.2"),
|
entry("cloud2.example.com", "10.0.0.2"),
|
||||||
instanceWith("cloud3.example.com", "internal3.example.com", "10.0.0.3"),
|
entry("cloud3.example.com", "10.0.0.3"),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := g.Generate(globalCfg, instances)
|
out := g.Generate(globalCfg, entries)
|
||||||
|
|
||||||
for i, inst := range instances {
|
for _, e := range entries {
|
||||||
ip := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}[i]
|
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
|
||||||
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.Domain, ip)) {
|
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
|
||||||
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.Domain, out)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.InternalDomain, ip)) {
|
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
|
||||||
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.InternalDomain, out)
|
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
|
||||||
}
|
|
||||||
if !strings.Contains(out, fmt.Sprintf("local=/%s/", inst.Cloud.InternalDomain)) {
|
|
||||||
t.Errorf("expected local=/ entry for %s, got:\n%s", inst.Cloud.InternalDomain, out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,8 +317,7 @@ func TestGenerate_MultipleInstances(t *testing.T) {
|
|||||||
func TestGenerate_NilConfig(t *testing.T) {
|
func TestGenerate_NilConfig(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
|
||||||
// Should not panic with nil config
|
out := g.Generate(nil, []DNSEntry{})
|
||||||
out := g.Generate(nil, []config.InstanceConfig{})
|
|
||||||
|
|
||||||
if !strings.Contains(out, "bind-interfaces") {
|
if !strings.Contains(out, "bind-interfaces") {
|
||||||
t.Errorf("expected valid config with nil cfg, got:\n%s", out)
|
t.Errorf("expected valid config with nil cfg, got:\n%s", out)
|
||||||
@@ -507,32 +333,31 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
|
|||||||
cfg := &config.State{}
|
cfg := &config.State{}
|
||||||
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
|
||||||
|
|
||||||
out := g.Generate(cfg, []config.InstanceConfig{})
|
out := g.Generate(cfg, []DNSEntry{})
|
||||||
|
|
||||||
if !strings.Contains(out, "listen-address=192.168.8.100") {
|
if !strings.Contains(out, "listen-address=192.168.8.100") {
|
||||||
t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out)
|
t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: verify no address=// or local=// entries appear in any test output
|
// Test: verify no address=// or local=// entries appear
|
||||||
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.State{}
|
cfg := &config.State{}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
instances []config.InstanceConfig
|
entries []DNSEntry
|
||||||
}{
|
}{
|
||||||
{"empty", nil},
|
{"empty", nil},
|
||||||
{"domain_only", []config.InstanceConfig{instanceWith("example.com", "", "10.0.0.1")}},
|
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
|
||||||
{"internal_only", []config.InstanceConfig{instanceWith("", "internal.example.com", "10.0.0.1")}},
|
{"no_ip", []DNSEntry{entry("example.com", "")}},
|
||||||
{"both", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "10.0.0.1")}},
|
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
|
||||||
{"no_lb_ip", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "")}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
out := g.Generate(cfg, tc.instances)
|
out := g.Generate(cfg, tc.entries)
|
||||||
if strings.Contains(out, "address=//") {
|
if strings.Contains(out, "address=//") {
|
||||||
t.Errorf("leaked empty address=// in output:\n%s", out)
|
t.Errorf("leaked empty address=// in output:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -542,23 +367,3 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: GenerateInstanceConfig with all fields empty doesn't produce broken entries
|
|
||||||
func TestGenerateInstanceConfig_EmptyFields(t *testing.T) {
|
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
|
||||||
var inst config.InstanceConfig
|
|
||||||
// All fields empty: no domain, no internal domain, no LB IP
|
|
||||||
|
|
||||||
out := g.GenerateInstanceConfig(inst)
|
|
||||||
|
|
||||||
if strings.Contains(out, "address=//") {
|
|
||||||
t.Errorf("empty fields produced broken address=// entry:\n%s", out)
|
|
||||||
}
|
|
||||||
if strings.Contains(out, "local=//") {
|
|
||||||
t.Errorf("empty fields produced broken local=// entry:\n%s", out)
|
|
||||||
}
|
|
||||||
// Should still contain the header comment
|
|
||||||
if !strings.Contains(out, "DNS configuration for instance") {
|
|
||||||
t.Errorf("expected header comment in output, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,73 +5,6 @@ export interface Status {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Instance Config Types (Wild Cloud instance level)
|
|
||||||
// Endpoint: /api/v1/instances/{name}/config
|
|
||||||
// File: {dataDir}/instances/{name}/config.yaml
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export interface NodeConfig {
|
|
||||||
role: string;
|
|
||||||
interface: string;
|
|
||||||
disk: string;
|
|
||||||
currentIp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstanceConfig {
|
|
||||||
operator?: {
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
cloud?: {
|
|
||||||
baseDomain?: string;
|
|
||||||
domain?: string;
|
|
||||||
internalDomain?: string;
|
|
||||||
dhcpRange?: string;
|
|
||||||
nfs?: {
|
|
||||||
host?: string;
|
|
||||||
mediaPath?: string;
|
|
||||||
storageCapacity?: string;
|
|
||||||
};
|
|
||||||
dockerRegistryHost?: string;
|
|
||||||
};
|
|
||||||
cluster?: {
|
|
||||||
name?: string;
|
|
||||||
loadBalancerIp?: string;
|
|
||||||
ipAddressPool?: string;
|
|
||||||
hostnamePrefix?: string;
|
|
||||||
certManager?: {
|
|
||||||
cloudflare?: {
|
|
||||||
domain?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
externalDns?: {
|
|
||||||
ownerId?: string;
|
|
||||||
};
|
|
||||||
internalDns?: {
|
|
||||||
externalResolver?: string;
|
|
||||||
};
|
|
||||||
dockerRegistry?: {
|
|
||||||
storage?: string;
|
|
||||||
};
|
|
||||||
nodes?: {
|
|
||||||
talos?: {
|
|
||||||
version?: string;
|
|
||||||
schematicId?: string;
|
|
||||||
};
|
|
||||||
control?: {
|
|
||||||
vip?: string;
|
|
||||||
};
|
|
||||||
active?: Record<string, NodeConfig>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
apps?: Record<string, Record<string, unknown>>; // Each app has its own dynamic config
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstanceConfigResponse {
|
|
||||||
config?: InstanceConfig;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
message: string;
|
message: string;
|
||||||
type: 'info' | 'success' | 'error';
|
type: 'info' | 'success' | 'error';
|
||||||
@@ -100,7 +33,7 @@ export interface DnsmasqStatus {
|
|||||||
pid: number;
|
pid: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
config_file: string;
|
config_file: string;
|
||||||
instances_configured: number;
|
domains_configured: number;
|
||||||
last_restart: string;
|
last_restart: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user