diff --git a/csil/v1/components/k3s.csil b/csil/v1/components/k3s.csil index 247b797..066c673 100644 --- a/csil/v1/components/k3s.csil +++ b/csil/v1/components/k3s.csil @@ -22,6 +22,8 @@ Config = { server_url: text @go_name("ServerURL"), dns_servers: [* text] @go_name("DNSServers"), ? additional_registries: [* AdditionalRegistry] @go_name("AdditionalRegistries"), + ? etcd_args: [* text] @go_name("EtcdArgs"), + ? allow_cgnat_vip: bool @go_name("AllowCGNATVIP"), } ; Additional registry configuration for user-defined registries diff --git a/csil/v1/components/tailscale.csil b/csil/v1/components/tailscale.csil new file mode 100644 index 0000000..1d75ae7 --- /dev/null +++ b/csil/v1/components/tailscale.csil @@ -0,0 +1,20 @@ +; Tailscale component configuration +; +; Configuration for Tailscale operator integration, enabling secure connectivity +; to Tailscale overlay networks with automated VIP route advertisement and DNS. + +options { + go_module: "github.com/catalystcommunity/foundry/v1", + go_package: "github.com/catalystcommunity/foundry/v1/internal/component/tailscale" +} + +; Tailscale operator configuration +; OAuth credentials can be literal values or OpenBAO references: ${secret:path:key} +; Default values will be set in Go code SetDefaults() function +Config = { + ? oauth_client_id: text @go_name("OAuthClientID"), + ? oauth_client_secret: text @go_name("OAuthClientSecret"), + ? operator_image: text, + ? advertise_routes: [* text], + ? tags: [* text] +} diff --git a/csil/v1/config/network-simple.csil b/csil/v1/config/network-simple.csil index 220ffba..aa5c639 100644 --- a/csil/v1/config/network-simple.csil +++ b/csil/v1/config/network-simple.csil @@ -48,7 +48,9 @@ ClusterConfig = { name: text, ? domain: text, primary_domain: text @go_name("PrimaryDomain"), - vip: text @go_name("VIP") + vip: text @go_name("VIP"), + ? allow_cgnat_vip: bool @go_name("AllowCGNATVIP"), + ? use_tailscale: bool @go_name("UseTailscale") } ; Map of component names to their configurations diff --git a/docs/tailscale-integration.md b/docs/tailscale-integration.md new file mode 100644 index 0000000..130685b --- /dev/null +++ b/docs/tailscale-integration.md @@ -0,0 +1,241 @@ +# Using Foundry with Tailscale Networks + +This guide covers deploying Foundry clusters on Tailscale overlay networks using CGNAT IP addresses (RFC 6598 Shared Address Space, 100.64.0.0/10). + +## Overview + +Tailscale uses the CGNAT IP range (100.64.0.0/10) for its overlay network, which is outside the traditional RFC 1918 private IP ranges. By default, Foundry's VIP validation only accepts RFC 1918 addresses. The `allow_cgnat_vip` configuration flag enables support for Tailscale and similar overlay networks. + +## Prerequisites + +- Tailscale installed and configured on all cluster nodes +- Nodes tagged appropriately (e.g., `tag:k8s`) +- Tailscale ACL configured to allow inter-node communication + +## Required Tailscale ACL Configuration + +Your Tailscale ACL must allow: +1. **Your local machine → cluster nodes** (for Foundry SSH access) +2. **Cluster nodes → cluster nodes** (for K3s cluster formation) + +### Example ACL + +```json +{ + "acls": [ + { + "action": "accept", + "src": ["*"], + "dst": ["*:*"] + } + ], + "ssh": [ + { + "action": "accept", + "src": ["autogroup:members"], + "dst": ["tag:k8s"], + "users": ["root", "ubuntu"] + }, + { + "action": "accept", + "src": ["tag:k8s"], + "dst": ["tag:k8s"], + "users": ["root"] + } + ], + "tagOwners": { + "tag:k8s": ["autogroup:admin"] + } +} +``` + +**Critical:** The second SSH rule (`tag:k8s` → `tag:k8s`) allows cluster nodes to SSH to each other, which is required for K3s agent installation on worker nodes. + +## Configuration + +### Single Control Plane Setup (Recommended) + +For single control plane deployments, the simplest approach is to use the control plane's Tailscale IP as the VIP: + +```yaml +cluster: + name: my-cluster + primary_domain: example.local + vip: 100.81.89.62 # Control plane's Tailscale IP + allow_cgnat_vip: true + +hosts: + - hostname: control-plane + address: 100.81.89.62 + user: root + - hostname: worker-1 + address: 100.70.90.12 + user: root + - hostname: worker-2 + address: 100.125.196.1 + user: root +``` + +**Why this works:** +- Single control plane means no HA failover needed +- VIP is just a stable endpoint for workers to connect to +- Using the control plane's actual IP avoids routing complexity + +### High Availability (Multi-Control-Plane) Setup + +For HA setups with multiple control planes, you need to make the VIP routable via Tailscale: + +#### Option 1: Tailscale Subnet Routes + +Advertise the VIP as a subnet route from the active control plane: + +```bash +# On the control plane node +tailscale up --advertise-routes=100.81.89.100/32 +``` + +Then approve the route in the Tailscale admin console. + +```yaml +cluster: + name: my-cluster + primary_domain: example.local + vip: 100.81.89.100 # Dedicated VIP + allow_cgnat_vip: true +``` + +**Note:** kube-vip will manage the VIP assignment, but you need to ensure the route is advertised from whichever node currently holds the VIP. + +#### Option 2: Tailscale Operator (Recommended for HA) + +Install the Tailscale operator on control planes to automatically manage subnet route advertisements: + +```yaml +# Future enhancement - see "Roadmap" section +cluster: + name: my-cluster + primary_domain: example.local + vip: 100.81.89.100 + allow_cgnat_vip: true + use_tailscale: true # Not yet implemented +``` + +This will be available in a future Foundry release. + +## Network Routing Considerations + +### Understanding VIP Routing on Tailscale + +Traditional kube-vip assumes Layer 2 networking where the VIP can "float" between nodes via ARP announcements. Tailscale is a Layer 3 overlay network where: + +- **IPs are routed, not bridged** - Nodes communicate via Tailscale's WireGuard tunnels +- **No ARP** - IP routing is managed by Tailscale's coordination server +- **Explicit routes required** - Any IP that isn't a node's primary Tailscale IP needs to be advertised as a subnet route + +### VIP Reachability + +For worker nodes to reach the VIP: + +**Single control plane:** +- VIP = control plane IP → Always routable (it's the node's primary IP) + +**Multiple control planes:** +- VIP = dedicated IP → Must be advertised as subnet route +- Route must be updated when VIP moves between control planes +- Tailscale operator can automate this + +## Troubleshooting + +### Workers Can't Join Cluster + +**Symptom:** +``` +Failed to validate connection to cluster at https://100.81.89.100:6443: +failed to get CA certs: context deadline exceeded +``` + +**Diagnosis:** +Worker nodes cannot reach the VIP. Check: + +```bash +# On a worker node +curl -k https://:6443/version --max-time 5 + +# If it times out, the VIP is not routable +``` + +**Solution:** +- Single control plane: Set `vip` to control plane's IP +- Multi control plane: Advertise VIP as subnet route from active control plane + +### SSH Connection Refused Between Nodes + +**Symptom:** +``` +tailscale: tailnet policy does not permit you to SSH to this node +``` + +**Diagnosis:** +Tailscale ACL doesn't allow SSH between cluster nodes. + +**Solution:** +Add SSH rule allowing `tag:k8s` → `tag:k8s` as shown in the ACL example above. + +### VIP Assigned But Not Reachable + +**Symptom:** +- `ip addr show` on control plane shows VIP assigned +- Workers still can't reach it + +**Diagnosis:** +VIP is assigned to the local interface but not advertised to Tailscale. + +**Solution:** +```bash +# On control plane +tailscale up --advertise-routes=/32 + +# Then approve in Tailscale admin console +``` + +## Validation Checklist + +Before deploying: + +- [ ] All nodes have Tailscale installed and connected +- [ ] Nodes are tagged appropriately (e.g., `tag:k8s`) +- [ ] Tailscale ACL allows SSH from your machine to nodes +- [ ] Tailscale ACL allows SSH between nodes (`tag:k8s` → `tag:k8s`) +- [ ] For HA setups: VIP subnet route is configured and approved +- [ ] `allow_cgnat_vip: true` is set in cluster config +- [ ] Workers can reach the VIP: `curl -k https://:6443/version` + +## Roadmap + +Future enhancements planned for Tailscale integration: + +1. **Tailscale Operator Integration** (`use_tailscale: true`) + - Automatic operator installation on control planes + - Automated VIP subnet route management + - Support for cross-pod network policies via Tailscale ACLs + +2. **Multi-Cluster Mesh** + - Connect multiple Foundry clusters via Tailscale + - Cross-cluster service discovery + - Unified network policy across clusters + +3. **GitOps for Tailscale ACLs** + - Version control for network policies + - CI/CD automation for ACL updates + - Integration with Foundry stack management + +## References + +- [RFC 6598 - Shared Address Space (CGNAT)](https://www.rfc-editor.org/rfc/rfc6598) +- [Tailscale ACL Documentation](https://tailscale.com/kb/1018/acls/) +- [Tailscale Subnet Routes](https://tailscale.com/kb/1019/subnets/) +- [kube-vip Documentation](https://kube-vip.io/) + +## Contributing + +Found an issue or have suggestions for Tailscale integration? Please open an issue on the [Foundry GitHub repository](https://github.com/catalystcommunity/foundry). diff --git a/v1/cmd/foundry/commands/cluster/init.go b/v1/cmd/foundry/commands/cluster/init.go index b692b20..ac870b8 100644 --- a/v1/cmd/foundry/commands/cluster/init.go +++ b/v1/cmd/foundry/commands/cluster/init.go @@ -284,6 +284,7 @@ func InitializeCluster(ctx context.Context, cfg *config.Config) error { fmt.Sprintf("%s.%s", cfg.Cluster.Name, cfg.Cluster.PrimaryDomain), }, DisableComponents: []string{"traefik", "servicelb"}, + AllowCGNATVIP: cfg.Cluster.AllowCGNATVIP, } // Parse additional registries and etcd args from component config @@ -345,6 +346,7 @@ func InitializeCluster(ctx context.Context, cfg *config.Config) error { DisableComponents: k3sConfig.DisableComponents, RegistryConfig: k3sConfig.RegistryConfig, EtcdArgs: k3sConfig.EtcdArgs, + AllowCGNATVIP: k3sConfig.AllowCGNATVIP, } // Join control plane diff --git a/v1/internal/component/k3s/install.go b/v1/internal/component/k3s/install.go index ce7b741..549b352 100644 --- a/v1/internal/component/k3s/install.go +++ b/v1/internal/component/k3s/install.go @@ -294,8 +294,9 @@ func waitForK3sReady(executor SSHExecutor, retryCfg RetryConfig) error { func setupKubeVIP(ctx context.Context, executor SSHExecutor, cfg *Config) error { // Determine VIP config vipConfig := &VIPConfig{ - VIP: cfg.VIP, - Interface: cfg.Interface, + VIP: cfg.VIP, + Interface: cfg.Interface, + AllowCGNATVIP: cfg.AllowCGNATVIP, } // Generate kube-vip manifests diff --git a/v1/internal/component/k3s/types.gen.go b/v1/internal/component/k3s/types.gen.go index ce4ad0d..4a093ea 100644 --- a/v1/internal/component/k3s/types.gen.go +++ b/v1/internal/component/k3s/types.gen.go @@ -17,9 +17,8 @@ type Config struct { ServerURL string `json:"server_url" yaml:"server_url"` DNSServers []string `json:"dns_servers" yaml:"dns_servers"` AdditionalRegistries []AdditionalRegistry `json:"additional_registries,omitempty" yaml:"additional_registries,omitempty"` - // EtcdArgs are additional arguments passed to the embedded etcd server - // Example: ["heartbeat-interval=500", "election-timeout=5000"] EtcdArgs []string `json:"etcd_args,omitempty" yaml:"etcd_args,omitempty"` + AllowCGNATVIP *bool `json:"allow_cgnat_vip,omitempty" yaml:"allow_cgnat_vip,omitempty"` } // AdditionalRegistry represents a structured data type diff --git a/v1/internal/component/k3s/types.go b/v1/internal/component/k3s/types.go index 12930cf..caf0b5a 100644 --- a/v1/internal/component/k3s/types.go +++ b/v1/internal/component/k3s/types.go @@ -64,6 +64,11 @@ func ParseConfig(cfg component.ComponentConfig) (*Config, error) { config.VIP = vip } + // Allow CGNAT VIP + if allowCGNAT, ok := cfg.GetBool("allow_cgnat_vip"); ok { + config.AllowCGNATVIP = &allowCGNAT + } + // Interface if iface, ok := cfg.GetString("interface"); ok { config.Interface = iface @@ -194,7 +199,9 @@ func (c *Config) Validate() error { return fmt.Errorf("VIP is required") } - if err := ValidateVIP(c.VIP); err != nil { + // Dereference AllowCGNATVIP pointer (defaults to false if nil) + allowCGNAT := c.AllowCGNATVIP != nil && *c.AllowCGNATVIP + if err := ValidateVIP(c.VIP, allowCGNAT); err != nil { return fmt.Errorf("VIP validation failed: %w", err) } diff --git a/v1/internal/component/k3s/vip.go b/v1/internal/component/k3s/vip.go index 7ed13b9..74df47f 100644 --- a/v1/internal/component/k3s/vip.go +++ b/v1/internal/component/k3s/vip.go @@ -13,6 +13,9 @@ import ( type VIPConfig struct { VIP string Interface string + // AllowCGNATVIP is *bool (not bool) because it's optional in CSIL-generated Config. + // Pointer allows nil (not set) vs false (explicitly disabled). Defaults to false if nil. + AllowCGNATVIP *bool } // SSHExecutor is an interface for executing SSH commands @@ -22,7 +25,9 @@ type SSHExecutor interface { } // ValidateVIP validates that a VIP address is in correct format -func ValidateVIP(vip string) error { +// allowCGNAT enables validation of IPs in the 100.64.0.0/10 range (RFC6598 Shared Address Space) +// used by Tailscale and other overlay networks +func ValidateVIP(vip string, allowCGNAT bool) error { if vip == "" { return fmt.Errorf("VIP address cannot be empty") } @@ -38,20 +43,28 @@ func ValidateVIP(vip string) error { return fmt.Errorf("VIP must be an IPv4 address: %s", vip) } - // Check if it's a private IP (RFC1918) - if !isPrivateIP(ip) { - return fmt.Errorf("VIP should be a private IP address: %s", vip) + // Check if it's a private IP (RFC1918) or optionally shared address space (RFC6598) + if !isPrivateIP(ip, allowCGNAT) { + if allowCGNAT { + return fmt.Errorf("VIP should be a private IP address (RFC1918 or RFC6598): %s", vip) + } + return fmt.Errorf("VIP should be a private IP address: %s (hint: set allow_cgnat_vip: true to use Tailscale/CGNAT IPs)", vip) } return nil } -// isPrivateIP checks if an IP is in private ranges (RFC1918) -func isPrivateIP(ip net.IP) bool { +// isPrivateIP checks if an IP is in private ranges (RFC1918) or optionally shared address space (RFC6598) +func isPrivateIP(ip net.IP, allowCGNAT bool) bool { private := []string{ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", + "10.0.0.0/8", // RFC1918 - Private-Use + "172.16.0.0/12", // RFC1918 - Private-Use + "192.168.0.0/16", // RFC1918 - Private-Use + } + + // Optionally include CGNAT range (RFC6598) used by Tailscale and similar overlay networks + if allowCGNAT { + private = append(private, "100.64.0.0/10") // RFC6598 - Shared Address Space (CGNAT) } for _, cidr := range private { @@ -81,9 +94,9 @@ func DetectNetworkInterface(conn network.SSHExecutor) (string, error) { // DetermineVIPConfig determines the VIP configuration for the cluster // It validates the VIP and detects the network interface -func DetermineVIPConfig(vip string, conn network.SSHExecutor) (*VIPConfig, error) { +func DetermineVIPConfig(vip string, conn network.SSHExecutor, allowCGNAT bool) (*VIPConfig, error) { // Validate VIP - if err := ValidateVIP(vip); err != nil { + if err := ValidateVIP(vip, allowCGNAT); err != nil { return nil, fmt.Errorf("VIP validation failed: %w", err) } @@ -93,10 +106,14 @@ func DetermineVIPConfig(vip string, conn network.SSHExecutor) (*VIPConfig, error return nil, fmt.Errorf("interface detection failed: %w", err) } - return &VIPConfig{ + cfg := &VIPConfig{ VIP: vip, Interface: iface, - }, nil + } + if allowCGNAT { + cfg.AllowCGNATVIP = &allowCGNAT + } + return cfg, nil } // GenerateKubeVIPManifest generates the kube-vip DaemonSet manifest YAML @@ -115,7 +132,9 @@ func GenerateKubeVIPManifest(cfg *VIPConfig) (string, error) { } // Validate VIP one more time - if err := ValidateVIP(cfg.VIP); err != nil { + // Dereference AllowCGNATVIP pointer (defaults to false if nil) + allowCGNAT := cfg.AllowCGNATVIP != nil && *cfg.AllowCGNATVIP + if err := ValidateVIP(cfg.VIP, allowCGNAT); err != nil { return "", fmt.Errorf("invalid VIP configuration: %w", err) } diff --git a/v1/internal/component/k3s/vip_test.go b/v1/internal/component/k3s/vip_test.go index f92f0e0..4e8f853 100644 --- a/v1/internal/component/k3s/vip_test.go +++ b/v1/internal/component/k3s/vip_test.go @@ -90,7 +90,7 @@ func TestValidateVIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateVIP(tt.vip) + err := ValidateVIP(tt.vip, false) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) @@ -244,7 +244,7 @@ func TestDetermineVIPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mock := tt.setupMock() - got, err := DetermineVIPConfig(tt.vip, mock) + got, err := DetermineVIPConfig(tt.vip, mock, false) if tt.wantErr { require.Error(t, err) @@ -573,7 +573,7 @@ func TestIsPrivateIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ip := parseIP(t, tt.ip) - got := isPrivateIP(ip) + got := isPrivateIP(ip, false) assert.Equal(t, tt.want, got) }) } @@ -603,7 +603,7 @@ func TestVIPConfigIntegration(t *testing.T) { vip := "192.168.1.100" // Step 1: Determine VIP config - cfg, err := DetermineVIPConfig(vip, mock) + cfg, err := DetermineVIPConfig(vip, mock, false) require.NoError(t, err) assert.Equal(t, vip, cfg.VIP) assert.Equal(t, "eth0", cfg.Interface) diff --git a/v1/internal/component/k3s/worker.go b/v1/internal/component/k3s/worker.go index dbe3469..7b7dda3 100644 --- a/v1/internal/component/k3s/worker.go +++ b/v1/internal/component/k3s/worker.go @@ -22,7 +22,9 @@ func JoinWorker(ctx context.Context, executor SSHExecutor, serverURL string, tok // Validate VIP if provided (worker nodes may not need full config validation) if cfg.VIP != "" { - if err := ValidateVIP(cfg.VIP); err != nil { + // Dereference AllowCGNATVIP pointer (defaults to false if nil) + allowCGNAT := cfg.AllowCGNATVIP != nil && *cfg.AllowCGNATVIP + if err := ValidateVIP(cfg.VIP, allowCGNAT); err != nil { return fmt.Errorf("VIP validation failed: %w", err) } } diff --git a/v1/internal/component/tailscale/types.gen.go b/v1/internal/component/tailscale/types.gen.go new file mode 100644 index 0000000..b9fe476 --- /dev/null +++ b/v1/internal/component/tailscale/types.gen.go @@ -0,0 +1,14 @@ +// Package tailscale contains generated types. +// +// Code generated by csilgen; DO NOT EDIT. +package tailscale + +// Config represents a structured data type +type Config struct { + OAuthClientID *string `json:"oauth_client_id,omitempty" yaml:"oauth_client_id,omitempty"` + OAuthClientSecret *string `json:"oauth_client_secret,omitempty" yaml:"oauth_client_secret,omitempty"` + OperatorImage *string `json:"operator_image,omitempty" yaml:"operator_image,omitempty"` + AdvertiseRoutes []string `json:"advertise_routes,omitempty" yaml:"advertise_routes,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` +} + diff --git a/v1/internal/component/tailscale/types_test.go b/v1/internal/component/tailscale/types_test.go new file mode 100644 index 0000000..772f360 --- /dev/null +++ b/v1/internal/component/tailscale/types_test.go @@ -0,0 +1,193 @@ +package tailscale + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestConfig_MarshalUnmarshal(t *testing.T) { + tests := []struct { + name string + input string + wantNil map[string]bool // which fields should be nil + wantVals map[string]interface{} // which fields should have values + }{ + { + name: "all fields populated", + input: ` +oauth_client_id: "client-123" +oauth_client_secret: "secret-456" +operator_image: "tailscale/operator:v1.2.3" +advertise_routes: + - "10.0.0.0/8" + - "192.168.0.0/16" +tags: + - "tag:k8s-foundry" + - "tag:production" +`, + wantVals: map[string]interface{}{ + "oauth_client_id": "client-123", + "oauth_client_secret": "secret-456", + "operator_image": "tailscale/operator:v1.2.3", + }, + }, + { + name: "minimal config with defaults", + input: ` +oauth_client_id: "client-123" +oauth_client_secret: "secret-456" +`, + wantNil: map[string]bool{ + "operator_image": true, + "advertise_routes": false, // slice, not pointer + "tags": false, // slice, not pointer + }, + wantVals: map[string]interface{}{ + "oauth_client_id": "client-123", + "oauth_client_secret": "secret-456", + }, + }, + { + name: "with secret references", + input: ` +oauth_client_id: "${secret:foundry-core/tailscale:client_id}" +oauth_client_secret: "${secret:foundry-core/tailscale:client_secret}" +`, + wantVals: map[string]interface{}{ + "oauth_client_id": "${secret:foundry-core/tailscale:client_id}", + "oauth_client_secret": "${secret:foundry-core/tailscale:client_secret}", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg Config + if err := yaml.Unmarshal([]byte(tt.input), &cfg); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + // Check nil fields + for field, shouldBeNil := range tt.wantNil { + switch field { + case "operator_image": + if shouldBeNil && cfg.OperatorImage != nil { + t.Errorf("Expected %s to be nil, got: %v", field, *cfg.OperatorImage) + } + } + } + + // Check values + for field, wantVal := range tt.wantVals { + var gotVal interface{} + switch field { + case "oauth_client_id": + if cfg.OAuthClientID == nil { + t.Errorf("Expected %s to be set, got nil", field) + continue + } + gotVal = *cfg.OAuthClientID + case "oauth_client_secret": + if cfg.OAuthClientSecret == nil { + t.Errorf("Expected %s to be set, got nil", field) + continue + } + gotVal = *cfg.OAuthClientSecret + case "operator_image": + if cfg.OperatorImage == nil { + t.Errorf("Expected %s to be set, got nil", field) + continue + } + gotVal = *cfg.OperatorImage + } + + if gotVal != wantVal { + t.Errorf("%s: got %v, want %v", field, gotVal, wantVal) + } + } + }) + } +} + +func TestConfig_PointerTypes(t *testing.T) { + // Test that optional fields are pointer types (*string) + cfg := Config{} + + // All string fields should be nil by default + if cfg.OAuthClientID != nil { + t.Error("Expected OAuthClientID to be nil by default") + } + if cfg.OAuthClientSecret != nil { + t.Error("Expected OAuthClientSecret to be nil by default") + } + if cfg.OperatorImage != nil { + t.Error("Expected OperatorImage to be nil by default") + } + + // Slices should be nil (not allocated) + if cfg.AdvertiseRoutes != nil { + t.Error("Expected AdvertiseRoutes to be nil by default") + } + if cfg.Tags != nil { + t.Error("Expected Tags to be nil by default") + } +} + +func TestConfig_YAMLRoundTrip(t *testing.T) { + clientID := "client-123" + clientSecret := "secret-456" + image := "tailscale/operator:latest" + + original := Config{ + OAuthClientID: &clientID, + OAuthClientSecret: &clientSecret, + OperatorImage: &image, + AdvertiseRoutes: []string{"10.0.0.0/8", "192.168.0.0/16"}, + Tags: []string{"tag:k8s-foundry"}, + } + + // Marshal to YAML + data, err := yaml.Marshal(&original) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + // Unmarshal back + var roundtrip Config + if err := yaml.Unmarshal(data, &roundtrip); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + // Compare + if *roundtrip.OAuthClientID != *original.OAuthClientID { + t.Errorf("OAuthClientID mismatch: got %v, want %v", *roundtrip.OAuthClientID, *original.OAuthClientID) + } + if *roundtrip.OAuthClientSecret != *original.OAuthClientSecret { + t.Errorf("OAuthClientSecret mismatch: got %v, want %v", *roundtrip.OAuthClientSecret, *original.OAuthClientSecret) + } + if *roundtrip.OperatorImage != *original.OperatorImage { + t.Errorf("OperatorImage mismatch: got %v, want %v", *roundtrip.OperatorImage, *original.OperatorImage) + } + if len(roundtrip.AdvertiseRoutes) != len(original.AdvertiseRoutes) { + t.Errorf("AdvertiseRoutes length mismatch: got %d, want %d", len(roundtrip.AdvertiseRoutes), len(original.AdvertiseRoutes)) + } + if len(roundtrip.Tags) != len(original.Tags) { + t.Errorf("Tags length mismatch: got %d, want %d", len(roundtrip.Tags), len(original.Tags)) + } +} + +func TestConfig_EmptyYAML(t *testing.T) { + // Test that empty YAML unmarshals to zero value + var cfg Config + if err := yaml.Unmarshal([]byte("{}"), &cfg); err != nil { + t.Fatalf("Failed to unmarshal empty YAML: %v", err) + } + + if cfg.OAuthClientID != nil { + t.Error("Expected nil OAuthClientID from empty YAML") + } + if cfg.OAuthClientSecret != nil { + t.Error("Expected nil OAuthClientSecret from empty YAML") + } +} diff --git a/v1/internal/config/types.gen.go b/v1/internal/config/types.gen.go index d91c7ba..e47fbd6 100644 --- a/v1/internal/config/types.gen.go +++ b/v1/internal/config/types.gen.go @@ -43,6 +43,8 @@ type ClusterConfig struct { Domain *string `json:"domain,omitempty" yaml:"domain,omitempty"` PrimaryDomain string `json:"primary_domain" yaml:"primary_domain"` VIP string `json:"vip" yaml:"vip"` + AllowCGNATVIP *bool `json:"allow_cgnat_vip,omitempty" yaml:"allow_cgnat_vip,omitempty"` + UseTailscale *bool `json:"use_tailscale,omitempty" yaml:"use_tailscale,omitempty"` } // ComponentMap is a type alias