Removes more Wild Cloud cruft.
This commit is contained in:
@@ -60,7 +60,7 @@ func (api *API) DnsmasqRestart(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DnsmasqGenerate generates the dnsmasq configuration from global config.
|
// DnsmasqGenerate generates the dnsmasq configuration from global config.
|
||||||
// Query param ?overwrite=true will write the config and restart the service.
|
// Query param ?overwrite=true will write the config and restart the service.
|
||||||
// Instance-specific DNS records are managed via service registration (not here).
|
// Domain-specific DNS records are managed via domain registration and reconciliation.
|
||||||
func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
|
func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
overwrite := r.URL.Query().Get("overwrite") == "true"
|
overwrite := r.URL.Query().Get("overwrite") == "true"
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ func writeConfigFile(path, content string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts.
|
// updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts.
|
||||||
// Instance-specific DNS records are managed via service registration (not here).
|
// Domain-specific DNS records are managed via domain registration and reconciliation.
|
||||||
func (api *API) updateDnsmasqConfig() error {
|
func (api *API) updateDnsmasqConfig() error {
|
||||||
globalConfigPath := api.statePath()
|
globalConfigPath := api.statePath()
|
||||||
globalCfg, err := config.LoadState(globalConfigPath)
|
globalCfg, err := config.LoadState(globalConfigPath)
|
||||||
|
|||||||
@@ -62,14 +62,8 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// Add route params if present
|
// Add route params if present
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
if name := vars["name"]; name != "" {
|
if domain := vars["domain"]; domain != "" {
|
||||||
attrs = append(attrs, "instance", name)
|
attrs = append(attrs, "domain", domain)
|
||||||
}
|
|
||||||
if app := vars["app"]; app != "" {
|
|
||||||
attrs = append(attrs, "app", app)
|
|
||||||
}
|
|
||||||
if node := vars["node"]; node != "" {
|
|
||||||
attrs = append(attrs, "node", node)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sw.status >= 400 {
|
if sw.status >= 400 {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type CertStatus struct {
|
type CertStatus struct {
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
|
Wildcard bool `json:"wildcard,omitempty"`
|
||||||
Expiry time.Time `json:"expiry,omitempty"`
|
Expiry time.Time `json:"expiry,omitempty"`
|
||||||
CertPath string `json:"certPath,omitempty"`
|
CertPath string `json:"certPath,omitempty"`
|
||||||
KeyPath string `json:"keyPath,omitempty"`
|
KeyPath string `json:"keyPath,omitempty"`
|
||||||
@@ -118,8 +119,8 @@ func (m *Manager) GetStatus(domain string) *CertStatus {
|
|||||||
status.CertPath = pemPath
|
status.CertPath = pemPath
|
||||||
status.KeyPath = pemPath
|
status.KeyPath = pemPath
|
||||||
|
|
||||||
// Parse expiry from the certificate
|
// Parse certificate metadata
|
||||||
cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer")
|
cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer", "-subject")
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status
|
return status
|
||||||
@@ -140,6 +141,11 @@ func (m *Manager) GetStatus(domain string) *CertStatus {
|
|||||||
if strings.HasPrefix(line, "issuer=") {
|
if strings.HasPrefix(line, "issuer=") {
|
||||||
status.IssuerCN = strings.TrimPrefix(line, "issuer=")
|
status.IssuerCN = strings.TrimPrefix(line, "issuer=")
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(line, "subject=") {
|
||||||
|
if strings.Contains(line, "CN = *.") {
|
||||||
|
status.Wildcard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -147,13 +147,4 @@ func SaveState(config *State, configPath string) error {
|
|||||||
return os.WriteFile(configPath, data, 0644)
|
return os.WriteFile(configPath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if the configuration is empty or uninitialized
|
|
||||||
func (c *State) IsEmpty() bool {
|
|
||||||
if c == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if essential fields are empty
|
|
||||||
return c.Cloud.Central.Domain == "" && c.Operator.Email == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,63 +236,6 @@ func TestSaveState_CreatesDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: State.IsEmpty checks if config is empty
|
|
||||||
func TestState_IsEmpty(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
config *State
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil config is empty",
|
|
||||||
config: nil,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default config is empty",
|
|
||||||
config: &State{},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "config with only central domain is not empty",
|
|
||||||
config: func() *State {
|
|
||||||
cfg := &State{}
|
|
||||||
cfg.Cloud.Central.Domain = "central.example.com"
|
|
||||||
return cfg
|
|
||||||
}(),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "config with only operator email is not empty",
|
|
||||||
config: func() *State {
|
|
||||||
cfg := &State{}
|
|
||||||
cfg.Operator.Email = "admin@example.com"
|
|
||||||
return cfg
|
|
||||||
}(),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "config with all fields is not empty",
|
|
||||||
config: func() *State {
|
|
||||||
cfg := &State{}
|
|
||||||
cfg.Cloud.Central.Domain = "central.example.com"
|
|
||||||
cfg.Operator.Email = "admin@example.com"
|
|
||||||
return cfg
|
|
||||||
}(),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := tt.config.IsEmpty()
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test: 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()
|
||||||
|
|||||||
@@ -100,19 +100,6 @@ log-dhcp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteConfig writes the dnsmasq configuration to the specified path
|
|
||||||
func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error {
|
|
||||||
configContent := g.Generate(cfg, entries)
|
|
||||||
|
|
||||||
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
|
||||||
|
|
||||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
||||||
return fmt.Errorf("writing dnsmasq config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
||||||
// Falls back to restart if reload fails.
|
// Falls back to restart if reload fails.
|
||||||
func (g *ConfigGenerator) RestartService() error {
|
func (g *ConfigGenerator) RestartService() error {
|
||||||
@@ -223,6 +210,95 @@ func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, re
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateMainConfig creates the main dnsmasq configuration with global settings.
|
||||||
|
// Used by the DnsmasqGenerate handler to preview/apply the base config independently
|
||||||
|
// of domain-specific DNS entries (which are handled by Generate + UpdateConfig).
|
||||||
|
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
||||||
|
dnsIP, err := network.GetWildCentralIP()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
|
||||||
|
dnsIP = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
||||||
|
# This file contains global settings. Domain-specific DNS entries are
|
||||||
|
# generated by reconciliation into the monolithic config.
|
||||||
|
|
||||||
|
# Basic Settings
|
||||||
|
listen-address=%s
|
||||||
|
bind-interfaces
|
||||||
|
domain-needed
|
||||||
|
bogus-priv
|
||||||
|
no-resolv
|
||||||
|
|
||||||
|
# Upstream DNS servers
|
||||||
|
server=1.1.1.1
|
||||||
|
server=8.8.8.8
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log-queries
|
||||||
|
log-dhcp
|
||||||
|
`, dnsIP)
|
||||||
|
|
||||||
|
// DHCP section (only when explicitly enabled)
|
||||||
|
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
||||||
|
dhcp := cfg.Cloud.Dnsmasq.DHCP
|
||||||
|
sb.WriteString("\n# DHCP Configuration\n")
|
||||||
|
|
||||||
|
leaseTime := dhcp.LeaseTime
|
||||||
|
if leaseTime == "" {
|
||||||
|
leaseTime = "24h"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
||||||
|
|
||||||
|
gateway := dhcp.Gateway
|
||||||
|
if gateway != "" {
|
||||||
|
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway)
|
||||||
|
}
|
||||||
|
if dnsIP != "" {
|
||||||
|
fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lease := range dhcp.StaticLeases {
|
||||||
|
if lease.Hostname != "" {
|
||||||
|
fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
||||||
|
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||||
|
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "syntax check OK") {
|
||||||
|
return fmt.Errorf("config validation did not report OK: %s", string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadService sends a SIGHUP to dnsmasq to reload configuration.
|
||||||
|
// Lighter weight than a full restart. Falls back to RestartService on failure.
|
||||||
|
func (g *ConfigGenerator) ReloadService() error {
|
||||||
|
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
|
||||||
|
_, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
|
||||||
|
return g.RestartService()
|
||||||
|
}
|
||||||
|
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
||||||
// This should only be called on first start of dnsmasq
|
// This should only be called on first start of dnsmasq
|
||||||
func (g *ConfigGenerator) ConfigureSystemDNS() error {
|
func (g *ConfigGenerator) ConfigureSystemDNS() error {
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package dnsmasq
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
|
||||||
// and a conf-dir directive to include per-domain configs
|
|
||||||
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
|
|
||||||
// Get the Wild Central IP address
|
|
||||||
dnsIP, err := network.GetWildCentralIP()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
|
|
||||||
dnsIP = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
|
||||||
# This file contains global settings. Domain-specific DNS entries are
|
|
||||||
# generated by reconciliation into the monolithic config.
|
|
||||||
|
|
||||||
# Basic Settings
|
|
||||||
listen-address=%s
|
|
||||||
bind-interfaces
|
|
||||||
domain-needed
|
|
||||||
bogus-priv
|
|
||||||
no-resolv
|
|
||||||
|
|
||||||
# Upstream DNS servers
|
|
||||||
server=1.1.1.1
|
|
||||||
server=8.8.8.8
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log-queries
|
|
||||||
log-dhcp
|
|
||||||
`, dnsIP)
|
|
||||||
|
|
||||||
// DHCP section (only when explicitly enabled)
|
|
||||||
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
|
||||||
dhcp := cfg.Cloud.Dnsmasq.DHCP
|
|
||||||
sb.WriteString("\n# DHCP Configuration\n")
|
|
||||||
|
|
||||||
leaseTime := dhcp.LeaseTime
|
|
||||||
if leaseTime == "" {
|
|
||||||
leaseTime = "24h"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
|
||||||
|
|
||||||
gateway := dhcp.Gateway
|
|
||||||
if gateway != "" {
|
|
||||||
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway
|
|
||||||
}
|
|
||||||
if dnsIP != "" {
|
|
||||||
fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) // DNS server
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, lease := range dhcp.StaticLeases {
|
|
||||||
if lease.Hostname != "" {
|
|
||||||
fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
|
||||||
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
|
||||||
// Use dnsmasq --test to validate the configuration
|
|
||||||
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if output contains "syntax check OK"
|
|
||||||
if !strings.Contains(string(output), "syntax check OK") {
|
|
||||||
return fmt.Errorf("config validation did not report OK: %s", string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration
|
|
||||||
// This is lighter weight than a full restart
|
|
||||||
func (g *ConfigGenerator) ReloadService() error {
|
|
||||||
// Use systemctl reload which sends SIGHUP
|
|
||||||
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
|
|
||||||
_, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If reload fails, try restart as fallback
|
|
||||||
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
|
|
||||||
return g.RestartService()
|
|
||||||
}
|
|
||||||
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -84,14 +84,6 @@ type GenerateOpts struct {
|
|||||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate creates a complete HAProxy configuration from instance routes, custom routes,
|
|
||||||
// and optional config. Uses L4 TCP mode with SNI inspection for HTTPS —
|
|
||||||
// TLS terminates at each k8s cluster's traefik. HTTP routes add an L7 frontend
|
|
||||||
// where Central terminates TLS with a wildcard cert and reverse-proxies by Host header.
|
|
||||||
func (m *Manager) Generate(instances []L4Route, custom []CustomRoute, centralDomain ...string) string {
|
|
||||||
return m.GenerateWithOpts(instances, custom, GenerateOpts{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
||||||
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
|
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestGetListenPorts_IncludesCustom(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) {
|
func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate(nil, nil)
|
out := m.GenerateWithOpts(nil, nil, GenerateOpts{})
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"global\n",
|
"global\n",
|
||||||
@@ -86,7 +86,7 @@ func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerate_NoInstances_NoHTTPSFrontend(t *testing.T) {
|
func TestGenerate_NoInstances_NoHTTPSFrontend(t *testing.T) {
|
||||||
m := NewManager("")
|
m := NewManager("")
|
||||||
out := m.Generate(nil, nil)
|
out := m.GenerateWithOpts(nil, nil, GenerateOpts{})
|
||||||
if strings.Contains(out, "frontend https_in") {
|
if strings.Contains(out, "frontend https_in") {
|
||||||
t.Errorf("https_in frontend should be absent when no instances configured")
|
t.Errorf("https_in frontend should be absent when no instances configured")
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ func TestGenerate_WithInstance_SNIACLs(t *testing.T) {
|
|||||||
instances := []L4Route{
|
instances := []L4Route{
|
||||||
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
|
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
|
||||||
}
|
}
|
||||||
out := m.Generate(instances, nil)
|
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
|
||||||
|
|
||||||
// With subdomains:true, both ACL lines for OR-matching
|
// With subdomains:true, both ACL lines for OR-matching
|
||||||
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
|
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
|
||||||
@@ -116,7 +116,7 @@ func TestGenerate_WithInstance_Backend(t *testing.T) {
|
|||||||
instances := []L4Route{
|
instances := []L4Route{
|
||||||
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
|
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
|
||||||
}
|
}
|
||||||
out := m.Generate(instances, nil)
|
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
|
||||||
|
|
||||||
if !strings.Contains(out, "backend be_https_payne_cloud") {
|
if !strings.Contains(out, "backend be_https_payne_cloud") {
|
||||||
t.Errorf("expected backend block, got:\n%s", out)
|
t.Errorf("expected backend block, got:\n%s", out)
|
||||||
@@ -132,7 +132,7 @@ func TestGenerate_MultipleInstances(t *testing.T) {
|
|||||||
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
|
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
|
||||||
{Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"},
|
{Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"},
|
||||||
}
|
}
|
||||||
out := m.Generate(instances, nil)
|
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
|
||||||
|
|
||||||
if !strings.Contains(out, "be_https_payne_cloud") {
|
if !strings.Contains(out, "be_https_payne_cloud") {
|
||||||
t.Errorf("expected payne-cloud backend, got:\n%s", out)
|
t.Errorf("expected payne-cloud backend, got:\n%s", out)
|
||||||
@@ -154,7 +154,7 @@ func TestGenerate_WithSubdomains(t *testing.T) {
|
|||||||
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
|
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
|
||||||
{Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false},
|
{Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false},
|
||||||
}
|
}
|
||||||
out := m.Generate(instances, nil)
|
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
|
||||||
|
|
||||||
// cloud.payne.io with subdomains:true should have wildcard ACL
|
// cloud.payne.io with subdomains:true should have wildcard ACL
|
||||||
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
|
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
|
||||||
@@ -207,7 +207,7 @@ func TestGenerate_CustomRoute(t *testing.T) {
|
|||||||
custom := []CustomRoute{
|
custom := []CustomRoute{
|
||||||
{Name: "civil_ssh", Port: 2222, Backend: "192.168.8.10:22"},
|
{Name: "civil_ssh", Port: 2222, Backend: "192.168.8.10:22"},
|
||||||
}
|
}
|
||||||
out := m.Generate(nil, custom)
|
out := m.GenerateWithOpts(nil, custom, GenerateOpts{})
|
||||||
|
|
||||||
if !strings.Contains(out, "frontend fe_civil_ssh") {
|
if !strings.Contains(out, "frontend fe_civil_ssh") {
|
||||||
t.Errorf("expected custom frontend, got:\n%s", out)
|
t.Errorf("expected custom frontend, got:\n%s", out)
|
||||||
@@ -417,7 +417,7 @@ func TestGenerateWithOpts_BackwardCompatible(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Using old Generate function should still work
|
// Using old Generate function should still work
|
||||||
out := m.Generate(instances, nil)
|
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
|
||||||
if !strings.Contains(out, "be_https_test") {
|
if !strings.Contains(out, "be_https_test") {
|
||||||
t.Errorf("backward compat: expected instance backend, got:\n%s", out)
|
t.Errorf("backward compat: expected instance backend, got:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ func TestGenerate_CustomRoutePorts(t *testing.T) {
|
|||||||
{Name: "mqtt", Port: 1883, Backend: "192.168.1.20:1883"},
|
{Name: "mqtt", Port: 1883, Backend: "192.168.1.20:1883"},
|
||||||
}
|
}
|
||||||
|
|
||||||
out := m.Generate(nil, custom)
|
out := m.GenerateWithOpts(nil, custom, GenerateOpts{})
|
||||||
|
|
||||||
if !strings.Contains(out, "bind *:2222") {
|
if !strings.Contains(out, "bind *:2222") {
|
||||||
t.Errorf("expected bind on port 2222, got:\n%s", out)
|
t.Errorf("expected bind on port 2222, got:\n%s", out)
|
||||||
|
|||||||
@@ -1,616 +0,0 @@
|
|||||||
package sse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WatcherManager manages kubectl and talos watchers per instance
|
|
||||||
type WatcherManager struct {
|
|
||||||
watchers map[string]*InstanceWatchers
|
|
||||||
mu sync.RWMutex
|
|
||||||
sseManager *Manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstanceWatchers holds all watchers for a single instance
|
|
||||||
type InstanceWatchers struct {
|
|
||||||
kubectl *KubectlWatcher
|
|
||||||
talos *TalosWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWatcherManager creates a new watcher manager
|
|
||||||
func NewWatcherManager(sseManager *Manager) *WatcherManager {
|
|
||||||
return &WatcherManager{
|
|
||||||
watchers: make(map[string]*InstanceWatchers),
|
|
||||||
sseManager: sseManager,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartWatchers starts watchers for an instance
|
|
||||||
func (wm *WatcherManager) StartWatchers(instanceName, kubeconfig, talosconfig, nodeIP string) error {
|
|
||||||
wm.mu.Lock()
|
|
||||||
defer wm.mu.Unlock()
|
|
||||||
|
|
||||||
// Stop existing watchers if any
|
|
||||||
if existing, ok := wm.watchers[instanceName]; ok {
|
|
||||||
existing.kubectl.Stop()
|
|
||||||
if existing.talos != nil {
|
|
||||||
existing.talos.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start kubectl watcher
|
|
||||||
kubectlWatcher := NewKubectlWatcher(instanceName, kubeconfig, wm.sseManager)
|
|
||||||
if err := kubectlWatcher.Start(); err != nil {
|
|
||||||
return fmt.Errorf("failed to start kubectl watcher: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start talos watcher (optional, can be nil if not configured)
|
|
||||||
var talosWatcher *TalosWatcher
|
|
||||||
if talosconfig != "" && nodeIP != "" {
|
|
||||||
talosWatcher = NewTalosWatcher(instanceName, talosconfig, nodeIP, wm.sseManager)
|
|
||||||
if err := talosWatcher.Start(); err != nil {
|
|
||||||
kubectlWatcher.Stop()
|
|
||||||
return fmt.Errorf("failed to start talos watcher: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wm.watchers[instanceName] = &InstanceWatchers{
|
|
||||||
kubectl: kubectlWatcher,
|
|
||||||
talos: talosWatcher,
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopWatchers stops watchers for an instance
|
|
||||||
func (wm *WatcherManager) StopWatchers(instanceName string) {
|
|
||||||
wm.mu.Lock()
|
|
||||||
defer wm.mu.Unlock()
|
|
||||||
|
|
||||||
if watchers, ok := wm.watchers[instanceName]; ok {
|
|
||||||
watchers.kubectl.Stop()
|
|
||||||
if watchers.talos != nil {
|
|
||||||
watchers.talos.Stop()
|
|
||||||
}
|
|
||||||
delete(wm.watchers, instanceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// KubectlWatcher watches Kubernetes resources using kubectl --watch
|
|
||||||
type KubectlWatcher struct {
|
|
||||||
instanceName string
|
|
||||||
kubeconfig string
|
|
||||||
sseManager *Manager
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewKubectlWatcher creates a new kubectl watcher
|
|
||||||
func NewKubectlWatcher(instanceName string, kubeconfig string, manager *Manager) *KubectlWatcher {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
return &KubectlWatcher{
|
|
||||||
instanceName: instanceName,
|
|
||||||
kubeconfig: kubeconfig,
|
|
||||||
sseManager: manager,
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins watching Kubernetes resources
|
|
||||||
func (w *KubectlWatcher) Start() error {
|
|
||||||
// Watch pods
|
|
||||||
w.wg.Add(1)
|
|
||||||
go w.watchResource("pods", w.parsePodEvent)
|
|
||||||
|
|
||||||
// Watch deployments
|
|
||||||
w.wg.Add(1)
|
|
||||||
go w.watchResource("deployments", w.parseDeploymentEvent)
|
|
||||||
|
|
||||||
// Watch services
|
|
||||||
w.wg.Add(1)
|
|
||||||
go w.watchResource("services", w.parseServiceEvent)
|
|
||||||
|
|
||||||
slog.Info("started kubectl watchers", "component", "sse", "instance", w.instanceName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchResource watches a specific Kubernetes resource type
|
|
||||||
func (w *KubectlWatcher) watchResource(resourceType string, parser func([]byte, string)) {
|
|
||||||
defer w.wg.Done()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-w.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start kubectl watch command
|
|
||||||
cmd := exec.CommandContext(
|
|
||||||
w.ctx,
|
|
||||||
"kubectl",
|
|
||||||
"--kubeconfig", w.kubeconfig,
|
|
||||||
"get", resourceType,
|
|
||||||
"--all-namespaces",
|
|
||||||
"--watch",
|
|
||||||
"-o", "json",
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create stdout pipe", "component", "sse", "resource", resourceType, "error", err)
|
|
||||||
w.handleWatchError(resourceType)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
slog.Error("failed to start watch", "component", "sse", "resource", resourceType, "error", err)
|
|
||||||
w.handleWatchError(resourceType)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parser([]byte(line), resourceType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
slog.Error("watch scanner error", "component", "sse", "resource", resourceType, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = cmd.Wait()
|
|
||||||
|
|
||||||
// If context not cancelled, restart after a delay
|
|
||||||
if w.ctx.Err() == nil {
|
|
||||||
slog.Info("restarting watcher", "component", "sse", "resource", resourceType, "instance", w.instanceName)
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePodEvent parses pod watch events
|
|
||||||
func (w *KubectlWatcher) parsePodEvent(data []byte, resourceType string) {
|
|
||||||
var event struct {
|
|
||||||
Type string `json:"type"` // ADDED, MODIFIED, DELETED
|
|
||||||
Object struct {
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Status struct {
|
|
||||||
Phase string `json:"phase"`
|
|
||||||
Conditions []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
} `json:"conditions"`
|
|
||||||
ContainerStatuses []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
State struct {
|
|
||||||
Running any `json:"running"`
|
|
||||||
Waiting any `json:"waiting"`
|
|
||||||
Terminated any `json:"terminated"`
|
|
||||||
} `json:"state"`
|
|
||||||
} `json:"containerStatuses"`
|
|
||||||
} `json:"status"`
|
|
||||||
} `json:"object"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing as watch event first
|
|
||||||
if err := json.Unmarshal(data, &event); err == nil && event.Type != "" {
|
|
||||||
eventType := ""
|
|
||||||
switch event.Type {
|
|
||||||
case "ADDED":
|
|
||||||
eventType = "pod:added"
|
|
||||||
case "MODIFIED":
|
|
||||||
eventType = "pod:modified"
|
|
||||||
case "DELETED":
|
|
||||||
eventType = "pod:deleted"
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: eventType,
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"name": event.Object.Metadata.Name,
|
|
||||||
"namespace": event.Object.Metadata.Namespace,
|
|
||||||
"phase": event.Object.Status.Phase,
|
|
||||||
"ready": w.isPodReady(event.Object.Status.ContainerStatuses),
|
|
||||||
},
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"namespace": event.Object.Metadata.Namespace,
|
|
||||||
"app": event.Object.Metadata.Labels["app"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing as initial pod object (from initial list)
|
|
||||||
var pod struct {
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Status struct {
|
|
||||||
Phase string `json:"phase"`
|
|
||||||
ContainerStatuses []struct {
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
} `json:"containerStatuses"`
|
|
||||||
} `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &pod); err == nil && pod.Metadata.Name != "" {
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: "pod:added",
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"name": pod.Metadata.Name,
|
|
||||||
"namespace": pod.Metadata.Namespace,
|
|
||||||
"phase": pod.Status.Phase,
|
|
||||||
"ready": w.isPodReady(pod.Status.ContainerStatuses),
|
|
||||||
},
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"namespace": pod.Metadata.Namespace,
|
|
||||||
"app": pod.Metadata.Labels["app"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDeploymentEvent parses deployment watch events
|
|
||||||
func (w *KubectlWatcher) parseDeploymentEvent(data []byte, resourceType string) {
|
|
||||||
var deployment struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Object struct {
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Status struct {
|
|
||||||
Replicas int32 `json:"replicas"`
|
|
||||||
UpdatedReplicas int32 `json:"updatedReplicas"`
|
|
||||||
ReadyReplicas int32 `json:"readyReplicas"`
|
|
||||||
AvailableReplicas int32 `json:"availableReplicas"`
|
|
||||||
} `json:"status"`
|
|
||||||
} `json:"object"`
|
|
||||||
// Also handle direct deployment objects
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Status struct {
|
|
||||||
Replicas int32 `json:"replicas"`
|
|
||||||
UpdatedReplicas int32 `json:"updatedReplicas"`
|
|
||||||
ReadyReplicas int32 `json:"readyReplicas"`
|
|
||||||
AvailableReplicas int32 `json:"availableReplicas"`
|
|
||||||
} `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &deployment); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if it's a watch event or direct object
|
|
||||||
var name, namespace, eventType string
|
|
||||||
var labels map[string]string
|
|
||||||
var status struct {
|
|
||||||
Replicas int32
|
|
||||||
ReadyReplicas int32
|
|
||||||
}
|
|
||||||
|
|
||||||
if deployment.Type != "" {
|
|
||||||
// Watch event
|
|
||||||
switch deployment.Type {
|
|
||||||
case "ADDED":
|
|
||||||
eventType = "deployment:added"
|
|
||||||
case "MODIFIED":
|
|
||||||
eventType = "deployment:modified"
|
|
||||||
case "DELETED":
|
|
||||||
eventType = "deployment:deleted"
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name = deployment.Object.Metadata.Name
|
|
||||||
namespace = deployment.Object.Metadata.Namespace
|
|
||||||
labels = deployment.Object.Metadata.Labels
|
|
||||||
status.Replicas = deployment.Object.Status.Replicas
|
|
||||||
status.ReadyReplicas = deployment.Object.Status.ReadyReplicas
|
|
||||||
} else if deployment.Metadata.Name != "" {
|
|
||||||
// Direct object (initial list)
|
|
||||||
eventType = "deployment:added"
|
|
||||||
name = deployment.Metadata.Name
|
|
||||||
namespace = deployment.Metadata.Namespace
|
|
||||||
labels = deployment.Metadata.Labels
|
|
||||||
status.Replicas = deployment.Status.Replicas
|
|
||||||
status.ReadyReplicas = deployment.Status.ReadyReplicas
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: eventType,
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"name": name,
|
|
||||||
"namespace": namespace,
|
|
||||||
"replicas": status.Replicas,
|
|
||||||
"readyReplicas": status.ReadyReplicas,
|
|
||||||
"ready": status.Replicas == status.ReadyReplicas && status.Replicas > 0,
|
|
||||||
},
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"namespace": namespace,
|
|
||||||
"app": labels["app"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseServiceEvent parses service watch events
|
|
||||||
func (w *KubectlWatcher) parseServiceEvent(data []byte, resourceType string) {
|
|
||||||
var service struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Object struct {
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Spec struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ClusterIP string `json:"clusterIP"`
|
|
||||||
Ports []struct {
|
|
||||||
Port int32 `json:"port"`
|
|
||||||
} `json:"ports"`
|
|
||||||
} `json:"spec"`
|
|
||||||
} `json:"object"`
|
|
||||||
// Also handle direct service objects
|
|
||||||
Metadata struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Labels map[string]string `json:"labels"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
Spec struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ClusterIP string `json:"clusterIP"`
|
|
||||||
Ports []struct {
|
|
||||||
Port int32 `json:"port"`
|
|
||||||
} `json:"ports"`
|
|
||||||
} `json:"spec"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &service); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if it's a watch event or direct object
|
|
||||||
var name, namespace, serviceType, eventType string
|
|
||||||
var labels map[string]string
|
|
||||||
|
|
||||||
if service.Type != "" {
|
|
||||||
// Watch event
|
|
||||||
switch service.Type {
|
|
||||||
case "ADDED":
|
|
||||||
eventType = "service:added"
|
|
||||||
case "MODIFIED":
|
|
||||||
eventType = "service:modified"
|
|
||||||
case "DELETED":
|
|
||||||
eventType = "service:deleted"
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name = service.Object.Metadata.Name
|
|
||||||
namespace = service.Object.Metadata.Namespace
|
|
||||||
labels = service.Object.Metadata.Labels
|
|
||||||
serviceType = service.Object.Spec.Type
|
|
||||||
} else if service.Metadata.Name != "" {
|
|
||||||
// Direct object (initial list)
|
|
||||||
eventType = "service:added"
|
|
||||||
name = service.Metadata.Name
|
|
||||||
namespace = service.Metadata.Namespace
|
|
||||||
labels = service.Metadata.Labels
|
|
||||||
serviceType = service.Spec.Type
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: eventType,
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"name": name,
|
|
||||||
"namespace": namespace,
|
|
||||||
"type": serviceType,
|
|
||||||
},
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"namespace": namespace,
|
|
||||||
"app": labels["app"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPodReady checks if all containers in a pod are ready
|
|
||||||
func (w *KubectlWatcher) isPodReady(containerStatuses any) bool {
|
|
||||||
switch statuses := containerStatuses.(type) {
|
|
||||||
case []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
State struct {
|
|
||||||
Running any `json:"running"`
|
|
||||||
Waiting any `json:"waiting"`
|
|
||||||
Terminated any `json:"terminated"`
|
|
||||||
} `json:"state"`
|
|
||||||
}:
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, status := range statuses {
|
|
||||||
if !status.Ready {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case []struct {
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
}:
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, status := range statuses {
|
|
||||||
if !status.Ready {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWatchError handles errors in watching resources
|
|
||||||
func (w *KubectlWatcher) handleWatchError(resourceType string) {
|
|
||||||
// Send error event
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: "k8s:watch:error",
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"resource": resourceType,
|
|
||||||
"error": "Watch process failed, restarting",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the watcher
|
|
||||||
func (w *KubectlWatcher) Stop() {
|
|
||||||
w.cancel()
|
|
||||||
w.wg.Wait()
|
|
||||||
slog.Info("stopped kubectl watchers", "component", "sse", "instance", w.instanceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TalosWatcher watches Talos events using talosctl
|
|
||||||
type TalosWatcher struct {
|
|
||||||
instanceName string
|
|
||||||
talosconfig string
|
|
||||||
nodeIP string
|
|
||||||
sseManager *Manager
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTalosWatcher creates a new Talos watcher
|
|
||||||
func NewTalosWatcher(instanceName, talosconfig, nodeIP string, manager *Manager) *TalosWatcher {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
return &TalosWatcher{
|
|
||||||
instanceName: instanceName,
|
|
||||||
talosconfig: talosconfig,
|
|
||||||
nodeIP: nodeIP,
|
|
||||||
sseManager: manager,
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins watching Talos events
|
|
||||||
func (w *TalosWatcher) Start() error {
|
|
||||||
go w.watchEvents()
|
|
||||||
slog.Info("started talos watcher", "component", "sse", "instance", w.instanceName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchEvents watches Talos events
|
|
||||||
func (w *TalosWatcher) watchEvents() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-w.ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(
|
|
||||||
w.ctx,
|
|
||||||
"talosctl",
|
|
||||||
"--talosconfig", w.talosconfig,
|
|
||||||
"--nodes", w.nodeIP,
|
|
||||||
"events",
|
|
||||||
"--tail", "0", // Don't show historical events
|
|
||||||
"--follow",
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create stdout pipe for talos events", "component", "sse", "instance", w.instanceName, "nodeIP", w.nodeIP, "error", err)
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
slog.Error("failed to start talos event watch", "component", "sse", "instance", w.instanceName, "nodeIP", w.nodeIP, "error", err)
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Talos event output
|
|
||||||
// Talos events typically look like:
|
|
||||||
// 172.20.0.2: time.syncd: 2024-01-15T10:30:45.123Z: NTP time sync successful
|
|
||||||
if strings.Contains(line, "Service") {
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: "talos:service:status",
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"node": w.nodeIP,
|
|
||||||
"message": line,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if strings.Contains(line, "Health") || strings.Contains(line, "health") {
|
|
||||||
w.sseManager.Broadcast(&Event{
|
|
||||||
Type: "talos:node:health",
|
|
||||||
InstanceName: w.instanceName,
|
|
||||||
Data: map[string]any{
|
|
||||||
"node": w.nodeIP,
|
|
||||||
"message": line,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = cmd.Wait()
|
|
||||||
|
|
||||||
// If context not cancelled, restart after a delay
|
|
||||||
if w.ctx.Err() == nil {
|
|
||||||
slog.Info("restarting talos watcher", "component", "sse", "instance", w.instanceName)
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the watcher
|
|
||||||
func (w *TalosWatcher) Stop() {
|
|
||||||
w.cancel()
|
|
||||||
slog.Info("stopped talos watcher", "component", "sse", "instance", w.instanceName)
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
package sse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestKubectlWatcherJSONParsing(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test valid pod JSON parsing
|
|
||||||
validPodJSON := `{
|
|
||||||
"type": "ADDED",
|
|
||||||
"object": {
|
|
||||||
"apiVersion": "v1",
|
|
||||||
"kind": "Pod",
|
|
||||||
"metadata": {
|
|
||||||
"name": "test-pod",
|
|
||||||
"namespace": "default",
|
|
||||||
"labels": {
|
|
||||||
"app": "test-app"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"phase": "Running",
|
|
||||||
"conditions": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// Register a client to receive events
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Parse the JSON
|
|
||||||
watcher.parsePodEvent([]byte(validPodJSON), "test-instance")
|
|
||||||
|
|
||||||
// Should receive parsed event
|
|
||||||
select {
|
|
||||||
case event := <-client.Channel:
|
|
||||||
if event.Type != "pod:added" {
|
|
||||||
t.Errorf("Expected event type 'pod:added', got '%s'", event.Type)
|
|
||||||
}
|
|
||||||
if podData, ok := event.Data.(map[string]any); ok {
|
|
||||||
if podData["name"] != "test-pod" {
|
|
||||||
t.Errorf("Expected pod name 'test-pod', got '%v'", podData["name"])
|
|
||||||
}
|
|
||||||
if podData["namespace"] != "default" {
|
|
||||||
t.Errorf("Expected namespace 'default', got '%v'", podData["namespace"])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Error("Event data is not a map")
|
|
||||||
}
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Did not receive pod event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKubectlWatcherEventTypeMapping(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
watchType string
|
|
||||||
expectedType string
|
|
||||||
}{
|
|
||||||
{"ADDED", "pod:added"},
|
|
||||||
{"MODIFIED", "pod:modified"},
|
|
||||||
{"DELETED", "pod:deleted"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.watchType, func(t *testing.T) {
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
podJSON := fmt.Sprintf(`{
|
|
||||||
"type": "%s",
|
|
||||||
"object": {
|
|
||||||
"kind": "Pod",
|
|
||||||
"metadata": {
|
|
||||||
"name": "test-pod",
|
|
||||||
"namespace": "default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`, tc.watchType)
|
|
||||||
|
|
||||||
// Parse the event
|
|
||||||
watcher.parsePodEvent([]byte(podJSON), "test-instance")
|
|
||||||
|
|
||||||
// Check received event type
|
|
||||||
select {
|
|
||||||
case event := <-client.Channel:
|
|
||||||
if event.Type != tc.expectedType {
|
|
||||||
t.Errorf("Expected event type '%s', got '%s'", tc.expectedType, event.Type)
|
|
||||||
}
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Did not receive event")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeploymentEventParsing(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
deploymentJSON := `{
|
|
||||||
"type": "MODIFIED",
|
|
||||||
"object": {
|
|
||||||
"apiVersion": "apps/v1",
|
|
||||||
"kind": "Deployment",
|
|
||||||
"metadata": {
|
|
||||||
"name": "test-deployment",
|
|
||||||
"namespace": "default"
|
|
||||||
},
|
|
||||||
"spec": {
|
|
||||||
"replicas": 3
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"replicas": 3,
|
|
||||||
"readyReplicas": 2,
|
|
||||||
"availableReplicas": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Parse deployment event
|
|
||||||
watcher.parseDeploymentEvent([]byte(deploymentJSON), "test-instance")
|
|
||||||
|
|
||||||
// Check received event
|
|
||||||
select {
|
|
||||||
case event := <-client.Channel:
|
|
||||||
if event.Type != "deployment:modified" {
|
|
||||||
t.Errorf("Expected event type 'deployment:modified', got '%s'", event.Type)
|
|
||||||
}
|
|
||||||
if deployData, ok := event.Data.(map[string]any); ok {
|
|
||||||
if deployData["name"] != "test-deployment" {
|
|
||||||
t.Errorf("Expected deployment name 'test-deployment', got '%v'", deployData["name"])
|
|
||||||
}
|
|
||||||
if deployData["readyReplicas"] != int32(2) {
|
|
||||||
t.Errorf("Expected 2 ready replicas, got '%v'", deployData["readyReplicas"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Did not receive deployment event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceEventParsing(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceJSON := `{
|
|
||||||
"type": "ADDED",
|
|
||||||
"object": {
|
|
||||||
"apiVersion": "v1",
|
|
||||||
"kind": "Service",
|
|
||||||
"metadata": {
|
|
||||||
"name": "test-service",
|
|
||||||
"namespace": "default"
|
|
||||||
},
|
|
||||||
"spec": {
|
|
||||||
"type": "LoadBalancer",
|
|
||||||
"clusterIP": "10.96.0.1",
|
|
||||||
"ports": [
|
|
||||||
{
|
|
||||||
"port": 80,
|
|
||||||
"targetPort": 8080,
|
|
||||||
"protocol": "TCP"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"loadBalancer": {
|
|
||||||
"ingress": [
|
|
||||||
{
|
|
||||||
"ip": "192.168.1.100"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Parse service event
|
|
||||||
watcher.parseServiceEvent([]byte(serviceJSON), "test-instance")
|
|
||||||
|
|
||||||
// Check received event
|
|
||||||
select {
|
|
||||||
case event := <-client.Channel:
|
|
||||||
if event.Type != "service:added" {
|
|
||||||
t.Errorf("Expected event type 'service:added', got '%s'", event.Type)
|
|
||||||
}
|
|
||||||
if serviceData, ok := event.Data.(map[string]any); ok {
|
|
||||||
if serviceData["name"] != "test-service" {
|
|
||||||
t.Errorf("Expected service name 'test-service', got '%v'", serviceData["name"])
|
|
||||||
}
|
|
||||||
if serviceData["type"] != "LoadBalancer" {
|
|
||||||
t.Errorf("Expected service type 'LoadBalancer', got '%v'", serviceData["type"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Did not receive service event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTalosEventParsing is commented out as TalosWatcher doesn't expose parsing methods
|
|
||||||
// The actual parsing happens internally in the watchEvents method
|
|
||||||
/*
|
|
||||||
func TestTalosEventParsing(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &TalosWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
talosconfig: "/tmp/test-talosconfig",
|
|
||||||
nodeIP: "192.168.1.10",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test machine config event
|
|
||||||
machineConfigEvent := `seq:task message:update successful node:192.168.1.10 type:MachineConfig`
|
|
||||||
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Parse talos event
|
|
||||||
watcher.parseTalosEventLine(machineConfigEvent)
|
|
||||||
|
|
||||||
// Check received event
|
|
||||||
select {
|
|
||||||
case event := <-client.Channel:
|
|
||||||
if event.Type != "talos.MachineConfig" {
|
|
||||||
t.Errorf("Expected event type 'talos.MachineConfig', got '%s'", event.Type)
|
|
||||||
}
|
|
||||||
if talosData, ok := event.Data.(map[string]any); ok {
|
|
||||||
if talosData["message"] != "update successful" {
|
|
||||||
t.Errorf("Expected message 'update successful', got '%v'", talosData["message"])
|
|
||||||
}
|
|
||||||
if talosData["node"] != "192.168.1.10" {
|
|
||||||
t.Errorf("Expected node '192.168.1.10', got '%v'", talosData["node"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Error("Did not receive talos event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
func TestWatcherManagerLifecycle(t *testing.T) {
|
|
||||||
sseManager := NewManager()
|
|
||||||
watcherManager := NewWatcherManager(sseManager)
|
|
||||||
|
|
||||||
// Test starting watchers for an instance
|
|
||||||
err := watcherManager.StartWatchers("test-instance", "/tmp/kubeconfig", "/tmp/talosconfig", "192.168.1.10")
|
|
||||||
if err != nil {
|
|
||||||
// This will likely fail because the configs don't exist, but we can test the manager state
|
|
||||||
t.Logf("Expected error starting watchers with non-existent configs: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if instance is registered (even if watchers failed to start)
|
|
||||||
watcherManager.mu.RLock()
|
|
||||||
_, exists := watcherManager.watchers["test-instance"]
|
|
||||||
watcherManager.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
t.Error("Instance should be registered even if watchers fail to start")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test stopping watchers
|
|
||||||
watcherManager.StopWatchers("test-instance")
|
|
||||||
|
|
||||||
// Check instance is removed
|
|
||||||
watcherManager.mu.RLock()
|
|
||||||
_, exists = watcherManager.watchers["test-instance"]
|
|
||||||
watcherManager.mu.RUnlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
t.Error("Instance should be removed after stopping watchers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWatcherManagerMultipleInstances(t *testing.T) {
|
|
||||||
sseManager := NewManager()
|
|
||||||
watcherManager := NewWatcherManager(sseManager)
|
|
||||||
|
|
||||||
// Start watchers for multiple instances
|
|
||||||
_ = watcherManager.StartWatchers("instance-1", "/tmp/kubeconfig1", "/tmp/talosconfig1", "192.168.1.11")
|
|
||||||
_ = watcherManager.StartWatchers("instance-2", "/tmp/kubeconfig2", "/tmp/talosconfig2", "192.168.1.12")
|
|
||||||
|
|
||||||
// Check both instances are registered
|
|
||||||
watcherManager.mu.RLock()
|
|
||||||
count := len(watcherManager.watchers)
|
|
||||||
watcherManager.mu.RUnlock()
|
|
||||||
|
|
||||||
if count != 2 {
|
|
||||||
t.Errorf("Expected 2 instances registered, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop watchers for each instance
|
|
||||||
watcherManager.StopWatchers("instance-1")
|
|
||||||
watcherManager.StopWatchers("instance-2")
|
|
||||||
|
|
||||||
// Check all instances are removed
|
|
||||||
watcherManager.mu.RLock()
|
|
||||||
count = len(watcherManager.watchers)
|
|
||||||
watcherManager.mu.RUnlock()
|
|
||||||
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("Expected 0 instances after stopping all, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidJSONHandling(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Try parsing invalid JSON
|
|
||||||
invalidJSON := `{invalid json}`
|
|
||||||
watcher.parsePodEvent([]byte(invalidJSON), "test-instance")
|
|
||||||
|
|
||||||
// Should not receive any event
|
|
||||||
select {
|
|
||||||
case <-client.Channel:
|
|
||||||
t.Error("Should not receive event for invalid JSON")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
// Expected - no event for invalid JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyEventHandling(t *testing.T) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register a client
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Try parsing empty JSON
|
|
||||||
emptyJSON := `{}`
|
|
||||||
watcher.parsePodEvent([]byte(emptyJSON), "test-instance")
|
|
||||||
|
|
||||||
// Should not crash, might not produce event
|
|
||||||
select {
|
|
||||||
case <-client.Channel:
|
|
||||||
// If we get an event, make sure it doesn't crash
|
|
||||||
t.Log("Received event for empty JSON (acceptable)")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
// Also acceptable - no event for empty JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkJSONParsing(b *testing.B) {
|
|
||||||
manager := NewManager()
|
|
||||||
watcher := &KubectlWatcher{
|
|
||||||
instanceName: "test-instance",
|
|
||||||
kubeconfig: "/tmp/test-kubeconfig",
|
|
||||||
sseManager: manager,
|
|
||||||
}
|
|
||||||
|
|
||||||
podJSON := `{
|
|
||||||
"type": "ADDED",
|
|
||||||
"object": {
|
|
||||||
"apiVersion": "v1",
|
|
||||||
"kind": "Pod",
|
|
||||||
"metadata": {
|
|
||||||
"name": "test-pod",
|
|
||||||
"namespace": "default",
|
|
||||||
"labels": {
|
|
||||||
"app": "test-app"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"phase": "Running"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// Register a client to consume events
|
|
||||||
client := manager.RegisterClient("test-instance", EventFilters{})
|
|
||||||
defer manager.UnregisterClient(client)
|
|
||||||
|
|
||||||
// Consume events in background
|
|
||||||
go func() {
|
|
||||||
for range client.Channel {
|
|
||||||
// Consume events
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Benchmark JSON parsing and event creation
|
|
||||||
b.ResetTimer()
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
watcher.parsePodEvent([]byte(podJSON), "test-instance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -108,3 +108,4 @@ func EnsureFilePermissions(path string, perm os.FileMode) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
@@ -154,7 +154,7 @@ function MobileControls({
|
|||||||
{hasCert ? (
|
{hasCert ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
<span>TLS: {cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain}</span>
|
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {
|
import {
|
||||||
Globe, Wifi, Lock, Shield, ShieldAlert, Router,
|
Globe, Wifi, Lock, Shield, ShieldAlert, Router,
|
||||||
AlertCircle, Loader2, Plus, RotateCw, Pencil,
|
AlertCircle, Loader2, Plus, RotateCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -57,6 +57,68 @@ export interface DomainTopologyProps {
|
|||||||
|
|
||||||
const handleStyle = { opacity: 0, width: 4, height: 4 };
|
const handleStyle = { opacity: 0, width: 4, height: 4 };
|
||||||
|
|
||||||
|
function BackendNode({ data, leftHandle, rightHandle, bottomHandle }: {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
leftHandle: React.ReactNode;
|
||||||
|
rightHandle: React.ReactNode;
|
||||||
|
bottomHandle: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const onChangeBackend = data.onChangeBackend as ((addr: string) => void) | undefined;
|
||||||
|
const hasRoutes = data.hasRoutes as boolean;
|
||||||
|
const editable = !!onChangeBackend && !hasRoutes;
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const val = inputRef.current?.value.trim();
|
||||||
|
if (val && onChangeBackend) onChangeBackend(val);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label={editing ? '' : (data.label as string)}
|
||||||
|
sublabel={editing ? undefined : (data.sublabel as string | undefined)}
|
||||||
|
status="ok"
|
||||||
|
className="font-mono"
|
||||||
|
interactive={editable && !editing}
|
||||||
|
onClick={editable && !editing ? () => setEditing(true) : undefined}
|
||||||
|
tooltip={editable && !editing ? 'Click to edit backend address' : undefined}
|
||||||
|
>
|
||||||
|
{editing && (
|
||||||
|
<div className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
defaultValue={data.label as string}
|
||||||
|
className="h-5 text-[10px] font-mono px-1.5"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-4 text-[9px] px-1 flex-1 !cursor-pointer"
|
||||||
|
style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()} onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-4 text-[9px] px-1 flex-1 !cursor-pointer"
|
||||||
|
style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()} onClick={() => setEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TopologyNode>
|
||||||
|
{rightHandle}
|
||||||
|
{bottomHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FlowNode({ data }: NodeProps) {
|
function FlowNode({ data }: NodeProps) {
|
||||||
const v = data.variant as string;
|
const v = data.variant as string;
|
||||||
|
|
||||||
@@ -172,6 +234,7 @@ function FlowNode({ data }: NodeProps) {
|
|||||||
if (v === 'tls') {
|
if (v === 'tls') {
|
||||||
const status = data.status as NodeStatus;
|
const status = data.status as NodeStatus;
|
||||||
const certDomain = data.certDomain as string | undefined;
|
const certDomain = data.certDomain as string | undefined;
|
||||||
|
const isWildcard = data.isWildcardCert as boolean;
|
||||||
const daysLeft = data.daysLeft as number;
|
const daysLeft = data.daysLeft as number;
|
||||||
const hasCert = data.hasCert as boolean;
|
const hasCert = data.hasCert as boolean;
|
||||||
const provisionDomain = data.provisionDomain as string;
|
const provisionDomain = data.provisionDomain as string;
|
||||||
@@ -192,7 +255,7 @@ function FlowNode({ data }: NodeProps) {
|
|||||||
tooltip={
|
tooltip={
|
||||||
hasCert ? (
|
hasCert ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>Certificate: {certDomain}</div>
|
<div>Certificate: {certDomain} ({isWildcard ? 'wildcard' : 'single domain'})</div>
|
||||||
<div>Expires: {expiry ?? 'unknown'} ({daysLeft}d)</div>
|
<div>Expires: {expiry ?? 'unknown'} ({daysLeft}d)</div>
|
||||||
{issuer && <div>Issuer: {issuer}</div>}
|
{issuer && <div>Issuer: {issuer}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,63 +293,7 @@ function FlowNode({ data }: NodeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (v === 'backend') {
|
if (v === 'backend') {
|
||||||
const onChangeBackend = data.onChangeBackend as ((addr: string) => void) | undefined;
|
return <BackendNode data={data} leftHandle={leftHandle} rightHandle={rightHandle} bottomHandle={bottomHandle} />;
|
||||||
const hasRoutes = data.hasRoutes as boolean;
|
|
||||||
const editable = !!onChangeBackend && !hasRoutes;
|
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const val = inputRef.current?.value.trim();
|
|
||||||
if (val && onChangeBackend) onChangeBackend(val);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{leftHandle}
|
|
||||||
<TopologyNode
|
|
||||||
label={data.label as string}
|
|
||||||
sublabel={data.sublabel as string | undefined}
|
|
||||||
status="ok"
|
|
||||||
className="font-mono"
|
|
||||||
interactive={editable && !editing}
|
|
||||||
onClick={editable ? () => setEditing(true) : undefined}
|
|
||||||
tooltip={editable ? 'Click to edit backend address' : undefined}
|
|
||||||
>
|
|
||||||
{editable && !editing && (
|
|
||||||
<Pencil className="absolute top-1.5 right-1.5 h-2.5 w-2.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
)}
|
|
||||||
{editing && (
|
|
||||||
<div className="mt-1.5 nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()}>
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
defaultValue={data.label as string}
|
|
||||||
className="h-5 text-[10px] font-mono px-1.5"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') handleSave();
|
|
||||||
if (e.key === 'Escape') setEditing(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1 mt-1">
|
|
||||||
<Button variant="ghost" size="sm" className="h-4 text-[9px] px-1 flex-1 !cursor-pointer"
|
|
||||||
style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()} onClick={handleSave}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-4 text-[9px] px-1 flex-1 !cursor-pointer"
|
|
||||||
style={{ pointerEvents: 'all' }} onPointerDown={e => e.stopPropagation()} onClick={() => setEditing(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TopologyNode>
|
|
||||||
{rightHandle}
|
|
||||||
{bottomHandle}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (v === 'lan') {
|
if (v === 'lan') {
|
||||||
@@ -474,7 +481,8 @@ export function DomainTopology(props: DomainTopologyProps) {
|
|||||||
// TLS
|
// TLS
|
||||||
const hasCert = !!cert?.cert.exists;
|
const hasCert = !!cert?.cert.exists;
|
||||||
const tlsStatus: NodeStatus = hasCert ? 'ok' : 'error';
|
const tlsStatus: NodeStatus = hasCert ? 'ok' : 'error';
|
||||||
const certDomain = hasCert ? (cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain) : undefined;
|
const isWildcardCert = !!(cert?.cert.wildcard || cert?.coveredBy);
|
||||||
|
const certDomain = hasCert ? (isWildcardCert ? `*.${cert!.domain}` : cert!.domain) : undefined;
|
||||||
const daysLeft = cert?.cert.daysLeft ?? 0;
|
const daysLeft = cert?.cert.daysLeft ?? 0;
|
||||||
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||||
|
|
||||||
@@ -556,7 +564,7 @@ export function DomainTopology(props: DomainTopologyProps) {
|
|||||||
id: 'tls', type: 'topology',
|
id: 'tls', type: 'topology',
|
||||||
position: { x: tlsX, y: pipelineY },
|
position: { x: tlsX, y: pipelineY },
|
||||||
data: {
|
data: {
|
||||||
variant: 'tls', status: tlsStatus, certDomain, daysLeft, hasCert, provisionDomain,
|
variant: 'tls', status: tlsStatus, certDomain, isWildcardCert, daysLeft, hasCert, provisionDomain,
|
||||||
expiry: cert?.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : undefined,
|
expiry: cert?.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : undefined,
|
||||||
issuer: cert?.cert.issuerCN,
|
issuer: cert?.cert.issuerCN,
|
||||||
isProvisioningCert, isRenewingCert, onProvisionCert, onRenewCert,
|
isProvisioningCert, isRenewingCert, onProvisionCert, onRenewCert,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const TopologyNode = forwardRef<HTMLDivElement, TopologyNodeProps>(
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{label && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon && <span className="shrink-0 text-muted-foreground">{icon}</span>}
|
{icon && <span className="shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -69,6 +70,7 @@ export const TopologyNode = forwardRef<HTMLDivElement, TopologyNodeProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiClient } from './client';
|
|||||||
export interface CertInfo {
|
export interface CertInfo {
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
wildcard?: boolean;
|
||||||
expiry?: string;
|
expiry?: string;
|
||||||
certPath?: string;
|
certPath?: string;
|
||||||
keyPath?: string;
|
keyPath?: string;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
import type { DnsmasqStatus } from '../../types';
|
||||||
export interface DnsmasqStatus {
|
|
||||||
running: boolean;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dnsmasqApi = {
|
export const dnsmasqApi = {
|
||||||
async getStatus(): Promise<DnsmasqStatus> {
|
async getStatus(): Promise<DnsmasqStatus> {
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export { certApi } from './cert';
|
|||||||
export type { CertStatusResponse, CertInfo } from './cert';
|
export type { CertStatusResponse, CertInfo } from './cert';
|
||||||
export { cloudflareApi } from './cloudflare';
|
export { cloudflareApi } from './cloudflare';
|
||||||
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
|
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
|
||||||
export { operatorApi, centralDomainApi, dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
|
export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
|
||||||
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
|
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ export interface Message {
|
|||||||
type: 'info' | 'success' | 'error';
|
type: 'info' | 'success' | 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadingState {
|
|
||||||
[key: string]: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Messages {
|
export interface Messages {
|
||||||
[key: string]: Message;
|
[key: string]: Message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const formatTimestamp = (timestamp: string): string => {
|
|
||||||
return new Date(timestamp).toLocaleString();
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user