feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
321
internal/ddns/cloudflare_test.go
Normal file
321
internal/ddns/cloudflare_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package ddns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewRunner_ZeroStatus(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
s := rn.GetStatus()
|
||||
if s.Enabled {
|
||||
t.Error("new runner should have Enabled=false")
|
||||
}
|
||||
if s.CurrentIP != "" {
|
||||
t.Errorf("new runner should have empty CurrentIP, got %q", s.CurrentIP)
|
||||
}
|
||||
if !s.LastChecked.IsZero() {
|
||||
t.Error("new runner should have zero LastChecked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrigger_NonBlocking(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Trigger()
|
||||
rn.Trigger()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Trigger blocked unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ExitsOnContextCancel(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{}) // not enabled — returns immediately
|
||||
close(done)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run did not exit after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: false})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoToken_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when no API token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoRecords_ExitsImmediately(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Run should exit immediately when no records configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop.
|
||||
// Uses Start (not Run directly) since Start owns the Enabled lifecycle.
|
||||
func TestStart_SetsEnabledStatus(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
started := make(chan struct{}, 1)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("goroutine did not start in time")
|
||||
}
|
||||
|
||||
if !rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=true after Start with valid config")
|
||||
}
|
||||
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=false after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config.
|
||||
func TestStart_InvalidConfig_SetsDisabled(t *testing.T) {
|
||||
for _, cfg := range []Config{
|
||||
{Enabled: false},
|
||||
{Enabled: true, APIToken: ""},
|
||||
{Enabled: true, APIToken: "tok", Records: nil},
|
||||
} {
|
||||
rn := NewRunner()
|
||||
rn.Start(context.Background(), cfg)
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Errorf("expected Enabled=false for config %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_Restart_KeepsEnabled verifies that calling Start twice does not
|
||||
// clear Enabled — the race where the old goroutine's exit sets Enabled=false.
|
||||
func TestStart_Restart_KeepsEnabled(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
reached := make(chan struct{}, 2)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case reached <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
// Wait for first goroutine to actually start
|
||||
select {
|
||||
case <-reached:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first goroutine did not start")
|
||||
}
|
||||
|
||||
// Restart — old goroutine is cancelled, new one starts
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
// Enabled must still be true regardless of old goroutine's exit timing
|
||||
if !rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=true after restart")
|
||||
}
|
||||
|
||||
rn.Stop()
|
||||
}
|
||||
|
||||
|
||||
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
|
||||
func TestStop_ClearsEnabled(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.Enabled = true
|
||||
rn.mu.Unlock()
|
||||
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().Enabled {
|
||||
t.Error("expected Enabled=false after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine.
|
||||
func TestStart_CancelsAndRestarts(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
|
||||
block := make(chan struct{})
|
||||
firstReady := make(chan struct{}, 1)
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
select {
|
||||
case firstReady <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-block
|
||||
return "1.2.3.4", nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
|
||||
|
||||
rn.Start(ctx, cfg)
|
||||
|
||||
select {
|
||||
case <-firstReady:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first goroutine did not start")
|
||||
}
|
||||
|
||||
// Unblock the first goroutine so the second Start can cancel it
|
||||
close(block)
|
||||
|
||||
// Second Start with disabled config must not deadlock
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rn.Start(ctx, Config{Enabled: false})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("second Start blocked unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch.
|
||||
// Note: this calls updateCloudflareRecord which fails with a fake token,
|
||||
// but CurrentIP is still updated to reflect the fetched IP.
|
||||
func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil }
|
||||
|
||||
rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}})
|
||||
|
||||
if rn.GetStatus().CurrentIP != "5.6.7.8" {
|
||||
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are
|
||||
// always synced on the first check, even when the public IP hasn't changed.
|
||||
// This catches the bug where adding a new record (e.g. dev.payne.io) would be
|
||||
// skipped because currentIP already matched the fetched IP.
|
||||
func TestStart_ClearsCurrentIP(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine
|
||||
rn.mu.Unlock()
|
||||
|
||||
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
rn.Stop()
|
||||
|
||||
if rn.GetStatus().CurrentIP != "" {
|
||||
t.Error("Start should clear CurrentIP so the first check syncs all records")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches.
|
||||
func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "9.9.9.9"
|
||||
rn.mu.Unlock()
|
||||
|
||||
calls := 0
|
||||
rn.ipFetcher = func() (string, error) {
|
||||
calls++
|
||||
return "9.9.9.9", nil
|
||||
}
|
||||
|
||||
prevUpdated := rn.GetStatus().LastUpdated
|
||||
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}})
|
||||
|
||||
if rn.GetStatus().LastUpdated != prevUpdated {
|
||||
t.Error("LastUpdated should not change when IP is unchanged")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 ipFetcher call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_ReflectsManualUpdate(t *testing.T) {
|
||||
rn := NewRunner()
|
||||
rn.mu.Lock()
|
||||
rn.status.CurrentIP = "1.2.3.4"
|
||||
rn.status.LastChecked = time.Now()
|
||||
rn.status.Enabled = true
|
||||
rn.mu.Unlock()
|
||||
|
||||
s := rn.GetStatus()
|
||||
if s.CurrentIP != "1.2.3.4" {
|
||||
t.Errorf("got CurrentIP %q, want 1.2.3.4", s.CurrentIP)
|
||||
}
|
||||
if !s.Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user