Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"context"
"crypto/tls"
"errors"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -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 "<recordPrefix>.<z32-endpoint-id>.<baseDomain>" 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 "<recordPrefix>.<z32>.<baseDomain>".
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
Expand Down Expand Up @@ -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{})
}
115 changes: 115 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
32 changes: 32 additions & 0 deletions internal/config/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions internal/config/zz_generated.defaults.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading