diff --git a/cmd/main.go b/cmd/main.go index b8f2549..28fc80d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -164,7 +164,10 @@ func main() { setupLog.Info("server config", "config", serverConfig) - // TODO(jreese) validate the config + if err := serverConfig.Validate(); err != nil { + setupLog.Error(err, "invalid server config") + os.Exit(1) + } cfg := ctrl.GetConfigOrDie() serverConfig.ControlPlaneClient.ApplyTo(cfg) diff --git a/internal/config/config.go b/internal/config/config.go index 0b9e3c7..3876afc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "context" "crypto/tls" + "errors" "fmt" "os" "path/filepath" @@ -150,6 +151,66 @@ type ConnectorConfig struct { // Defaults to 30 seconds. // +default=30 LeaseDurationSeconds int32 `json:"leaseDurationSeconds,omitempty"` + + // Iroh contains configuration specific to iroh-tunneled connectors. + Iroh IrohConnectorConfig `json:"iroh,omitempty"` +} + +// +k8s:deepcopy-gen=true + +// IrohConnectorConfig configures the iroh DNS discovery controller, which +// publishes ".." TXT records +// into a downstream DNS cluster for every Connector whose ConnectorClass +// is routed to iroh. +type IrohConnectorConfig struct { + // DNSEnabled toggles the iroh DNS discovery controller. When false the + // controller is not registered and the rest of these fields may be + // omitted. + // + // +default=false + DNSEnabled bool `json:"dnsEnabled,omitempty"` + + // DownstreamKubeconfigPath is the path to a kubeconfig file pointing + // at the cluster where DNSRecordSet resources are written. When empty, + // the operator's own in-cluster config is used (single-cluster + // deployment). + DownstreamKubeconfigPath string `json:"downstreamKubeconfigPath,omitempty"` + + // DNSZoneRef references the DNSZone (in the downstream cluster) that + // owns the names this controller manages. + DNSZoneRef IrohDNSZoneRef `json:"dnsZoneRef,omitempty"` + + // RecordPrefix is the leading DNS label of the discovery name. + // iroh uses "_iroh" by convention. + // + // +default="_iroh" + RecordPrefix string `json:"recordPrefix,omitempty"` + + // BaseDomain is the suffix appended to the prefix and z32 EndpointId + // to form the full lookup name "..". + BaseDomain string `json:"baseDomain,omitempty"` + + // TTLSeconds is the TTL written on each TXT record. + // + // +default=30 + TTLSeconds int32 `json:"ttlSeconds,omitempty"` +} + +// DownstreamRestConfig builds a rest.Config for the downstream cluster. +// An empty DownstreamKubeconfigPath falls back to the operator's own +// in-cluster config — same convention as DiscoveryConfig. +func (c *IrohConnectorConfig) DownstreamRestConfig() (*rest.Config, error) { + if c.DownstreamKubeconfigPath == "" { + return ctrl.GetConfig() + } + return clientcmd.BuildConfigFromFlags("", c.DownstreamKubeconfigPath) +} + +// +k8s:deepcopy-gen=true + +type IrohDNSZoneRef struct { + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` } // +k8s:deepcopy-gen=true @@ -1032,6 +1093,33 @@ func (c *DiscoveryConfig) ProjectRestConfig() (*rest.Config, error) { return clientcmd.BuildConfigFromFlags("", c.ProjectKubeconfigPath) } +// Validate returns a non-nil error if the loaded configuration violates a +// known invariant. New cross-field rules should land here as the +// codebase grows; today only Connector.Iroh is checked. +func (c *NetworkServicesOperator) Validate() error { + if err := c.Connector.Iroh.validate(); err != nil { + return fmt.Errorf("connector.iroh: %w", err) + } + return nil +} + +func (c *IrohConnectorConfig) validate() error { + if !c.DNSEnabled { + return nil + } + var errs []error + if c.BaseDomain == "" { + errs = append(errs, errors.New("baseDomain is required when dnsEnabled is true")) + } + if c.DNSZoneRef.Name == "" { + errs = append(errs, errors.New("dnsZoneRef.name is required when dnsEnabled is true")) + } + if c.DNSZoneRef.Namespace == "" { + errs = append(errs, errors.New("dnsZoneRef.namespace is required when dnsEnabled is true")) + } + return errors.Join(errs...) +} + func init() { SchemeBuilder.Register(&NetworkServicesOperator{}) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c0ad2b4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,115 @@ +package config + +import ( + "strings" + "testing" +) + +func TestNetworkServicesOperator_Validate_IrohDisabled(t *testing.T) { + // When DNSEnabled is false the rest of the iroh config is allowed to + // be empty — nothing depends on it. + cfg := &NetworkServicesOperator{} + if err := cfg.Validate(); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestNetworkServicesOperator_Validate_IrohEnabled(t *testing.T) { + full := IrohConnectorConfig{ + DNSEnabled: true, + BaseDomain: "datumconnect.net", + DNSZoneRef: IrohDNSZoneRef{Namespace: "datum-dns", Name: "datumconnect-net"}, + } + + tests := []struct { + name string + mutate func(*IrohConnectorConfig) + wantSub string + }{ + {name: "all required fields set"}, + { + name: "missing baseDomain", + mutate: func(c *IrohConnectorConfig) { c.BaseDomain = "" }, + wantSub: "baseDomain is required", + }, + { + name: "missing dnsZoneRef.name", + mutate: func(c *IrohConnectorConfig) { c.DNSZoneRef.Name = "" }, + wantSub: "dnsZoneRef.name is required", + }, + { + name: "missing dnsZoneRef.namespace", + mutate: func(c *IrohConnectorConfig) { c.DNSZoneRef.Namespace = "" }, + wantSub: "dnsZoneRef.namespace is required", + }, + { + name: "downstream kubeconfig path is optional (in-cluster fallback)", + mutate: func(c *IrohConnectorConfig) { + c.DownstreamKubeconfigPath = "" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iroh := full + if tt.mutate != nil { + tt.mutate(&iroh) + } + cfg := &NetworkServicesOperator{Connector: ConnectorConfig{Iroh: iroh}} + err := cfg.Validate() + if tt.wantSub == "" { + if err != nil { + t.Fatalf("expected nil, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantSub) + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Fatalf("expected error containing %q, got %q", tt.wantSub, err.Error()) + } + }) + } +} + +func TestNetworkServicesOperator_Validate_IrohEnabledAggregatesErrors(t *testing.T) { + cfg := &NetworkServicesOperator{ + Connector: ConnectorConfig{Iroh: IrohConnectorConfig{DNSEnabled: true}}, + } + err := cfg.Validate() + if err == nil { + t.Fatal("expected error, got nil") + } + // errors.Join joins distinct messages with newlines; all five required + // fields should be surfaced. + for _, want := range []string{ + "baseDomain is required", + "dnsZoneRef.name is required", + "dnsZoneRef.namespace is required", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("expected error to mention %q, got %q", want, err.Error()) + } + } +} + +func TestSetObjectDefaults_IrohConnectorConfig(t *testing.T) { + cfg := &NetworkServicesOperator{} + SetObjectDefaults_NetworkServicesOperator(cfg) + + iroh := cfg.Connector.Iroh + if got, want := iroh.RecordPrefix, "_iroh"; got != want { + t.Errorf("RecordPrefix = %q, want %q", got, want) + } + if got, want := iroh.TTLSeconds, int32(30); got != want { + t.Errorf("TTLSeconds = %d, want %d", got, want) + } + if iroh.DownstreamKubeconfigPath != "" { + t.Errorf("DownstreamKubeconfigPath should default to empty (in-cluster), got %q", iroh.DownstreamKubeconfigPath) + } + if iroh.DNSEnabled { + t.Error("DNSEnabled should default to false") + } +} diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go index 1cdd6ec..3243fbd 100644 --- a/internal/config/zz_generated.deepcopy.go +++ b/internal/config/zz_generated.deepcopy.go @@ -113,6 +113,7 @@ func (in *ClusterSettingsValidationOptions) DeepCopy() *ClusterSettingsValidatio // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectorConfig) DeepCopyInto(out *ConnectorConfig) { *out = *in + out.Iroh = in.Iroh } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorConfig. @@ -403,6 +404,37 @@ func (in *HTTPRouteValidationOptions) DeepCopy() *HTTPRouteValidationOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IrohConnectorConfig) DeepCopyInto(out *IrohConnectorConfig) { + *out = *in + out.DNSZoneRef = in.DNSZoneRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IrohConnectorConfig. +func (in *IrohConnectorConfig) DeepCopy() *IrohConnectorConfig { + if in == nil { + return nil + } + out := new(IrohConnectorConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IrohDNSZoneRef) DeepCopyInto(out *IrohDNSZoneRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IrohDNSZoneRef. +func (in *IrohDNSZoneRef) DeepCopy() *IrohDNSZoneRef { + if in == nil { + return nil + } + out := new(IrohDNSZoneRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LeaderElectionConfig) DeepCopyInto(out *LeaderElectionConfig) { *out = *in diff --git a/internal/config/zz_generated.defaults.go b/internal/config/zz_generated.defaults.go index 65817ce..d3dc318 100644 --- a/internal/config/zz_generated.defaults.go +++ b/internal/config/zz_generated.defaults.go @@ -230,6 +230,12 @@ func SetObjectDefaults_NetworkServicesOperator(in *NetworkServicesOperator) { if in.Connector.LeaseDurationSeconds == 0 { in.Connector.LeaseDurationSeconds = 30 } + if in.Connector.Iroh.RecordPrefix == "" { + in.Connector.Iroh.RecordPrefix = "_iroh" + } + if in.Connector.Iroh.TTLSeconds == 0 { + in.Connector.Iroh.TTLSeconds = 30 + } SetDefaults_DiscoveryConfig(&in.Discovery) if in.Redis.DialTimeout == nil { if err := json.Unmarshal([]byte(`"5s"`), &in.Redis.DialTimeout); err != nil {