diff --git a/README.md b/README.md index 4de046f..7cfa014 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,73 @@ ghcr.io/appthrust/dns-api: For local development or source installs, use the chart in [deploy/charts/dns-api](./deploy/charts/dns-api). +### Customer-managed DNS (external provider) + +The external provider is opt-in. It verifies customer-managed `A`, `AAAA`, and +`CNAME` records without creating, updating, or deleting DNS records and needs no +identity or provider credentials. Install cert-manager first, then save these +values as `external-values.yaml` and install from this checkout: + +```yaml +providers: + route53: + enabled: false + cloudflare: + enabled: false + external: + enabled: true + controllerName: external.dns.appthrust.io/controller + providerName: external.dns.appthrust.io + providerVersion: v1alpha1 + dnsQPS: 40 + dnsBurst: 80 +webhook: + enabled: true +certManager: + enabled: true +``` + +```sh +helm install dns-api ./deploy/charts/dns-api \ + --namespace dns-api-system \ + --create-namespace \ + --values external-values.yaml +``` + +Use a controller image built from the same revision (or a release containing the +external provider). The chart rejects external enablement without both webhook +and cert-manager support: endpoint conversion uses the webhook Service with +cert-manager CA injection, never a TLS-verification bypass. External-only installs +grant no cloud identity or Secret API permissions; serving TLS certificates are +mounted by Kubernetes. External ZoneClasses must omit `identityRef`. + +The chart values map to `--external-controller-enabled`, +`--external-controller-name`, `--external-provider-name`, +`--external-provider-version`, `--external-dns-qps`, and `--external-dns-burst`. +When running the manager directly, the first four accept environment defaults +`EXTERNAL_CONTROLLER_ENABLED`, `EXTERNAL_CONTROLLER_NAME`, +`EXTERNAL_PROVIDER_NAME`, and `EXTERNAL_PROVIDER_VERSION`; explicit flags win. +The defaults are the values above except enablement, which defaults to `false`. +DNS query limits apply per controller process. + +For Kustomize, `app/operator/config/default` remains external-disabled. +`app/operator/config/external` is the opt-in overlay that adds external controllers, +Provider, capability, TLS-backed APIService, and conversion-create permission to +the default Route 53/Cloudflare installation. Disabling cloud providers in a +custom overlay must also remove their identity admission entries and RBAC (and +Cloudflare's Secret permission). + +For a dedicated-member platform with base domain `B` and **M Projects**, customers +maintain **M+2 DNS record names**: `B` and `*.B` point at the PMC public edge, and +each `*..B` points at that Project's actual serving Gateway addresses. +Publish the required A/AAAA sets (or a supported CNAME) for each name; do not point +a shared wildcard at the union of reserved member VIPs. Verification requires +exact target sets, including the absence of unexpected address-family answers. +Wildcard sampling does not replace probing each exact application hostname. +Cross-namespace wildcard claims also require an explicit `allowedRecordSets.records` +grant. See the [external provider design](./docs/design/external-provider.md) for +resolver policy, readiness, and wildcard limits. + ## First Resources A typical setup has two sides. diff --git a/app/operator/cmd/manager/main.go b/app/operator/cmd/manager/main.go index 6857aa9..365baa7 100644 --- a/app/operator/cmd/manager/main.go +++ b/app/operator/cmd/manager/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "os" + "strconv" endpointrecordsetcontroller "github.com/appthrust/dns-api/internal/go/apps/endpoint/controllers/endpointrecordset" gatewayendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/gatewayendpoint/controllers/gateway" @@ -13,6 +14,10 @@ import ( cloudflarezoneclass "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/zoneclass" cloudflarezoneunit "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/zoneunit" cloudflarewebhook "github.com/appthrust/dns-api/internal/go/providers/cloudflare/webhook" + externalzoneclass "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneclass" + externalzoneunit "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneunit" + externalconversion "github.com/appthrust/dns-api/internal/go/providers/external/conversion" + externalprobe "github.com/appthrust/dns-api/internal/go/providers/external/probe" route53identity "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/identity" route53zoneclass "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneclass" route53zoneunit "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneunit" @@ -22,6 +27,7 @@ import ( dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -72,6 +78,7 @@ func init() { utilruntime.Must(dnsv1alpha1.AddToScheme(scheme)) utilruntime.Must(endpointv1alpha1.AddToScheme(scheme)) utilruntime.Must(endpointconversionv1alpha1.AddToScheme(scheme)) + utilruntime.Must(externalv1alpha1.AddToScheme(scheme)) utilruntime.Must(gatewayv1.Install(scheme)) utilruntime.Must(route53v1alpha1.AddToScheme(scheme)) } @@ -82,6 +89,10 @@ func main() { var leaderElection bool var gatewayEndpointControllerEnabled bool var serviceEndpointControllerEnabled bool + route53ControllerEnabled := envBoolOrDefault("ROUTE53_CONTROLLER_ENABLED", true) + cloudflareControllerEnabled := envBoolOrDefault("CLOUDFLARE_CONTROLLER_ENABLED", true) + externalControllerEnabled := envBoolOrDefault("EXTERNAL_CONTROLLER_ENABLED", false) + route53EndpointConversionEnabled := envBoolOrDefault("ROUTE53_ENDPOINT_CONVERSION_ENABLED", true) endpointRecordSetNamespace := envOrDefault("ENDPOINT_RECORDSET_NAMESPACE", "") route53ControllerName := envOrDefault("ROUTE53_CONTROLLER_NAME", route53zoneunit.DefaultControllerName) route53ProviderName := envOrDefault("ROUTE53_PROVIDER_NAME", route53v1alpha1.ProviderName) @@ -89,6 +100,11 @@ func main() { cloudflareControllerName := envOrDefault("CLOUDFLARE_CONTROLLER_NAME", cloudflarezoneunit.DefaultControllerName) cloudflareProviderName := envOrDefault("CLOUDFLARE_PROVIDER_NAME", cloudflarev1alpha1.ProviderName) cloudflareProviderVersion := envOrDefault("CLOUDFLARE_PROVIDER_VERSION", cloudflarev1alpha1.ProviderVersion) + externalControllerName := envOrDefault("EXTERNAL_CONTROLLER_NAME", externalv1alpha1.ControllerName) + externalProviderName := envOrDefault("EXTERNAL_PROVIDER_NAME", externalv1alpha1.ProviderName) + externalProviderVersion := envOrDefault("EXTERNAL_PROVIDER_VERSION", externalv1alpha1.ProviderVersion) + var externalDNSQPS float64 + var externalDNSBurst int flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -96,12 +112,21 @@ func main() { flag.BoolVar(&gatewayEndpointControllerEnabled, "gateway-endpoint-controller-enabled", true, "Watch Gateway API resources and synthesize EndpointRecordSets from HTTPRoutes.") flag.BoolVar(&serviceEndpointControllerEnabled, "service-endpoint-controller-enabled", true, "Watch Services and synthesize EndpointRecordSets from their endpoint annotations.") flag.StringVar(&endpointRecordSetNamespace, "endpoint-recordset-namespace", endpointRecordSetNamespace, "Namespace where generated EndpointRecordSet and RecordSet resources are stored. Defaults to the source namespace.") + flag.BoolVar(&route53ControllerEnabled, "route53-controller-enabled", route53ControllerEnabled, "Enable Route 53 controllers and identity admission.") + flag.BoolVar(&cloudflareControllerEnabled, "cloudflare-controller-enabled", cloudflareControllerEnabled, "Enable Cloudflare controllers and identity admission.") + flag.BoolVar(&externalControllerEnabled, "external-controller-enabled", externalControllerEnabled, "Enable external DNS verification controllers and endpoint conversion.") + flag.BoolVar(&route53EndpointConversionEnabled, "route53-endpoint-conversion-enabled", route53EndpointConversionEnabled, "Serve endpoint conversion when the Route 53 provider is enabled.") flag.StringVar(&route53ControllerName, "route53-controller-name", route53ControllerName, "ZoneClass.spec.controllerName handled by the Route 53 controller.") flag.StringVar(&route53ProviderName, "route53-provider-name", route53ProviderName, "Provider.metadata.name handled by the Route 53 controller.") flag.StringVar(&route53ProviderVersion, "route53-provider-version", route53ProviderVersion, "Provider version handled by the Route 53 controller.") flag.StringVar(&cloudflareControllerName, "cloudflare-controller-name", cloudflareControllerName, "ZoneClass.spec.controllerName handled by the Cloudflare controller.") flag.StringVar(&cloudflareProviderName, "cloudflare-provider-name", cloudflareProviderName, "Provider.metadata.name handled by the Cloudflare controller.") flag.StringVar(&cloudflareProviderVersion, "cloudflare-provider-version", cloudflareProviderVersion, "Provider version handled by the Cloudflare controller.") + flag.StringVar(&externalControllerName, "external-controller-name", externalControllerName, "ZoneClass.spec.controllerName handled by the external controller.") + flag.StringVar(&externalProviderName, "external-provider-name", externalProviderName, "Provider.metadata.name handled by the external controller.") + flag.StringVar(&externalProviderVersion, "external-provider-version", externalProviderVersion, "Provider version handled by the external controller.") + flag.Float64Var(&externalDNSQPS, "external-dns-qps", externalprobe.DefaultQPS, "Maximum external DNS query rate per controller process.") + flag.IntVar(&externalDNSBurst, "external-dns-burst", externalprobe.DefaultBurst, "Maximum external DNS query burst per controller process.") zapOpts := zap.Options{Development: true} zapOpts.BindFlags(flag.CommandLine) @@ -123,68 +148,102 @@ func main() { os.Exit(1) } - if err := (&route53identity.IdentityReconciler{ - Client: mgr.GetClient(), - Resolver: route53identity.NewAWSIdentityResolver(), - Recorder: mgr.GetEventRecorderFor("route53-identity-controller"), - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Route 53 Identity controller") - os.Exit(1) - } + if route53ControllerEnabled { + if err := (&route53identity.IdentityReconciler{ + Client: mgr.GetClient(), + Resolver: route53identity.NewAWSIdentityResolver(), + Recorder: mgr.GetEventRecorderFor("route53-identity-controller"), + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Route 53 Identity controller") + os.Exit(1) + } - if err := (&cloudflareidentity.IdentityReconciler{ - Client: mgr.GetClient(), - Resolver: cloudflareidentity.NewCloudflareAPIClient(), - Recorder: mgr.GetEventRecorderFor("cloudflare-identity-controller"), - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Cloudflare Identity controller") - os.Exit(1) - } + if err := (&route53zoneclass.ZoneClassReconciler{ + ControllerName: route53ControllerName, + Client: mgr.GetClient(), + ProviderName: route53ProviderName, + ProviderVersion: route53ProviderVersion, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up ZoneClass controller") + os.Exit(1) + } - if err := (&route53zoneclass.ZoneClassReconciler{ - ControllerName: route53ControllerName, - Client: mgr.GetClient(), - ProviderName: route53ProviderName, - ProviderVersion: route53ProviderVersion, - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up ZoneClass controller") - os.Exit(1) + if err := (&route53zoneunit.ZoneReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ProviderFactory: route53zoneunit.NewAWSProviderFactory(), + ControllerName: route53ControllerName, + ProviderName: route53ProviderName, + ProviderVersion: route53ProviderVersion, + Recorder: mgr.GetEventRecorderFor("route53-zoneunit-controller"), + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Route 53 ZoneUnit controller") + os.Exit(1) + } } - if err := (&cloudflarezoneclass.ZoneClassReconciler{ - ControllerName: cloudflareControllerName, - Client: mgr.GetClient(), - ProviderName: cloudflareProviderName, - ProviderVersion: cloudflareProviderVersion, - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Cloudflare ZoneClass controller") - os.Exit(1) - } + if cloudflareControllerEnabled { + if err := (&cloudflareidentity.IdentityReconciler{ + Client: mgr.GetClient(), + Resolver: cloudflareidentity.NewCloudflareAPIClient(), + Recorder: mgr.GetEventRecorderFor("cloudflare-identity-controller"), + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Cloudflare Identity controller") + os.Exit(1) + } - if err := (&route53zoneunit.ZoneReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - ProviderFactory: route53zoneunit.NewAWSProviderFactory(), - ControllerName: route53ControllerName, - ProviderName: route53ProviderName, - ProviderVersion: route53ProviderVersion, - Recorder: mgr.GetEventRecorderFor("route53-zoneunit-controller"), - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Route 53 ZoneUnit controller") - os.Exit(1) + if err := (&cloudflarezoneclass.ZoneClassReconciler{ + ControllerName: cloudflareControllerName, + Client: mgr.GetClient(), + ProviderName: cloudflareProviderName, + ProviderVersion: cloudflareProviderVersion, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Cloudflare ZoneClass controller") + os.Exit(1) + } + + if err := (&cloudflarezoneunit.ZoneReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ProviderFactory: cloudflarezoneunit.NewCloudflareAPIClient(), + ControllerName: cloudflareControllerName, + ProviderName: cloudflareProviderName, + ProviderVersion: cloudflareProviderVersion, + Recorder: mgr.GetEventRecorderFor("cloudflare-zoneunit-controller"), + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Cloudflare ZoneUnit controller") + os.Exit(1) + } } - if err := (&cloudflarezoneunit.ZoneReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - ProviderFactory: cloudflarezoneunit.NewCloudflareAPIClient(), - ControllerName: cloudflareControllerName, - ProviderName: cloudflareProviderName, - ProviderVersion: cloudflareProviderVersion, - Recorder: mgr.GetEventRecorderFor("cloudflare-zoneunit-controller"), - }).SetupWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Cloudflare ZoneUnit controller") - os.Exit(1) + if externalControllerEnabled { + if err := (&externalzoneclass.ZoneClassReconciler{ + Client: mgr.GetClient(), + ControllerName: externalControllerName, + ProviderName: externalProviderName, + ProviderVersion: externalProviderVersion, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up external ZoneClass controller") + os.Exit(1) + } + + // NewReconciler supplies the wire resolver, its effective pod DNS policy, + // time.Now, and random jitter when their injection hooks are unset. + externalReconciler, err := externalzoneunit.NewReconciler(mgr.GetClient(), externalzoneunit.Options{ + ControllerName: externalControllerName, + ProviderName: externalProviderName, + ProviderVersion: externalProviderVersion, + QPS: externalDNSQPS, + Burst: externalDNSBurst, + }) + if err != nil { + ctrl.Log.Error(err, "unable to configure external DNS resolver") + os.Exit(1) + } + if err := externalReconciler.SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up external ZoneUnit controller") + os.Exit(1) + } } if err := (&zoneunitcontroller.ZoneUnitCompositionReconciler{ @@ -230,15 +289,24 @@ func main() { ctrl.Log.Error(err, "unable to set up core validation webhook") os.Exit(1) } - if err := route53webhook.SetupValidationWebhookWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Route 53 validation webhook") - os.Exit(1) + if route53ControllerEnabled { + if err := route53webhook.SetupValidationWebhookWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Route 53 validation webhook") + os.Exit(1) + } + if route53EndpointConversionEnabled { + route53conversion.NewHandler().Register(mgr.GetWebhookServer()) + } } - if err := cloudflarewebhook.SetupValidationWebhookWithManager(mgr); err != nil { - ctrl.Log.Error(err, "unable to set up Cloudflare validation webhook") - os.Exit(1) + if cloudflareControllerEnabled { + if err := cloudflarewebhook.SetupValidationWebhookWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Cloudflare validation webhook") + os.Exit(1) + } + } + if externalControllerEnabled { + externalconversion.NewHandler().Register(mgr.GetWebhookServer()) } - route53conversion.NewHandler().Register(mgr.GetWebhookServer()) if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { ctrl.Log.Error(err, "unable to set up health check") @@ -262,3 +330,16 @@ func envOrDefault(name, fallback string) string { } return fallback } + +func envBoolOrDefault(name string, fallback bool) bool { + value := os.Getenv(name) + if value == "" { + return fallback + } + enabled, err := strconv.ParseBool(value) + if err != nil { + ctrl.Log.Error(err, "invalid boolean environment variable", "name", name) + os.Exit(1) + } + return enabled +} diff --git a/app/operator/cmd/manager/main_test.go b/app/operator/cmd/manager/main_test.go new file mode 100644 index 0000000..26e9460 --- /dev/null +++ b/app/operator/cmd/manager/main_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "os/exec" + "strings" + "testing" + + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" +) + +func TestProviderEnablementEnvironmentDefaults(t *testing.T) { + for name, fallback := range map[string]bool{ + "ROUTE53_CONTROLLER_ENABLED": true, + "CLOUDFLARE_CONTROLLER_ENABLED": true, + "ROUTE53_ENDPOINT_CONVERSION_ENABLED": true, + "EXTERNAL_CONTROLLER_ENABLED": false, + } { + t.Run(name, func(t *testing.T) { + for _, test := range []struct { + value string + want bool + }{ + {value: "", want: fallback}, + {value: "false", want: false}, + {value: "true", want: true}, + } { + t.Setenv(name, test.value) + if got := envBoolOrDefault(name, fallback); got != test.want { + t.Fatalf("envBoolOrDefault(%q) with %q = %t, want %t", name, test.value, got, test.want) + } + } + }) + } +} + +func TestExternalSchemeRegistered(t *testing.T) { + if !scheme.IsVersionRegistered(externalv1alpha1.SchemeGroupVersion) { + t.Fatal("external API scheme is not registered") + } +} + +// Run the real manager's flag binding in a subprocess. Help exits before any +// Kubernetes client, resolver, controller, or webhook is constructed. +func TestManagerFlagsProcess(t *testing.T) { + if os.Getenv("DNS_API_TEST_MANAGER_FLAGS") != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...) + break + } + } + flag.CommandLine = flag.NewFlagSet("manager", flag.ExitOnError) + flag.CommandLine.Usage = func() { + values := map[string]string{} + flag.CommandLine.VisitAll(func(f *flag.Flag) { values[f.Name] = f.Value.String() }) + if err := json.NewEncoder(os.Stdout).Encode(values); err != nil { + os.Exit(1) + } + } + main() +} + +func TestManagerProviderFlags(t *testing.T) { + defaults := map[string]string{ + "route53-controller-enabled": "true", + "cloudflare-controller-enabled": "true", + "route53-endpoint-conversion-enabled": "true", + "external-controller-enabled": "false", + "route53-controller-name": "route53.dns.appthrust.io/controller", + "route53-provider-name": "route53.dns.appthrust.io", + "route53-provider-version": "v1alpha1", + "cloudflare-controller-name": "cloudflare.dns.appthrust.io/controller", + "cloudflare-provider-name": "cloudflare.dns.appthrust.io", + "cloudflare-provider-version": "v1alpha1", + "external-controller-name": "external.dns.appthrust.io/controller", + "external-provider-name": "external.dns.appthrust.io", + "external-provider-version": "v1alpha1", + "external-dns-qps": "40", + "external-dns-burst": "80", + } + for _, mode := range []string{"defaults", "environment", "flags-override-environment"} { + t.Run(mode, func(t *testing.T) { + env := append(os.Environ(), "DNS_API_TEST_MANAGER_FLAGS=1") + args := []string{"-test.run=^TestManagerFlagsProcess$", "--"} + expected := map[string]string{} + for name, fallback := range defaults { + value := "" + want := fallback + hasEnvironment := name != "external-dns-qps" && name != "external-dns-burst" + if mode != "defaults" && hasEnvironment { + if strings.HasSuffix(name, "-enabled") { + value = "false" + if fallback == "false" { + value = "true" + } + } else { + value = "environment-" + name + } + want = value + } + if hasEnvironment { + env = append(env, strings.ToUpper(strings.ReplaceAll(name, "-", "_"))+"="+value) + } + if mode == "flags-override-environment" { + want = fallback + if name == "external-dns-qps" { + want = "12.5" + } + if name == "external-dns-burst" { + want = "25" + } + args = append(args, "--"+name+"="+want) + } + expected[name] = want + } + args = append(args, "--help") + cmd := exec.Command(os.Args[0], args...) + cmd.Env = env + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("manager flag parsing failed: %v\n%s", err, output) + } + var actual map[string]string + if err := json.Unmarshal(output, &actual); err != nil { + t.Fatalf("decode manager flags: %v\n%s", err, output) + } + for name, want := range expected { + if actual[name] != want { + t.Errorf("--%s = %q, want %q", name, actual[name], want) + } + } + }) + } +} diff --git a/app/operator/config/crd/bases/dns.appthrust.io_zoneclasses.yaml b/app/operator/config/crd/bases/dns.appthrust.io_zoneclasses.yaml index bd3c826..35f5b0e 100644 --- a/app/operator/config/crd/bases/dns.appthrust.io_zoneclasses.yaml +++ b/app/operator/config/crd/bases/dns.appthrust.io_zoneclasses.yaml @@ -121,8 +121,9 @@ spec: - message: controllerName is immutable rule: self == oldSelf identityRef: - description: IdentityRef selects the provider identity resource in - the ZoneClass namespace. + description: |- + IdentityRef selects the provider identity resource in the ZoneClass namespace. + Required when the selected Provider version declares identity; forbidden otherwise. properties: name: description: Name is the referenced object name in the same namespace @@ -154,7 +155,6 @@ spec: required: - allowedZones - controllerName - - identityRef - parameters - provider type: object diff --git a/app/operator/config/external/endpoint_capabilities.yaml b/app/operator/config/external/endpoint_capabilities.yaml new file mode 100644 index 0000000..9d07fd3 --- /dev/null +++ b/app/operator/config/external/endpoint_capabilities.yaml @@ -0,0 +1,12 @@ +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: external-v1alpha1 +spec: + provider: + name: external.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions diff --git a/app/operator/config/external/external_endpoint_conversion_apiservice.yaml b/app/operator/config/external/external_endpoint_conversion_apiservice.yaml new file mode 100644 index 0000000..3e6924a --- /dev/null +++ b/app/operator/config/external/external_endpoint_conversion_apiservice.yaml @@ -0,0 +1,15 @@ +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.endpoint.external.dns.appthrust.io + annotations: + cert-manager.io/inject-ca-from: dns-api-system/dns-api-webhook-serving-cert +spec: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: dns-api-webhook-service + namespace: dns-api-system + port: 443 diff --git a/app/operator/config/external/external_extensions.yaml b/app/operator/config/external/external_extensions.yaml new file mode 100644 index 0000000..f24735f --- /dev/null +++ b/app/operator/config/external/external_extensions.yaml @@ -0,0 +1,162 @@ +apiVersion: dns.appthrust.io/v1alpha1 +kind: Provider +metadata: + name: external.dns.appthrust.io +spec: + display: + name: External DNS + description: Verifies customer-managed DNS without changing DNS records. + versions: + - name: v1alpha1 + served: true + storage: true + zoneClass: + schemas: + parameters: + openAPIV3Schema: + type: object + description: DNS resolver policy for verification without DNS writes. + additionalProperties: false + properties: + resolver: + type: object + description: Resolver view used for every verification query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + description: One recursive resolver endpoint as IPv4:port or [IPv6]:port. + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + expectedNameservers: + type: array + description: Expected authoritative nameservers for the exact Zone domain. + maxItems: 16 + uniqueItems: true + items: + type: string + description: One expected authoritative nameserver hostname, lowercase, without a trailing dot. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + validationRules: + - rule: >- + has(self.spec.parameters.resolver) && + has(self.spec.parameters.resolver.mode) && + self.spec.parameters.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.resolver.servers) && size(self.spec.parameters.resolver.servers) > 0) + : (!has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || size(self.spec.parameters.resolver.servers) == 0) + message: resolver.servers must be nonempty for Recursive and empty for Cluster + - rule: >- + !has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || + self.spec.parameters.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: resolver.servers must contain canonical IP addresses and ports from 1 through 65535 + - rule: >- + !has(self.spec.parameters.expectedNameservers) || + self.spec.parameters.expectedNameservers.all(n, !isIP(n)) + message: expectedNameservers must contain DNS names, not IP addresses + zone: + schemas: + adoption: &empty + openAPIV3Schema: + type: object + additionalProperties: false + properties: {} + statusProviderData: &status + openAPIV3Schema: + type: object + description: Bounded read-only DNS verification evidence; not DNS withdrawal proof. + additionalProperties: false + properties: + resolverMode: + type: string + description: Selected recursive resolver view policy. + enum: [Cluster, Recursive] + checkedName: + type: string + description: Absolute DNS owner checked, without the root dot. + maxLength: 253 + sampledWildcard: + type: boolean + description: Whether checkedName is a stable concrete wildcard witness. + actual: &preview + type: object + description: Sorted bounded diagnostic preview; equality uses the complete observation. + additionalProperties: false + required: [total, truncated] + properties: + values: + type: array + description: Up to sixteen typed values or diagnostic entries. + maxItems: 16 + items: + type: string + description: One bounded value or diagnostic entry. + maxLength: 512 + total: + type: integer + description: Total entries before truncation. + minimum: 0 + truncated: + type: boolean + description: Whether values omits entries from the complete observation. + missing: *preview + extra: *preview + cnameChain: *preview + views: *preview + observedTTLSeconds: + type: integer + description: Minimum relevant positive TTL in seconds, when observed. + minimum: 0 + negativeTTLSeconds: + type: integer + description: Minimum of negative SOA TTL and SOA MINIMUM, when observed. + minimum: 0 + negativeExpiryTime: + type: string + format: date-time + description: Negative cache expiry suggested by the observed SOA, not the polling cap. + lastProbeTime: + type: string + format: date-time + description: Last completed attempt, including failures. + nextProbeTime: + type: string + format: date-time + description: Approximate next planned attempt; queue load can delay it. + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: VerificationConfigured, description: Verifier configured without authority checks.} + - {conditionType: Programmed, status: 'True', reason: NameserversVerified, description: Every required view verified the exact apex and nameservers.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Observed apex or nameservers differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordSet: + supportedTypes: [A, AAAA, CNAME] + schemas: + options: *empty + adoption: *empty + statusProviderData: *status + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: ResolutionVerified, description: Exact targets verified in every required view.} + - {conditionType: Programmed, status: 'False', reason: DNSNameNotFound, description: Required view returned NXDOMAIN.} + - {conditionType: Programmed, status: 'False', reason: DNSNoData, description: Required record type has no answers.} + - {conditionType: Programmed, status: 'False', reason: ResolutionPartial, description: A nonempty proper subset of targets was observed.} + - {conditionType: Programmed, status: 'False', reason: ResolutionMismatch, description: Targets or CNAME chain differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} diff --git a/app/operator/config/external/kustomization.yaml b/app/operator/config/external/kustomization.yaml new file mode 100644 index 0000000..15e42db --- /dev/null +++ b/app/operator/config/external/kustomization.yaml @@ -0,0 +1,45 @@ +resources: + - ../default + - external_extensions.yaml + - endpoint_capabilities.yaml + - external_endpoint_conversion_apiservice.yaml +patches: + - target: + group: apps + version: v1 + kind: Deployment + name: dns-api-controller-manager + patch: |- + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-controller-enabled=true + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-controller-name=external.dns.appthrust.io/controller + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-provider-name=external.dns.appthrust.io + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-provider-version=v1alpha1 + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-dns-qps=40 + - op: add + path: /spec/template/spec/containers/0/args/- + value: --external-dns-burst=80 + - target: + group: rbac.authorization.k8s.io + version: v1 + kind: ClusterRole + name: dns-api-controller-manager-role + patch: |- + - op: add + path: /rules/- + value: + apiGroups: + - endpoint.external.dns.appthrust.io + resources: + - endpointrecordsetconversions + verbs: + - create diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneclasses.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneclasses.yaml index bd3c826..35f5b0e 100644 --- a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneclasses.yaml +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneclasses.yaml @@ -121,8 +121,9 @@ spec: - message: controllerName is immutable rule: self == oldSelf identityRef: - description: IdentityRef selects the provider identity resource in - the ZoneClass namespace. + description: |- + IdentityRef selects the provider identity resource in the ZoneClass namespace. + Required when the selected Provider version declares identity; forbidden otherwise. properties: name: description: Name is the referenced object name in the same namespace @@ -154,7 +155,6 @@ spec: required: - allowedZones - controllerName - - identityRef - parameters - provider type: object diff --git a/deploy/charts/dns-api/external_contract_test.go b/deploy/charts/dns-api/external_contract_test.go new file mode 100644 index 0000000..7507f28 --- /dev/null +++ b/deploy/charts/dns-api/external_contract_test.go @@ -0,0 +1,309 @@ +package dnsapichart + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "testing" + + externalmanifest "github.com/appthrust/dns-api/internal/go/providers/external/manifest" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/yaml" +) + +func TestExternalOnlyRender(t *testing.T) { + for _, gateway := range []bool{false, true} { + for _, service := range []bool{false, true} { + t.Run(fmt.Sprintf("gateway=%t/service=%t", gateway, service), func(t *testing.T) { + rendered := renderChart(t, + "--set", "providers.route53.enabled=false", + "--set", "providers.cloudflare.enabled=false", + "--set", "providers.external.enabled=true", + "--set", "gatewayEndpoint.enabled="+strconv.FormatBool(gateway), + "--set", "serviceEndpoint.enabled="+strconv.FormatBool(service), + ) + role := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRole") + for _, forbidden := range []string{"secrets", "identities", "route53.dns.appthrust.io", "cloudflare.dns.appthrust.io"} { + if strings.Contains(role, forbidden) { + t.Errorf("external-only role contains %q:\n%s", forbidden, role) + } + } + for permission, enabled := range map[string]bool{ + "- gateway.networking.k8s.io\n": gateway, + "- services\n": service, + } { + if strings.Contains(role, permission) != enabled { + t.Errorf("permission %q presence does not equal %t", permission, enabled) + } + } + assertExternalConversionRule(t, role) + webhooks := renderedDocument(t, rendered, "admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration") + if strings.Contains(webhooks, "identities") { + t.Fatal("external-only installation includes identity admission") + } + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + for _, arg := range []string{ + "--route53-controller-enabled=false", + "--cloudflare-controller-enabled=false", + "--route53-endpoint-conversion-enabled=false", + "--external-controller-enabled=true", + "--external-controller-name=external.dns.appthrust.io/controller", + "--external-provider-name=external.dns.appthrust.io", + "--external-provider-version=v1alpha1", + "--external-dns-qps=40", + "--external-dns-burst=80", + } { + if !strings.Contains(deployment, arg) { + t.Errorf("external-only Deployment is missing %q", arg) + } + } + for _, kind := range []string{"Provider", "EndpointProviderCapability", "APIService", "ClusterRoleBinding"} { + if count := strings.Count(rendered, "kind: "+kind+"\n"); count != 1 { + t.Errorf("got %d %s declarations, want exactly one", count, kind) + } + } + assertManifestEqual(t, externalmanifest.ProviderYAML, renderedDocument(t, rendered, "dns.appthrust.io/v1alpha1", "Provider")) + assertManifestEqual(t, externalmanifest.CapabilityYAML, renderedDocument(t, rendered, "endpoint.dns.appthrust.io/v1alpha1", "EndpointProviderCapability")) + assertExternalTLS(t, rendered) + }) + } + } +} + +func TestExternalCanCoexistWithCloudProviders(t *testing.T) { + for _, route53 := range []bool{false, true} { + for _, cloudflare := range []bool{false, true} { + t.Run(fmt.Sprintf("route53=%t/cloudflare=%t", route53, cloudflare), func(t *testing.T) { + rendered := renderChart(t, + "--set", "providers.external.enabled=true", + "--set", "providers.route53.enabled="+strconv.FormatBool(route53), + "--set", "providers.cloudflare.enabled="+strconv.FormatBool(cloudflare), + ) + role := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRole") + assertExternalConversionRule(t, role) + for permission, enabled := range map[string]bool{ + "- route53.dns.appthrust.io\n": route53, + "- endpoint.route53.dns.appthrust.io\n": route53, + "- cloudflare.dns.appthrust.io\n": cloudflare, + "- secrets\n": cloudflare, + } { + if strings.Contains(role, permission) != enabled { + t.Errorf("permission %q presence does not equal %t", permission, enabled) + } + } + }) + } + } +} + +func TestExternalRequiresWebhookAndCertManager(t *testing.T) { + for _, webhook := range []bool{false, true} { + for _, certManager := range []bool{false, true} { + if webhook && certManager { + continue + } + t.Run(fmt.Sprintf("webhook=%t/certManager=%t", webhook, certManager), func(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + output, err := exec.Command("helm", "template", "dns-api", filepath.Dir(file), + "--set", "providers.external.enabled=true", + "--set", "webhook.enabled="+strconv.FormatBool(webhook), + "--set", "certManager.enabled="+strconv.FormatBool(certManager), + ).CombinedOutput() + if err == nil { + t.Fatal("external enablement without serving TLS succeeded") + } + if !strings.Contains(string(output), "providers.external.enabled requires webhook.enabled=true and certManager.enabled=true") { + t.Fatalf("unexpected render failure: %v\n%s", err, output) + } + }) + } + } +} + +func TestExternalDefaultsToDisabled(t *testing.T) { + rendered := renderChart(t) + if explicit := renderChart(t, "--set", "providers.external.enabled=false"); rendered != explicit { + t.Fatal("default render differs from explicitly disabled external provider") + } + for _, forbidden := range []string{"external.dns.appthrust.io", "--external-dns-", "--external-provider-"} { + if strings.Contains(rendered, forbidden) { + t.Errorf("default render contains external declaration %q", forbidden) + } + } + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + for _, arg := range []string{"--route53-controller-enabled=true", "--cloudflare-controller-enabled=true", "--route53-endpoint-conversion-enabled=true", "--external-controller-enabled=false"} { + if !strings.Contains(deployment, arg) { + t.Errorf("default Deployment is missing %q", arg) + } + } + // External's strict TLS requirement must not change existing disabled-provider renders. + renderChart(t, "--set", "webhook.enabled=false", "--set", "certManager.enabled=false") +} + +func TestExternalCustomBindings(t *testing.T) { + rendered := renderChart(t, + "--set", "providers.external.enabled=true", + "--set", "providers.route53.enabled=false", + "--set", "providers.cloudflare.enabled=false", + "--set", "providers.external.controllerName=custom.example/controller", + "--set", "providers.external.providerName=custom.example", + "--set", "providers.external.providerVersion=v1beta1", + "--set", "providers.external.dnsQPS=12.5", + "--set", "providers.external.dnsBurst=25", + "--set", "fullnameOverride=custom-dns", + "--set", "webhook.service.port=8443", + ) + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + for _, arg := range []string{"--external-controller-name=custom.example/controller", "--external-provider-name=custom.example", "--external-provider-version=v1beta1", "--external-dns-qps=12.5", "--external-dns-burst=25"} { + if !strings.Contains(deployment, arg) { + t.Errorf("custom Deployment is missing %q", arg) + } + } + provider := manifestMap(t, renderedDocument(t, rendered, "dns.appthrust.io/v1alpha1", "Provider")) + if provider["metadata"].(map[string]any)["name"] != "custom.example" || provider["spec"].(map[string]any)["versions"].([]any)[0].(map[string]any)["name"] != "v1beta1" { + t.Fatal("Provider does not match custom runtime binding") + } + capability := manifestMap(t, renderedDocument(t, rendered, "endpoint.dns.appthrust.io/v1alpha1", "EndpointProviderCapability")) + spec := capability["spec"].(map[string]any) + if !reflect.DeepEqual(spec["provider"], map[string]any{"name": "custom.example", "version": "v1beta1"}) { + t.Fatalf("capability does not match custom binding: %#v", spec) + } + // Provider versions are configurable; the stateless conversion protocol is not. + if !reflect.DeepEqual(spec["conversion"], map[string]any{"group": "endpoint.external.dns.appthrust.io", "version": "v1alpha1", "resource": "endpointrecordsetconversions"}) { + t.Fatalf("custom binding changed the conversion GVR: %#v", spec) + } + assertExternalTLS(t, rendered) +} + +func TestExternalKustomizeParity(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + configDir := filepath.Join(filepath.Dir(file), "../../../app/operator/config") + for name, expected := range map[string][]byte{"external_extensions.yaml": externalmanifest.ProviderYAML, "endpoint_capabilities.yaml": externalmanifest.CapabilityYAML} { + actual, err := os.ReadFile(filepath.Join(configDir, "external", name)) + if err != nil { + t.Fatal(err) + } + assertManifestEqual(t, expected, string(actual)) + } + output, err := exec.Command("kustomize", "build", filepath.Join(configDir, "external")).CombinedOutput() + if err != nil { + t.Fatalf("external kustomize build failed: %v\n%s", err, output) + } + rendered := string(output) + assertExternalTLS(t, rendered) + role := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRole") + assertExternalConversionRule(t, role) + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + for _, arg := range []string{"--external-controller-enabled=true", "--external-controller-name=external.dns.appthrust.io/controller", "--external-provider-name=external.dns.appthrust.io", "--external-provider-version=v1alpha1", "--external-dns-qps=40", "--external-dns-burst=80"} { + if !strings.Contains(deployment, arg) { + t.Errorf("Kustomize Deployment is missing %q", arg) + } + } + base, err := exec.Command("kustomize", "build", filepath.Join(configDir, "default")).CombinedOutput() + if err != nil { + t.Fatalf("default kustomize build failed: %v\n%s", err, base) + } + if strings.Contains(string(base), "external.dns.appthrust.io") || strings.Contains(string(base), "--external-controller-enabled=true") { + t.Fatal("Kustomize default installation enables external DNS") + } + var baseRole, externalRole rbacv1.ClusterRole + if err := yaml.Unmarshal([]byte(renderedDocument(t, string(base), "rbac.authorization.k8s.io/v1", "ClusterRole")), &baseRole); err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal([]byte(role), &externalRole); err != nil { + t.Fatal(err) + } + var unchanged []rbacv1.PolicyRule + for _, rule := range externalRole.Rules { + if !reflect.DeepEqual(rule.APIGroups, []string{"endpoint.external.dns.appthrust.io"}) { + unchanged = append(unchanged, rule) + } + } + if !reflect.DeepEqual(baseRole.Rules, unchanged) { + t.Fatal("external overlay changes permissions beyond its conversion-create rule") + } +} + +func assertExternalConversionRule(t *testing.T, source string) { + t.Helper() + var role rbacv1.ClusterRole + if err := yaml.Unmarshal([]byte(source), &role); err != nil { + t.Fatal(err) + } + expected := rbacv1.PolicyRule{APIGroups: []string{"endpoint.external.dns.appthrust.io"}, Resources: []string{"endpointrecordsetconversions"}, Verbs: []string{"create"}} + count := 0 + for _, rule := range role.Rules { + for _, group := range rule.APIGroups { + if group == "endpoint.external.dns.appthrust.io" { + count++ + if !reflect.DeepEqual(rule, expected) { + t.Errorf("external conversion permission differs: %#v", rule) + } + } + } + } + if count != 1 { + t.Errorf("got %d external conversion rules, want exactly one", count) + } +} + +func assertExternalTLS(t *testing.T, rendered string) { + t.Helper() + var apiService map[string]any + for _, document := range strings.Split(rendered, "\n---") { + if strings.Contains(document, "kind: APIService\n") && strings.Contains(document, "name: v1alpha1.endpoint.external.dns.appthrust.io\n") { + apiService = manifestMap(t, document) + } + } + if apiService == nil { + t.Fatal("external conversion APIService is missing") + } + spec := apiService["spec"].(map[string]any) + if _, exists := spec["insecureSkipTLSVerify"]; exists { + t.Fatal("external APIService bypasses TLS verification") + } + if spec["group"] != "endpoint.external.dns.appthrust.io" || spec["version"] != "v1alpha1" { + t.Fatalf("external APIService GVR differs: %#v", spec) + } + certificate := manifestMap(t, renderedDocument(t, rendered, "cert-manager.io/v1", "Certificate")) + certMetadata := certificate["metadata"].(map[string]any) + annotation := apiService["metadata"].(map[string]any)["annotations"].(map[string]any)["cert-manager.io/inject-ca-from"] + if annotation != certMetadata["namespace"].(string)+"/"+certMetadata["name"].(string) { + t.Fatalf("CA injection does not reference serving Certificate: %v", annotation) + } + service := manifestMap(t, renderedDocument(t, rendered, "v1", "Service")) + serviceMetadata := service["metadata"].(map[string]any) + serviceRef := spec["service"].(map[string]any) + servicePort := service["spec"].(map[string]any)["ports"].([]any)[0].(map[string]any)["port"] + if serviceRef["namespace"] != serviceMetadata["namespace"] || serviceRef["name"] != serviceMetadata["name"] || serviceRef["port"] != servicePort { + t.Fatalf("APIService does not reference webhook Service: %#v", serviceRef) + } + wantDNSName := serviceRef["name"].(string) + "." + serviceRef["namespace"].(string) + ".svc" + for _, name := range certificate["spec"].(map[string]any)["dnsNames"].([]any) { + if name == wantDNSName { + return + } + } + t.Fatalf("Certificate does not cover webhook Service DNS name %s", wantDNSName) +} + +func assertManifestEqual(t *testing.T, expected []byte, actual string) { + t.Helper() + if !reflect.DeepEqual(manifestMap(t, string(expected)), manifestMap(t, actual)) { + t.Fatalf("packaged manifest differs from embedded source:\n%s", actual) + } +} + +func manifestMap(t *testing.T, source string) map[string]any { + t.Helper() + var result map[string]any + if err := yaml.Unmarshal([]byte(source), &result); err != nil { + t.Fatalf("decode manifest: %v", err) + } + return result +} diff --git a/deploy/charts/dns-api/rbac_contract_test.go b/deploy/charts/dns-api/rbac_contract_test.go index f8f606b..9bad00d 100644 --- a/deploy/charts/dns-api/rbac_contract_test.go +++ b/deploy/charts/dns-api/rbac_contract_test.go @@ -1,11 +1,21 @@ package dnsapichart import ( + "bytes" + "fmt" + "os" "os/exec" "path/filepath" + "reflect" "runtime" + "slices" + "strconv" "strings" "testing" + + admissionv1 "k8s.io/api/admissionregistration/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" ) // The dns-api chart owns only its controller identity. Cloud's central @@ -50,6 +60,150 @@ func TestEndpointRecordSetRBACBindsOnlyControllerServiceAccount(t *testing.T) { } } +func TestProviderEnablementGatesRuntimePermissionsAndAdmission(t *testing.T) { + for _, route53 := range []bool{false, true} { + for _, cloudflare := range []bool{false, true} { + for _, conversion := range []bool{false, true} { + t.Run(fmt.Sprintf("route53=%t/cloudflare=%t/conversion=%t", route53, cloudflare, conversion), func(t *testing.T) { + rendered := renderChart(t, + "--set", "providers.route53.enabled="+strconv.FormatBool(route53), + "--set", "providers.cloudflare.enabled="+strconv.FormatBool(cloudflare), + "--set", "providers.route53.endpoint.conversionAPI.enabled="+strconv.FormatBool(conversion), + "--set", "gatewayEndpoint.enabled=false", + "--set", "serviceEndpoint.enabled=false", + ) + role := renderedDocument(t, rendered, "rbac.authorization.k8s.io/v1", "ClusterRole") + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + webhooks := renderedDocument(t, rendered, "admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration") + for flag, enabled := range map[string]bool{ + "route53-controller-enabled": route53, + "cloudflare-controller-enabled": cloudflare, + "route53-endpoint-conversion-enabled": route53 && conversion, + "external-controller-enabled": false, + } { + if !strings.Contains(deployment, "--"+flag+"="+strconv.FormatBool(enabled)) { + t.Fatalf("Deployment does not pass %s=%t:\n%s", flag, enabled, deployment) + } + } + for permission, enabled := range map[string]bool{ + "- route53.dns.appthrust.io\n": route53, + "- cloudflare.dns.appthrust.io\n": cloudflare, + "- secrets\n": cloudflare, + "- endpoint.route53.dns.appthrust.io\n": route53 && conversion, + "- endpoint.external.dns.appthrust.io\n": false, + "- gateway.networking.k8s.io\n": false, + "- services\n": false, + "- namespaces\n": true, + "- dns.appthrust.io\n": true, + "- endpoint.dns.appthrust.io\n": true, + } { + if strings.Contains(role, permission) != enabled { + t.Fatalf("permission %q presence does not equal %t:\n%s", permission, enabled, role) + } + } + for resource, enabled := range map[string]bool{ + "route53identities": route53, + "cloudflareidentities": cloudflare, + "zoneclasses": true, + } { + for _, suffix := range []string{"", "/status"} { + if strings.Contains(webhooks, "- "+resource+suffix+"\n") != enabled { + t.Fatalf("admission for %s%s does not equal %t:\n%s", resource, suffix, enabled, webhooks) + } + } + } + for declaration, enabled := range map[string]bool{ + "name: route53.dns.appthrust.io\n": route53, + "name: cloudflare.dns.appthrust.io\n": cloudflare, + "name: external.dns.appthrust.io\n": false, + "kind: EndpointProviderCapability\n": route53 && conversion, + "kind: APIService\n": route53 && conversion, + } { + if strings.Contains(rendered, declaration) != enabled { + t.Fatalf("declaration %q presence does not equal %t", declaration, enabled) + } + } + if strings.Count(rendered, "kind: ClusterRoleBinding\n") != 1 { + t.Fatal("expected exactly one controller ClusterRoleBinding") + } + }) + } + } + } +} + +func TestRoute53ConversionRequiresWebhookService(t *testing.T) { + rendered := renderChart(t, "--set", "webhook.enabled=false") + for _, forbidden := range []string{"kind: APIService\n", "kind: EndpointProviderCapability\n", "- endpoint.route53.dns.appthrust.io\n"} { + if strings.Contains(rendered, forbidden) { + t.Fatalf("webhook-disabled render contains %q", forbidden) + } + } + deployment := renderedDocument(t, rendered, "apps/v1", "Deployment") + if !strings.Contains(deployment, "--route53-endpoint-conversion-enabled=false") { + t.Fatal("webhook-disabled deployment must disable Route 53 conversion") + } +} + +func TestHelmAndKustomizeAdmissionCoverageAgree(t *testing.T) { + rendered := renderedDocument(t, renderChart(t), "admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration") + var helmConfig admissionv1.ValidatingWebhookConfiguration + if err := yaml.Unmarshal([]byte(rendered), &helmConfig); err != nil { + t.Fatalf("decode Helm admission: %v", err) + } + _, file, _, _ := runtime.Caller(0) + source, err := os.ReadFile(filepath.Join(filepath.Dir(file), "../../../app/operator/config/webhook/validating_webhook_configuration.yaml")) + if err != nil { + t.Fatalf("read Kustomize admission: %v", err) + } + var kustomizeConfig admissionv1.ValidatingWebhookConfiguration + if err := yaml.Unmarshal(source, &kustomizeConfig); err != nil { + t.Fatalf("decode Kustomize admission: %v", err) + } + if len(helmConfig.Webhooks) != len(kustomizeConfig.Webhooks) { + t.Fatalf("Helm has %d webhooks, Kustomize has %d", len(helmConfig.Webhooks), len(kustomizeConfig.Webhooks)) + } + for index, expected := range kustomizeConfig.Webhooks { + actual := helmConfig.Webhooks[index] + // Release namespace and service names differ; admission coverage must not. + actual.ClientConfig.Service.Namespace = expected.ClientConfig.Service.Namespace + actual.ClientConfig.Service.Name = expected.ClientConfig.Service.Name + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("admission differs for %s:\nHelm: %#v\nKustomize: %#v", expected.Name, actual, expected) + } + } +} + +func TestZoneClassCRDDistributionsAgreeOnOptionalIdentity(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + chartDir := filepath.Dir(file) + chartCRD, err := os.ReadFile(filepath.Join(chartDir, "crds/dns.appthrust.io_zoneclasses.yaml")) + if err != nil { + t.Fatalf("read chart CRD: %v", err) + } + operatorCRD, err := os.ReadFile(filepath.Join(chartDir, "../../../app/operator/config/crd/bases/dns.appthrust.io_zoneclasses.yaml")) + if err != nil { + t.Fatalf("read operator CRD: %v", err) + } + if !bytes.Equal(chartCRD, operatorCRD) { + t.Fatal("ZoneClass CRD distributions differ; run task controller-chart:sync-crds") + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(chartCRD, &crd); err != nil { + t.Fatalf("decode ZoneClass CRD: %v", err) + } + for _, version := range crd.Spec.Versions { + spec := version.Schema.OpenAPIV3Schema.Properties["spec"] + if slices.Contains(spec.Required, "identityRef") { + t.Fatalf("%s structurally requires identityRef", version.Name) + } + identity := spec.Properties["identityRef"] + if identity.Type != "object" || !slices.Contains(identity.Required, "name") { + t.Fatalf("%s does not require name when identityRef is present: %#v", version.Name, identity) + } + } +} + func ruleForAPIGroup(t *testing.T, role, apiGroup string) string { t.Helper() start := strings.Index(role, " - apiGroups:\n - "+apiGroup+"\n") diff --git a/deploy/charts/dns-api/templates/apiservice-external-endpoint-conversion.yaml b/deploy/charts/dns-api/templates/apiservice-external-endpoint-conversion.yaml new file mode 100644 index 0000000..d9e9786 --- /dev/null +++ b/deploy/charts/dns-api/templates/apiservice-external-endpoint-conversion.yaml @@ -0,0 +1,22 @@ +{{- if .Values.providers.external.enabled }} +{{- if not (and .Values.webhook.enabled .Values.certManager.enabled) }} +{{- fail "providers.external.enabled requires webhook.enabled=true and certManager.enabled=true" }} +{{- end }} +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.endpoint.external.dns.appthrust.io + labels: + {{- include "dns-api.labels" . | nindent 4 }} + annotations: + cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "dns-api.webhookCertificateName" . }} +spec: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: {{ include "dns-api.webhookServiceName" . }} + namespace: {{ .Release.Namespace }} + port: {{ .Values.webhook.service.port }} +{{- end }} diff --git a/deploy/charts/dns-api/templates/deployment.yaml b/deploy/charts/dns-api/templates/deployment.yaml index a2dea37..3eec920 100644 --- a/deploy/charts/dns-api/templates/deployment.yaml +++ b/deploy/charts/dns-api/templates/deployment.yaml @@ -41,6 +41,17 @@ spec: - --endpoint-recordset-namespace={{ .Values.endpoint.recordSetNamespace }} - --gateway-endpoint-controller-enabled={{ .Values.gatewayEndpoint.enabled }} - --service-endpoint-controller-enabled={{ .Values.serviceEndpoint.enabled }} + - --route53-controller-enabled={{ .Values.providers.route53.enabled }} + - --cloudflare-controller-enabled={{ .Values.providers.cloudflare.enabled }} + - --route53-endpoint-conversion-enabled={{ and .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled .Values.webhook.enabled }} + - --external-controller-enabled={{ .Values.providers.external.enabled }} + {{- if .Values.providers.external.enabled }} + - {{ printf "--external-controller-name=%s" .Values.providers.external.controllerName | quote }} + - {{ printf "--external-provider-name=%s" .Values.providers.external.providerName | quote }} + - {{ printf "--external-provider-version=%s" .Values.providers.external.providerVersion | quote }} + - --external-dns-qps={{ .Values.providers.external.dnsQPS }} + - --external-dns-burst={{ .Values.providers.external.dnsBurst }} + {{- end }} {{- range .Values.extraArgs }} - {{ . | quote }} {{- end }} diff --git a/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml b/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml index fa6460b..2d1538b 100644 --- a/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml +++ b/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled }} +{{- if and .Values.webhook.enabled .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled }} apiVersion: endpoint.dns.appthrust.io/v1alpha1 kind: EndpointProviderCapability metadata: diff --git a/deploy/charts/dns-api/templates/endpointprovidercapability-external.yaml b/deploy/charts/dns-api/templates/endpointprovidercapability-external.yaml new file mode 100644 index 0000000..1990ffb --- /dev/null +++ b/deploy/charts/dns-api/templates/endpointprovidercapability-external.yaml @@ -0,0 +1,14 @@ +{{- if .Values.providers.external.enabled }} +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: {{ printf "external-%s" .Values.providers.external.providerVersion | quote }} +spec: + provider: + name: {{ .Values.providers.external.providerName | quote }} + version: {{ .Values.providers.external.providerVersion | quote }} + conversion: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions +{{- end }} diff --git a/deploy/charts/dns-api/templates/provider-external.yaml b/deploy/charts/dns-api/templates/provider-external.yaml new file mode 100644 index 0000000..5ddfea7 --- /dev/null +++ b/deploy/charts/dns-api/templates/provider-external.yaml @@ -0,0 +1,164 @@ +{{- if .Values.providers.external.enabled }} +apiVersion: dns.appthrust.io/v1alpha1 +kind: Provider +metadata: + name: {{ .Values.providers.external.providerName | quote }} +spec: + display: + name: External DNS + description: Verifies customer-managed DNS without changing DNS records. + versions: + - name: {{ .Values.providers.external.providerVersion | quote }} + served: true + storage: true + zoneClass: + schemas: + parameters: + openAPIV3Schema: + type: object + description: DNS resolver policy for verification without DNS writes. + additionalProperties: false + properties: + resolver: + type: object + description: Resolver view used for every verification query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + description: One recursive resolver endpoint as IPv4:port or [IPv6]:port. + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + expectedNameservers: + type: array + description: Expected authoritative nameservers for the exact Zone domain. + maxItems: 16 + uniqueItems: true + items: + type: string + description: One expected authoritative nameserver hostname, lowercase, without a trailing dot. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + validationRules: + - rule: >- + has(self.spec.parameters.resolver) && + has(self.spec.parameters.resolver.mode) && + self.spec.parameters.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.resolver.servers) && size(self.spec.parameters.resolver.servers) > 0) + : (!has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || size(self.spec.parameters.resolver.servers) == 0) + message: resolver.servers must be nonempty for Recursive and empty for Cluster + - rule: >- + !has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || + self.spec.parameters.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: resolver.servers must contain canonical IP addresses and ports from 1 through 65535 + - rule: >- + !has(self.spec.parameters.expectedNameservers) || + self.spec.parameters.expectedNameservers.all(n, !isIP(n)) + message: expectedNameservers must contain DNS names, not IP addresses + zone: + schemas: + adoption: &empty + openAPIV3Schema: + type: object + additionalProperties: false + properties: {} + statusProviderData: &status + openAPIV3Schema: + type: object + description: Bounded read-only DNS verification evidence; not DNS withdrawal proof. + additionalProperties: false + properties: + resolverMode: + type: string + description: Selected recursive resolver view policy. + enum: [Cluster, Recursive] + checkedName: + type: string + description: Absolute DNS owner checked, without the root dot. + maxLength: 253 + sampledWildcard: + type: boolean + description: Whether checkedName is a stable concrete wildcard witness. + actual: &preview + type: object + description: Sorted bounded diagnostic preview; equality uses the complete observation. + additionalProperties: false + required: [total, truncated] + properties: + values: + type: array + description: Up to sixteen typed values or diagnostic entries. + maxItems: 16 + items: + type: string + description: One bounded value or diagnostic entry. + maxLength: 512 + total: + type: integer + description: Total entries before truncation. + minimum: 0 + truncated: + type: boolean + description: Whether values omits entries from the complete observation. + missing: *preview + extra: *preview + cnameChain: *preview + views: *preview + observedTTLSeconds: + type: integer + description: Minimum relevant positive TTL in seconds, when observed. + minimum: 0 + negativeTTLSeconds: + type: integer + description: Minimum of negative SOA TTL and SOA MINIMUM, when observed. + minimum: 0 + negativeExpiryTime: + type: string + format: date-time + description: Negative cache expiry suggested by the observed SOA, not the polling cap. + lastProbeTime: + type: string + format: date-time + description: Last completed attempt, including failures. + nextProbeTime: + type: string + format: date-time + description: Approximate next planned attempt; queue load can delay it. + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: VerificationConfigured, description: Verifier configured without authority checks.} + - {conditionType: Programmed, status: 'True', reason: NameserversVerified, description: Every required view verified the exact apex and nameservers.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Observed apex or nameservers differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordSet: + supportedTypes: [A, AAAA, CNAME] + schemas: + options: *empty + adoption: *empty + statusProviderData: *status + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: ResolutionVerified, description: Exact targets verified in every required view.} + - {conditionType: Programmed, status: 'False', reason: DNSNameNotFound, description: Required view returned NXDOMAIN.} + - {conditionType: Programmed, status: 'False', reason: DNSNoData, description: Required record type has no answers.} + - {conditionType: Programmed, status: 'False', reason: ResolutionPartial, description: A nonempty proper subset of targets was observed.} + - {conditionType: Programmed, status: 'False', reason: ResolutionMismatch, description: Targets or CNAME chain differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} +{{- end }} diff --git a/deploy/charts/dns-api/templates/rbac.yaml b/deploy/charts/dns-api/templates/rbac.yaml index 349c6aa..0346733 100644 --- a/deploy/charts/dns-api/templates/rbac.yaml +++ b/deploy/charts/dns-api/templates/rbac.yaml @@ -17,11 +17,20 @@ rules: - "" resources: - namespaces + verbs: + - get + - list + - watch + {{- if .Values.providers.cloudflare.enabled }} + - apiGroups: + - "" + resources: - secrets verbs: - get - list - watch + {{- end }} {{- if .Values.serviceEndpoint.enabled }} - apiGroups: - "" @@ -39,6 +48,7 @@ rules: verbs: - update {{- end }} + {{- if .Values.providers.cloudflare.enabled }} - apiGroups: - cloudflare.dns.appthrust.io resources: @@ -57,6 +67,7 @@ rules: - get - patch - update + {{- end }} - apiGroups: - coordination.k8s.io resources: @@ -153,12 +164,22 @@ rules: - get - patch - update + {{- if and .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled .Values.webhook.enabled }} - apiGroups: - endpoint.route53.dns.appthrust.io resources: - endpointrecordsetconversions verbs: - create + {{- end }} + {{- if .Values.providers.external.enabled }} + - apiGroups: + - endpoint.external.dns.appthrust.io + resources: + - endpointrecordsetconversions + verbs: + - create + {{- end }} {{- if .Values.gatewayEndpoint.enabled }} - apiGroups: - gateway.networking.k8s.io @@ -184,6 +205,7 @@ rules: verbs: - update {{- end }} + {{- if .Values.providers.route53.enabled }} - apiGroups: - route53.dns.appthrust.io resources: @@ -202,6 +224,7 @@ rules: - get - patch - update + {{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml b/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml index 3c80b38..c809831 100644 --- a/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml +++ b/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml @@ -50,6 +50,53 @@ webhooks: - UPDATE resources: - zoneclasses + - zoneclasses/status + {{- if .Values.providers.route53.enabled }} + - name: route53identities.route53.dns.appthrust.io + admissionReviewVersions: + - v1 + sideEffects: None + failurePolicy: {{ .Values.webhook.failurePolicy }} + clientConfig: + service: + namespace: {{ .Release.Namespace }} + name: {{ include "dns-api.webhookServiceName" . }} + path: /validate-route53-dns-appthrust-io-v1alpha1-route53identity + rules: + - apiGroups: + - route53.dns.appthrust.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - route53identities + - route53identities/status + {{- end }} + {{- if .Values.providers.cloudflare.enabled }} + - name: cloudflareidentities.cloudflare.dns.appthrust.io + admissionReviewVersions: + - v1 + sideEffects: None + failurePolicy: {{ .Values.webhook.failurePolicy }} + clientConfig: + service: + namespace: {{ .Release.Namespace }} + name: {{ include "dns-api.webhookServiceName" . }} + path: /validate-cloudflare-dns-appthrust-io-v1alpha1-cloudflareidentity + rules: + - apiGroups: + - cloudflare.dns.appthrust.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - cloudflareidentities + - cloudflareidentities/status + {{- end }} - name: zones.dns.appthrust.io admissionReviewVersions: - v1 diff --git a/deploy/charts/dns-api/values.yaml b/deploy/charts/dns-api/values.yaml index 481736e..38a43f5 100644 --- a/deploy/charts/dns-api/values.yaml +++ b/deploy/charts/dns-api/values.yaml @@ -62,6 +62,13 @@ providers: enabled: true cloudflare: enabled: true + external: + enabled: false + controllerName: external.dns.appthrust.io/controller + providerName: external.dns.appthrust.io + providerVersion: v1alpha1 + dnsQPS: 40 + dnsBurst: 80 extraArgs: [] extraEnv: [] diff --git a/deps/README.md b/deps/README.md new file mode 100644 index 0000000..44b55fe --- /dev/null +++ b/deps/README.md @@ -0,0 +1,9 @@ +# DNS Enhancement Proposals + +DEPs record API proposals, decisions, alternatives, and compatibility analysis. The [DEP process](../docs/design/common.md#dep-process) defines the layout, required sections, and statuses. [Current design](../docs/design/README.md) remains the specification; accepted DEPs are reflected there before implementation follows them. + +Use [the template](./dep-template.md) for a new proposal. Single Markdown files named `dep--.md` are the default. + +| DEP | Title | Status | +| --- | --- | --- | +| [0003](./dep-0003-record-withdrawal.md) | Verified record withdrawal before address reuse | Draft | diff --git a/deps/dep-0003-record-withdrawal.md b/deps/dep-0003-record-withdrawal.md new file mode 100644 index 0000000..e96fa08 --- /dev/null +++ b/deps/dep-0003-record-withdrawal.md @@ -0,0 +1,257 @@ +# DEP-0003: Verified record withdrawal before address reuse + +- Status: Draft +- Owner: dns-api maintainers (Core, Endpoint App, and external provider) +- Created: 2026-10-18 + +## Summary + +Introduce independent Core `RecordWithdrawal` and `RecordWithdrawalWaiver` claims, Core composition/projection through `ZoneUnit`, and external-provider exclusion verification. Endpoint sources preserve retired hostname/address pairs until the Endpoint App durably creates claims. Platform consumers use verified results before member retirement and address reuse; exceptional operator risk acceptance remains distinct from DNS verification. + +This is the dns-api part of the reviewed **Phase 4b: verified external-DNS withdrawal before VIP reuse** design, especially §§3.2–3.4, 4.1–4.3, 6–7, 8.2, and 9. It is a follow-up after PR #16, not additional stage-1 implementation scope. It is proposed behavior, not an implementation or runtime-verification report. Under the [DEP process](../docs/design/common.md#dep-process), committed `docs/design/` remains current specification until acceptance and reflection there. + +## Motivation + +The external provider observes customer-managed DNS; it neither owns nor deletes those DNS records. Ordinary generated-child deletion can finish while the customer's wildcard or exact record still resolves to a retired address. Reusing that address can route traffic for the former owner to a new allocation. + +The stage-1 baseline is [External Provider §Deletion](../docs/design/external-provider.md#deletion): matching-UID `DeletionRequested`/`DeletionCompleted` is Kubernetes bookkeeping, not proof of DNS withdrawal. Its deferred `Withdrawn` sentence explicitly leaves schema, persistence, consumers, and cache safety outside stage 1. On acceptance, this DEP supersedes that deferral, **not** ordinary RecordSet deletion semantics. + +A result tied only to an ERS or generated RecordSet disappears when that object is deleted. Provider-private scheduling state is reconstructible and pruned, not a durable public obligation inventory. Independent Core claims preserve the obligation across source deletion and keep App → Core → Provider ownership intact. + +### Source anchors + +Anchors below are from proposal source snapshot `00f98491d24f57fa5af66c805ccbb5c4fabd5aef`; line numbers describe that revision, not future edits: + +| Existing contract | Source anchor | +| --- | --- | +| Core composes and projects; provider reconciles Units | `docs/design/controllers-webhooks.md:5-28`; `docs/design/overview.md:13-18` | +| Unit ledger identities and deletion bookkeeping | `pkg/go/api/dns/v1alpha1/zoneunit_types.go:60-134,184-218`; `internal/go/core/controllers/zoneunit/zoneunit_controller.go:459-465,515-531` | +| External deletion and pruned scheduling state | `internal/go/providers/external/controllers/zoneunit/controller.go:179-187,217-223`; `model.go:16-27,102-113`; `docs/design/external-provider.md:264-268` | +| Source update/delete paths, including route cleanup | `internal/go/apps/gatewayendpoint/controllers/gateway/controller.go:219-245,345-402,406-420` | +| Endpoint finalizer installation and deletion | `internal/go/apps/endpoint/controllers/endpointrecordset/controller.go:63-84,536-559` | +| Nonempty A/AAAA validation | `pkg/go/api/dns/v1alpha1/recordset_types.go:87-97`; `internal/go/core/webhook/validator.go:450-481` | +| Exact-set evaluator and minimum-TTL summaries | `internal/go/providers/external/probe/evaluate.go:117-230`; `types.go:45-56`; `wire.go:267-270,297-305` | +| Zone child-claim deletion boundary | `internal/go/core/controllers/zoneunit/zoneunit_controller.go:217-255` | +| DEP process and reflection rules | `docs/design/common.md:7-21,41-49` | + +## Goals + +- Verify exclusion of one retired address while permitting healthy replacement addresses. +- Preserve obligations through concurrent source edits, direct deletion, and controller restarts. +- Keep authorization/composition/public status in Core and DNS verification in the provider. +- Expose identity-bound, cache-aware, reverified results and an independently authorized waiver. +- Preserve external stage-1 programming/deletion and non-external behavior. + +## Non-Goals + +- DNS mutation, customer credentials, a second address allocator, or automatic release after a timeout/attempt count. +- Empty public A/AAAA RecordSets, publication epochs, platform DNS-history inventories, or an ERS admission webhook. +- Enumeration of deleted-route history or proof that every historical customer hostname/cache is clear. +- IPv6 platform allocation: dns-api accepts IPv4 and IPv6 withdrawals, but the current platform member allocator remains IPv4-only. + +The supported proof covers the Project wildcard plus currently retained ERS hostnames, retirement entries, and their independent claims. Wildcards use stable concrete sampled witnesses, not exhaustive descendant enumeration. An explicit owner no longer represented by retained state is outside this proof. [INFERENCE] Configured resolvers/authorities are trusted and honor TTLs; arbitrary application caches, unconfigured views, and future customer edits are outside the guarantee. + +## API + +### RecordWithdrawal + +Namespaced GVK: `dns.appthrust.io/v1alpha1`, kind `RecordWithdrawal`. + +| Surface | Proposed contract | +| --- | --- | +| `spec.zoneRef` | Existing Core Zone reference shape; immutable. | +| `spec.name` | Canonical zone-relative owner: `@`, exact name, or leftmost wildcard; immutable. | +| `spec.address` | One canonical IPv4 or IPv6 address to exclude; immutable; no hostname target. | +| `metadata.name` | Deterministic creator key. Endpoint identity includes ERS UID, retirement generation, owner, and address; platform identity includes reservation UID, wildcard owner, and address. Collision handling checks complete immutable shape and source provenance. | +| `metadata.labels[endpoint.dns.appthrust.io/address]` | Required from both creators. IPv4 uses its canonical string; IPv6 uses lowercase unpadded base32 of the 16 normalized bytes. Validation checks label/spec consistency. | +| Source annotations | Creator/source namespace/name/UID/generation for diagnosis and collision checks, never authorization. Core does not interpret platform/Gateway kinds. | +| Ownership/lifecycle | No ownerReferences and no request-level finalizer. Operator-controlled DNS namespace; no age-based garbage collection. | + +Public status includes: + +- `observedGeneration`, resolved `zone` reference/UID and Unit identity; +- `policyFingerprint`, `requiredViews`, `checkedNames`, and `sampledWildcard`; +- `absentSince`, `notBefore`, `lastProbeTime`, `nextProbeTime`, and `verifiedAt`; +- `resultDigest`, covering claim UID/generation/spec, Unit/binding identity, policy, checked names/views, and observation window/result; +- bounded `views[]`/provider evidence and diagnostics, without using a truncated preview for decisions; +- `waiverRef` with exact accepted waiver name/UID/digest and reservation UID, when present; +- `Accepted`, `Withdrawn`, `Ready`, and optional `OverrideAccepted` conditions, each with observedGeneration. + +Creators supply neither resolver policy nor `spec.verifier`. Address-label selection is only discovery: consumers recheck canonical spec address, UID, expected source/wildcard identity, and result bindings. There is no reservation-UID label requirement, consumerRef propagation, Gateway metadata patch, or platform-kind lookup inside dns-api. Platform uses bounded live APIReader label queries; truncation/continuation or missing expected results holds release, never proves an empty inventory. + +### ZoneUnit and Provider contract + +`ZoneUnit.spec.withdrawals[]` is a map-list bounded to 256 items and keyed by the scalar top-level fields `claimNamespace` and `claimName`. Each item also contains a nested `claim{namespace,name,uid}` object; CEL keeps its namespace/name consistent with the top-level keys, and its UID is nonempty. The item carries observedGeneration, relative name, address, Core authorization/Allowed state, and any Core-validated waiver reference and immutable scope. Core applies the same Zone namespace and record-pattern grants as for corresponding A/AAAA RecordSets; wildcard grants remain explicit. + +`ZoneUnit.status.withdrawals[]` uses the same scalar map-list keys and 256-item bound, mirrors the nested claim identity with CEL-enforced namespace/name consistency, and carries verifier conditions, effective policy fingerprint, checked names/views, cache window, resultDigest, and waiver binding. Core projects only exact UID/generation matches using `claimNamespace`, `claimName`, and `claim.uid` for identity, and preserves provider messages. Same-name recreation cannot inherit an old result; missing, stale, or disallowed items are not ready. + +Provider versions expose a `recordWithdrawal` capability containing `supported`, public status-provider-data schema, and `additionalConditionReasons`. External advertises support; Route53/Cloudflare do not advertise or reconcile it. Unsupported binding is a Core condition. The provider reconciles only matching ZoneUnits/ZoneClasses: it does not read App objects, withdrawal claims, or waivers, or duplicate grant authorization. Core is the sole public withdrawal/waiver status writer. + +Pending withdrawals do not make unrelated active RecordSet/Zone `Programmed` false. Zone deletion has a separate dependency gate. + +### RecordWithdrawalWaiver + +Namespaced GVK: `dns.appthrust.io/v1alpha1`, kind `RecordWithdrawalWaiver`, in its target's operator-controlled namespace. It is an immutable separate resource, not a privileged field on the withdrawal claim, and has no ownerReferences or request-level finalizer. + +Its spec contains exact withdrawal namespace/name/UID and immutable claim-spec digest; canonical address; exact receiving reservation namespace/name/UID; reason; ticket/change reference; and `acceptStaleDNSRisk: true`. + +Kubernetes **create RBAC is the authority**. The spec contains no client-supplied authorizedBy, username, group, authorization time, or claimed audit identity. Kubernetes API audit supplies authenticated identity/time/provenance; persisted UID/resourceVersion identifies the authorization. + +Core checks reference/UID/digest/address bindings and composes a validated waiver into the corresponding Unit item. The provider binds the exact waiver UID and emits `OverrideAccepted=True/OperatorAuthorizedRelease`; Core projects it. No provider claim reads or SubjectAccessReview are introduced. A waiver **never sets `Withdrawn=True`**; an old DNS answer remains visible as `Withdrawn=False/DNSStillResolves`. + +Every unresolved obligation needs its exact waiver. Another reservation incarnation or recreated/changed claim cannot consume it. Waivers bypass DNS absence/cache proof only, not active/uncaptured ERS targets, allocation ownership, workload/storage drain, or host cleanup. Deleting a waiver does not undo an already consumed release. Claims/waivers remain retained after reservation deletion. + +### Conditions and messages + +| Condition/status | Reason | Meaning | +| --- | --- | --- | +| `Accepted=True` | `Accepted` | Binding, grants, and capability accepted. | +| `Accepted=False` | `InvalidWithdrawal`, `NotAllowed`, `UnsupportedProvider` | Invalid shape/binding, denied grant, or unavailable capability; no release. | +| `Withdrawn=False`, `Ready=False` | `DNSStillResolves` | A required checked name/view/family still returns the retired address. | +| `Withdrawn=False`, `Ready=False` | `NegativeTTLNotElapsed` | Exclusion observed; cache horizon or separate confirming attempt remains. | +| `Withdrawn=Unknown`, `Ready=False` | `DNSProbeFailed` | Missing, failed, incomplete observation or required negative SOA evidence. | +| `Withdrawn=Unknown`, `Ready=False` | `PolicyChanged` | Binding/view-policy change cannot be equivalent or weaker success. | +| `Withdrawn=True`, `Ready=True` | `WithdrawalVerified` | Complete current all-view exclusion after horizon and confirming probe. | +| `OverrideAccepted=True` | `OperatorAuthorizedRelease` | Separately authorized exact waiver, not successful DNS observation. | + +Stable message templates: + +- `DNS still resolves to retired address
in ().` +- `Retired address
is absent in every required view; waiting until for cache safety and a confirming probe.` +- `Retired address
was verified absent from in every required view at .` +- `External DNS withdrawal cannot be verified for in : .` + +Use the existing 2048-byte message limit and bounded sorted diagnostics; include no packets, credentials, or unbounded address lists. Use **result**/`resultDigest`; existing ERS admission **receipt** semantics remain unchanged. The provider describes DNS, not whether ERS desires the address, and does not emit an ERS-derived `EndpointStillPublished` condition. + +## Behavior + +### Source capture and endpoint lifecycle + +The Gateway source remains provider-neutral: no Zone/provider selection, resolver inspection, or Core claim creation. It preserves removed pairs in two correlated ERS collections: + +- `spec.retiredTargets[]`: entry ID, retirement source generation, removed IP, and its old hostname snapshot. +- `spec.retiredHostnames[]`: entry ID, retirement source generation, removed hostname, and its old IP snapshot. + +Each capture key includes ERS UID/generation. Associations are snapshots, not independent sets later multiplied together: simultaneous removal of H1 and change V1→V2 retains `(H1,V1)`, never invented `(H1,V2)`. Duplicate pairs across collections share deterministic claim identity; a later re-add/remove generation has a new retirement identity. + +Both collections use `listType=map` keyed by entry ID. Limits are 64 target-retirement entries and 256 hostname-retirement entries, with snapshots capped at 256 hostnames and 64 IPs respectively. Overflow reports `WithdrawalCapturePending` and leaves old intent intact; it never truncates or drops obligations. + +1. The source reads current ERS, computes removed pairs, and atomically changes desired intent while appending snapshots, preserving existing retirement entries with resourceVersion optimistic concurrency. +2. Source writes never prune. The endpoint controller resolves the Zone/provider for each retired pair and creates external Core claims by deterministic create-or-get, checking complete immutable shape/provenance on collision. +3. Only after every associated claim is durably created/read back does endpoint prune exact captured entry IDs using an optimistic-lock patch. Current-generation status confirms captured entries. A create failure leaves retirement intent; a crash between create and prune is harmless. Source retries merge current state, never restore an old whole spec over concurrent entries. +4. Non-external pairs finish existing child cleanup and prune without an external DNS wait. Generated RecordSets/ERSs can finish ordinary deletion once independent claims exist; they do not wait for customer DNS changes. + +Missing/deleting Gateway, rejected admission, and every cleanup path, **including `cleanupForRoute`**, move remaining pairs to retired state rather than immediately delete the ERS. Source deletion waits for current-generation capture confirmation. + +The source installs endpoint-owned `generatedRecordSetsFinalizer` **at ERS create**, not first endpoint reconcile. Direct ERS DELETE runs `reconcileDeletion`, captures every live and retired pair, then removes that finalizer after normal child cleanup. Other enabled sources follow the same create/retire contract or cannot publish into the protected external member-address path. Controller outage cannot lose retained intent. + +A retire-only ERS may have no active hostname/target but nonempty retirement entries; CEL permits that transitional state. Endpoint reports `Ready=False/Retiring` and creates no empty RecordSet or conversion request. Active ERSs retain nonempty-intent validation. No ERS validating webhook, protection-label authorization, or new failurePolicy dependency is added; trusted source RBAC and monotone writes enforce capture. Manual stripping of finalizers, deletion of retained evidence, or unauthorized overwriting of protected state is outside the supported contract. + +### Exclusion verification and cache safety + +`EvaluateWithdrawal` is separate from existing exact-set `Evaluate`: + +```text +excluded(address) := address ∉ normalize(terminal A ∪ terminal AAAA) +``` + +For every required checked name and view, probe **both families**, even for IPv4 retirement. One old answer blocks; missing/failed/incomplete required evidence is Unknown. Replacement addresses may remain and need not be identical across views. No partial-observation union, subset, or resolver failover constitutes success. + +- Complete NOERROR with other addresses is exclusion evidence. +- Complete NODATA and NXDOMAIN are exclusion evidence only with required valid negative SOA evidence. Active `Evaluate` still returns `DNSNameNotFound` for NXDOMAIN. +- Follow existing CNAME eight-link/loop and foreign-chain boundaries and inspect both terminal families. Loops, limits, mixed invalid data, or unresolved chains are Unknown. +- Required views include every configured Recursive server or effective Cluster resolver and every required discovered authority endpoint when expectedNameservers is configured. Existing NS/SOA/AA/delegation checks remain. + +Retain complete per-view/per-qtype expiry evidence; existing minimum-TTL summaries alone are insufficient: + +1. First complete all-view exclusion starts `absentSince=t0`. +2. For each negative response, negative TTL is `min(SOA TTL, SOA.MINIMUM)`; missing required SOA is not invented zero. +3. `notBefore` is the maximum of t0, every `t0 + negativeTTL`, and retained known positive-answer/CNAME expiry horizons. Platform drain delay remains additional. +4. Identical negative answers do not slide t0 indefinitely. A changed bound may extend but never shorten the window. Reappearance, failed/incomplete observation, policy change, or stale scheduling invalidates/restarts success; old positive-expiry evidence remains conservative. +5. A **new complete successful probe at/after notBefore** is mandatory, even with zero negative TTL. Timer firing or restart without fresh evidence cannot set `Withdrawn=True`. +6. Restart preserves a window only with matching claim/binding identity, policy/view set, and fresh schedule; otherwise restart conservatively. A 3600-second TTL is not shortened to the polling cap. A replacement with no negative responses invents no negative TTL but still honors old-positive horizons and a separate confirming probe. + +Retained claims are reverified. Later failure drops readiness. Consumers reject future/missing timestamps and stale success at `now > nextProbeTime + 300s`, and require matching UID/generation/binding/policy/digest and `verifiedAt >= notBefore`. + +A live Class/policy change invalidates success/window. The prior required view set remains a floor: a weaker policy reports `PolicyChanged` until restored or explicitly waived. Missing historical policy evidence is Unknown, not proof of equivalence. Creators do not supply copied policy snapshots. + +For wildcard claims the verifier derives a stable concrete witness from claim UID and bound Zone identity, reports `sampledWildcard=true`, and reuses it across probes/restarts. Recreated claims get distinct witnesses. A literal `*` query alone is insufficient; retained exact hostnames get their own claims. Closer enclosers/explicit owners can override a witness, so the result remains sampled. + +Reuse [Scheduling and Resource Bounds](../docs/design/external-provider.md#scheduling-and-resource-bounds): shared limiter, bounded concurrency/deadlines, fair work, jitter, timers, stale-input checks, and QUERY-only transport. There is no maximum hold duration or release-after-N-attempts. Customer DNS continuing to answer the old IP holds indefinitely with `DNSStillResolves` and visible probe times. Finite sampling does not promise global cache eradication. + +### Core retention and Zone deletion + +Core retains outstanding withdrawals as dependent Zone claims. Zone/ZoneUnit deletion stays blocked while retained claims reference it, with a visible blocking dependency; the external provider continues verification instead of treating Unit deletion as successful withdrawal. Completed claims remain retained/reverified and can also pin deletion until an operator safely archives/removes them after no allocated address/reservation can consume them. There is no age-based GC. Missing claims, CRDs, bindings, or force-deleted Units never imply success. + +### Admission and RBAC + +Admission validates local shape, immutable spec/references, canonical name/address, address-label consistency, bounded schemas, condition/status shape, and existing namespace-policy declarations. Cross-object binding/grants/provider capability are Core reconciliation conditions. Admission performs no DNS/network query or field-level SubjectAccessReview. Ordinary nonempty A/AAAA and CNAME/ownership rules remain unchanged. + +| Actor | Permission boundary | +| --- | --- | +| Platform CapacityReservation | Create/get/list/watch withdrawals in configured DNS namespace; read/list/watch waiver/results and configured Zone binding; existing ERS reads. No public claim status writes, waiver create, or general RecordSet mutation. | +| Platform CRS | Read/list/watch claims/waivers and reservation status; no claim create or public status writes. | +| Gateway source | Existing ERS create/update/patch/delete; install finalizer and preserve retired entries. No withdrawal, ZoneClass, or resolver permissions. | +| Endpoint controller | Create/get/list/watch withdrawals, existing Zone resolution and ERS spec/status/finalizer permissions. No waiver create or withdrawal status writes. | +| Core | Read/watch both claims, compose Unit spec, write public claim statuses, retain Zone dependencies. | +| External provider | Matching Unit/Class reconciliation and Unit status only; no App/withdrawal/waiver reads or public claim status writes. | +| Operator release role | Namespace-scoped waiver create/get/list/watch; API audit retention records privileged authorization. | +| Tenant/Project administrator | No mutation of retained operator-namespace claims, statuses, or safety metadata. | +| Operator archive role | Explicit safe deletion of completed, nonconsumable evidence; never an automatic release mechanism. | + +Chart/Kustomize permissions and registrations agree. New Core CRDs, Unit/ERS schemas, generated bindings, admission/controller registrations, and provider capability ship together in both `app/operator/config/crd/bases/` and `deploy/charts/dns-api/crds/`. No customer Secret/cloud identity privilege is added. + +## Compatibility + +Stage-1 external RecordSet `DeletionRequested`/`DeletionCompleted` and ordinary finalizer bookkeeping stay unchanged and separate. The new withdrawal items survive source deletion and continue probing; their Zone deletion dependency is intentionally new. Existing active exact-set evaluation and admission receipt terminology remain unchanged. `none`/Route53 do not gain external DNS waiting; writing providers do not advertise the capability. + +Two claim creators are intentional: platform CapacityReservation creates the mandatory Project wildcard per retiring external allocation, even with zero routes/ERSs; endpoint creates retained ERS-pair claims. Both use the same Core contract, neither writes status. Platform gates both CRS retirement and terminal-reservation address clearing on current results; a waiver has a separate release reason. External has no automatic `NeverPublished` release because customer wildcard DNS can exist before any route. + +The dns-api Core follow-up release, reviewed platform import, and CRD delivery precede platform Phase 4b support. Missing CRD/watch/verifier, denied/truncated reads, missing/stale results, or a mode switch cannot erase held external obligations. Pre-4b retirements that already lost history are not reconstructed by epochs or synthetic success. + +## Documentation + +On acceptance, reflect this contract in present tense into: + +- `docs/design/external-provider.md` §Deletion, conditions/public data, scheduling, and admission/security, replacing the deferred `Withdrawn` sentence while preserving stage-1 deletion bookkeeping; +- `docs/design/core-api.md`: both claims, Unit lists, immutable fields, status, and Zone deletion dependency; +- `docs/design/controllers-webhooks.md`: Core admission/authorization/composition/projection and ownership; +- `docs/design/provider-extension.md`: capability, schema, and condition reasons; +- `docs/design/endpoint-api.md`: correlated bounded retirement state, retire-only validation, capture acknowledgment, and finalizer lifecycle; +- `docs/design/gateway-api-integration.md`: monotone writes, finalizer at create, and all cleanup paths. + +The document writer subsequently updates user-facing manuals from those committed design snapshots: proof limitations, indefinite holds, safe archival, exact waiver procedure, and audit retention. Draft publication does not modify current design, root user README, or manuals. + +## Alternatives + +- **Provider-private retained ZoneUnit ledger:** feasible with public result projection and Zone retention, but changes reconstructible scheduling state into primary durable claim inventory. It still needs monotone source capture and exclusion verification. Independent Core claims make identity, authorization, and lifecycle explicit without hiding safety inventory in provider JSON. +- **Platform-owned history and empty RecordSet:** rejected. A/AAAA and ERS/conversion fragments are nonempty today; relaxing admission changes the ordinary model. Exact empty-set equality rejects a healthy replacement IP, whereas exclusion permits it; active NXDOMAIN evaluation is also not withdrawal. It duplicates DNS binding/grant/lifecycle logic in platform and asynchronous lastPublished watches can miss overwritten pairs. +- **ERS admission webhook:** rejected. It introduces a new all-write availability/failurePolicy and authorization dependency. Trusted monotone source writers, create-time endpoint finalization, and create-before-prune retain intent without a second admission convention. +- **Publication epochs/history-recovery classes:** rejected. They do not repair already-lost history and add another protocol beyond allocation provenance and UID/generation-fenced claims. Mandatory zero-route wildcard claims and currently retained correlated ERS pairs define the supported proof; no claimed historical completeness is manufactured. + +## Tests + +These are implementation requirements, not executed results of this documentation change. + +### Unit and controller coverage + +- Separate exact-set/exclusion behavior; replacements allowed; NXDOMAIN differs for publication; NODATA/missing SOA, zero/nonzero TTL, both families, and IPv4-mapped normalization. +- Every recursive/authority view, opposite-family old answer, complete CNAME terminal lookup, loop/eight-link limits, partial fan-out, and failed required view. +- Maximum negative/known-positive horizons, 3600-second TTL not capped, repeated negatives not sliding forever, mandatory separate confirming probe, restart/clock reversal/stale scheduling/late-result invalidation, policy weakening, UID recreation, and old-IP reappearance. +- Stable sampled wildcard witness; explicit retained hostname blocks after wildcard removal; zero routes still has platform wildcard obligation; proof limitation remains visible. +- Correlated simultaneous hostname/target changes, duplicate pairs, re-add/remove generations, append/prune conflict, create-before-prune crash/failure, overflow hold, and endpoint outage. +- Every source cleanup including `cleanupForRoute`, create-time finalizer, and direct DELETE capture of live and retired pairs. +- Core grant denial/wildcard grant, unsupported capability, exact claim/Unit UID-generation projection, message preservation, provider ownership boundaries, and pending withdrawal not breaking active programming. +- Zone deletion remains blocked and probes continue; ordinary RecordSet deletion bookkeeping stays unchanged. +- Canonical IPv4/IPv6 label encoding and exact-spec validation; label-only match never grants success. +- Waiver role allowed; tenant/endpoint/platform denied; exact immutable claim/reservation UID/digest/address binding; DNS False persists under accepted override; audit identity is never client-controlled. + +### Cross-controller and controlled acceptance + +Use regenerated real CRDs, admission, Provider declaration, endpoint/Core/external controllers, role-based denials, and controlled DNS rather than injecting a projected success shortcut. Pause/interleave move-to-retired, create/readback, status capture, and prune: live readers see retained intent or persisted claims, never neither. Cover missing prerequisites, bounded/truncated lists, stale True, policy changes, retained-source disappearance, and exact waiver consumption. + +The Phase 4b controlled fixture has two eligible sshd-container hosts, one workload VIP, and Projects A/B. A's wildcard and retained exact names keep answering the VIP after source retirement; projected `DNSStillResolves` blocks CRS and allocation beyond the old local drain timer. B passes host selection but fails specifically with `LoadBalancerRangeExhausted`, not machine exhaustion. A verifier restart preserves the hold. + +Customer DNS removal leaves one required view answering the VIP first, then all views supply valid exclusion/SOA evidence. Assert no release before the maximum cache horizon, followed by a new confirming all-view probe, Core `WithdrawalVerified`, the additional platform drain delay, ordinary member cleanup, and automatic terminal-reservation release. Recreate B's failed deployment through the normal path, then prove actual VIP reuse and serving without overlapping allocations. Capture wildcard-owner and independent non-witness evidence as well as the stable sampled witness and retained exact names. Compare unrelated management DNS/allocations and clean disposable resources through ordinary lifecycles without deleting unresolved evidence. + +Additional cases cover zero-route wildcard publication, finished reservation/member already gone (hosts clean but VIP held), replacement address still serving, both-family/partial-address behavior, and a separate exceptional waiver run with DNS still False. Waiver acceptance is never substituted for the normal verified-withdrawal proof. diff --git a/deps/dep-template.md b/deps/dep-template.md new file mode 100644 index 0000000..047c7bd --- /dev/null +++ b/deps/dep-template.md @@ -0,0 +1,47 @@ +# DEP-NNNN: Title + +- Status: Draft +- Owner: Proposal owner +- Created: YYYY-MM-DD + +Follow the [DEP process](../docs/design/common.md#dep-process). Replace the prompts below with the proposal; include source anchors for existing contracts. + +## Summary + +Describe the change and its decision boundary. + +## Motivation + +Explain the problem and existing behavior. + +## Goals + +List the intended outcomes. + +## Non-Goals + +State exclusions and proof limits. + +## API + +Define resources, fields, validation, conditions, and status ownership. + +## Behavior + +Describe reconciliation, failure handling, lifecycle, and permissions. + +## Compatibility + +Explain preserved behavior, migration, and delivery dependencies. + +## Documentation + +List current-design sections to update on acceptance and user documentation needed for implementation. + +## Alternatives + +Compare alternatives and explain the decision. + +## Tests + +Specify required tests and acceptance evidence; distinguish requirements from executed results. diff --git a/devbox.lock b/devbox.lock index 031c76d..99e2586 100644 --- a/devbox.lock +++ b/devbox.lock @@ -491,7 +491,7 @@ }, "nodejs@latest": { "last_modified": "2026-04-23T13:07:47Z", - "plugin_version": "0.0.3", + "plugin_version": "0.0.4", "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#nodejs_25", "source": "devbox-search", "version": "25.9.0", diff --git a/docs/design/README.md b/docs/design/README.md index 018e533..5e51522 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,6 +11,7 @@ - [Controllers and Webhooks](./controllers-webhooks.md): controller responsibilities, modules, acceptance boundary, and admission webhook. - [Cloudflare Provider](./cloudflare-provider.md): Cloudflare identity, schema, ZoneClass parameters, provider data, reconciliation, deletion, and record mapping. - [Route 53 Provider](./route53-provider.md): Route 53 identity, provider settings, and record-set provider schema. +- [External Provider](./external-provider.md): verify-only provider for customer-managed DNS: resolver policy, exact-set verification, conditions, scheduling bounds, deletion semantics, and conversion decision. - [Gateway API Integration](./gateway-api-integration.md): `HTTPRoute` / `Gateway` DNS report, Zone selection, and generated `RecordSet` behavior. - [UI](./ui.md): reusable DNS API UI package, screens, workflows, components, and mockup design. - [Headlamp Desktop Plugin](./headlamp-plugin.md): Headlamp Desktop plugin package, distribution, and Desktop-specific behavior. diff --git a/docs/design/controllers-webhooks.md b/docs/design/controllers-webhooks.md index f119122..2406b9a 100644 --- a/docs/design/controllers-webhooks.md +++ b/docs/design/controllers-webhooks.md @@ -25,8 +25,25 @@ Admission validation must not reject `Zone`, `RecordSet`, or `ZoneUnit` solely b All claim conditions include `observedGeneration`. If a resource moves out of a provider controller's scope, that controller stops updating `ZoneUnit.status`. Existing claim conditions can be recognized as old through `observedGeneration`. +When projecting a current provider condition from `ZoneUnit.status.zone` or `ZoneUnit.status.recordSets[]`, core preserves `Status`, `Reason`, and `Message`, including an empty Message. This applies to Accepted and Programmed on both Zone and RecordSet, without changing UID/generation fences or acceptance precedence. Core-generated waiting/rejection conditions retain their own messages. + Provider implementation differences are controlled by common conditions, reasons, status ownership, support levels, and conformance tests. The core does not hide differences by providing one shared DNS controller implementation; providers give a consistent user experience by satisfying the same contract. +## Provider Enablement + +Provider enablement gates runtime registrations and provider-only privileges, not just Provider manifests. The manager accepts these flags and environment defaults; explicit flags take precedence: + +| Chart value | Manager flag | Environment variable | Default | +| --- | --- | --- | --- | +| `providers.route53.enabled` | `--route53-controller-enabled` | `ROUTE53_CONTROLLER_ENABLED` | true | +| `providers.cloudflare.enabled` | `--cloudflare-controller-enabled` | `CLOUDFLARE_CONTROLLER_ENABLED` | true | +| `providers.route53.endpoint.conversionAPI.enabled` | `--route53-endpoint-conversion-enabled` | `ROUTE53_ENDPOINT_CONVERSION_ENABLED` | true | +| `providers.external.enabled` | `--external-controller-enabled` | `EXTERNAL_CONTROLLER_ENABLED` | false | + +Each provider switch gates its identity, ZoneClass, and ZoneUnit controllers, credential-client construction, and identity spec/status admission handler. The chart gates matching identity RBAC and webhook entries. Secret get/list/watch belongs only to enabled Cloudflare; mounted serving certificates do not require Secret API reads. Route 53 conversion requires both Route 53 flags. Its chart flag, capability, APIService, and conversion-create permission additionally require the webhook Service to be enabled. The external switch gates the external ZoneClass/ZoneUnit controllers, the external Provider and `EndpointProviderCapability`, its conversion handler/APIService, and conversion-create RBAC; external has no identity controller, identity webhook, or credential reads, and its conversion API requires the webhook Service with cert-manager-managed serving TLS ([External Provider](external-provider.md#packaging-and-metrics)). + +Core composition, endpoint translation, namespace reads, and core schema/status admission remain enabled independently. Gateway/Service source switches retain their existing registration and permission gates. Scheme/CRD types remain available when a provider is disabled. Helm's default admission coverage matches Kustomize, including ZoneClass status and both identity spec/status endpoints. Kustomize's default installation enables Route 53 and Cloudflare, with external disabled. The opt-in `app/operator/config/external` overlay adds the external Provider, capability, cert-manager-backed APIService, manager arguments, and only its conversion-create permission; it does not add identity or Secret privileges. Custom disable overlays must remove the corresponding admission entries and privileges as well as setting manager flags. Disabling a provider does not complete existing finalizers. + ## Implementation Modules Implementation modules are controller ownership boundaries. One Kubernetes controller has one owning module, and one owning module owns at most one controller. The owning module contains the reconciler entrypoint, watch mapping, finalizer writes, status writes, and controller-specific planning. Shared helpers used by more than one controller must live in a non-controller module and must not own watches, finalizers, or status writes. @@ -86,7 +103,7 @@ The webhook server runs as part of the controller-runtime manager and listens on Webhook TLS certificates are issued by cert-manager. Tilt installs cert-manager as a Helm chart. dns-api manifests apply `Certificate`, `Issuer`, and `ValidatingWebhookConfiguration`. cert-manager CA injection sets the webhook `caBundle`. -Core validating webhook covers at least spec create/update for `Provider`, `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit`, and status update for `Zone`, `RecordSet`, and `ZoneUnit`. Provider-specific validating webhooks cover provider-specific resources such as `Route53Identity` and `CloudflareIdentity`. The core webhook must not import provider API packages such as `api/route53` or `api/cloudflare`. +Core validating webhook covers at least spec create/update for `Provider`, `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit`, and status update for `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit`. Provider-specific validating webhooks cover enabled provider-specific resources such as `Route53Identity` and `CloudflareIdentity`; external has no identity admission handler. The core webhook must not import provider API packages such as `api/route53` or `api/cloudflare`. Provider controllers do not fully trust the webhook. The webhook is the primary user feedback boundary, but controllers keep minimal safety checks. Controller-side validation failure is returned as `Accepted=False` or `Programmed=False`. @@ -139,7 +156,11 @@ validate ZoneClass: provider, providerVersion = resolve ProviderVersion(spec.provider) - validate spec.identityRef shape by providerVersion.identity.resource + if providerVersion.identity is present: + require spec.identityRef with nonempty name + validate spec.identityRef shape by providerVersion.identity.resource + else: + reject spec.identityRef validate spec.parameters by providerVersion.zoneClass.schemas.parameters validate providerVersion.zoneClass.validationRules validate spec.allowedZones diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 7f3a899..8c90e00 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -9,13 +9,13 @@ - `provider.name`: `Provider.metadata.name`. Immutable. Because it references cluster-scoped `Provider`, it does not include namespace or kind. - `provider.version`: `Provider.spec.versions[].name`. Immutable. - `controllerName`: provider controller instance that reconciles `ZoneUnit` resources reachable through this class. Required and immutable. -- `identityRef`: provider identity reference. Required. `identityRef.name` references the provider identity resource declared by the selected Provider version in the same namespace as the `ZoneClass`. +- `identityRef`: optional provider identity reference. Required with a nonempty `name` if and only if the selected Provider version declares `identity`; forbidden otherwise. When present, it references the declared identity resource in the same namespace as the `ZoneClass`. - `parameters`: provider-specific inline settings. Validated by the referenced Provider version `zoneClass.schemas.parameters`. - `allowedZones`: namespace conditions for `Zone` resources that may use this `ZoneClass`. `ZoneClass.spec.controllerName` uses the Gateway API-style domain-prefixed path form. It must include both a DNS subdomain prefix and a path part after `/`. Valid examples are `route53.dns.appthrust.io/controller` and `cloudflare.dns.appthrust.io/prod-a`; `route53.dns.appthrust.io` is invalid. -`ZoneClass.spec.identityRef` contains only `name`. It does not contain namespace, group, or kind. Cross-namespace identity references are not part of the initial design; identity resources live with the `ZoneClass` in the same platform namespace. The referenced Provider version declares the identity `group` and `kind`, so repeating them in every `ZoneClass` would create an unnecessary mismatch surface. +`ZoneClass.spec.identityRef`, when present, contains only `name`. It does not contain namespace, group, or kind. Cross-namespace identity references are not part of the initial design; identity resources live with the `ZoneClass` in the same platform namespace. The referenced Provider version declares the identity `group` and `kind`, so repeating them in every `ZoneClass` would create an unnecessary mismatch surface. Credentialless providers omit the field rather than using an empty or sentinel reference. The Go field is `IdentityRef *LocalObjectReference` with JSON tag `identityRef,omitempty`; both CRD distributions omit the structural requirement, while core admission enforces the Provider-dependent requirement. Upgrade separately managed CRD carriers with the controller; Helm does not upgrade existing CRDs during a normal chart upgrade. `ZoneClass.status.conditions` contains `Accepted`. `Accepted=True` means `provider`, `identityRef`, `parameters`, and `allowedZones` are statically acceptable. `Accepted=False` reasons are `InvalidProvider`, `InvalidIdentityRef`, `InvalidParameters`, and `DeniedByPolicy`. `Accepted=Unknown` reasons are `IdentityNotResolved`, `ProviderStorageVersionNotResolved`, and `ReconcileError`. @@ -53,7 +53,7 @@ spec: `ZoneClass` is not reconciled by a core controller. The core validating webhook reads the referenced Provider version and validates `spec.identityRef`, `spec.parameters`, and `spec.allowedZones`. -Provider controllers reconcile only `ZoneClass` resources whose `spec.controllerName` matches their own controller name. If it does not match, they do not update status. They interpret `identityRef` according to the Provider version identity resource declaration, interpret provider-specific `parameters`, and set `ZoneClass.status.conditions[Accepted]` to `True` when the class is statically acceptable. +Provider controllers reconcile only `ZoneClass` resources whose `spec.controllerName` matches their own controller name. If it does not match, they do not update status. They interpret `identityRef` only when the Provider version declares an identity resource, interpret provider-specific `parameters`, and set `ZoneClass.status.conditions[Accepted]` to `True` when the class is statically acceptable. Credentialless providers perform no identity lookup. Route 53 and Cloudflare still reject absent or empty references controller-side as `Accepted=False/InvalidIdentityRef`. ## Zone diff --git a/docs/design/external-provider.md b/docs/design/external-provider.md new file mode 100644 index 0000000..c5b4ade --- /dev/null +++ b/docs/design/external-provider.md @@ -0,0 +1,355 @@ +# External Provider + +## Purpose and Scope + +The external provider verifies customer-managed DNS. The customer owns authoritative zones, records, delegation, and TTLs. dns-api sends DNS queries and writes Kubernetes reconciliation status; it never creates, updates, adopts, or deletes DNS data. There is no DNS UPDATE, cloud DNS client, credential chain, or fallback DNS writer. + +This document defines the stage-1 design, not evidence that the implementation or a deployment has passed verification. Stage 1 preserves the normal EndpointRecordSet → generated RecordSet → ZoneUnit → Accepted/Programmed path. The platform's Phase 4 acceptance is a remote-member HTTPPublish becoming Ready through real resolution and HTTPS, not merely creating a Provider or reporting a configured Zone. Platform wiring and that acceptance proof are outside this provider's implementation boundary. + +Verified-withdrawal evidence before retired VIP reuse is not part of this provider. Until a `Withdrawn` receipt contract exists, member replacement and deletion under the platform's `external` mode are unsupported. Kubernetes claim deletion described below is supported, but does not make member retirement or address reuse safe. + +## Provider Declaration + +The Core Provider is `external.dns.appthrust.io/v1alpha1`: `Provider.metadata.name=external.dns.appthrust.io`, with one served and storage version named `v1alpha1`. The Provider object itself uses `apiVersion: dns.appthrust.io/v1alpha1`. The default controller name is `external.dns.appthrust.io/controller`. + +Provider declaration fields: + +| Field | Contract | +| --- | --- | +| `spec.display.name` | `External DNS` | +| `spec.display.description` | `Verifies customer-managed DNS without changing DNS records.` | +| `spec.versions[].identity` | Omitted. There is no ExternalIdentity resource. | +| `spec.versions[].zoneClass.schemas.parameters` | The parameter schema below. | +| `spec.versions[].zone.schemas.adoption` | Empty-object schema: `type: object`, `additionalProperties: false`, `properties: {}`. | +| `spec.versions[].recordSet.supportedTypes` | Exactly `A`, `AAAA`, and `CNAME`. | +| `spec.versions[].recordSet.schemas.options` and `.adoption` | The same empty-object schema. Omitted or empty payload is acceptable; nonempty payload is rejected. | +| `spec.versions[].zone.schemas.statusProviderData` and `.recordSet.schemas.statusProviderData` | Public verification data described below, with field descriptions and bounded lists. | +| `spec.versions[].zone.additionalConditionReasons` and `.recordSet.additionalConditionReasons` | The target-specific reasons in the condition tables below. | + +External does not disable core record validations. Standard type/body exclusivity, required TTL, address-family checks, ownership arbitration, CNAME exclusivity, and apex CNAME rejection remain effective. A/AAAA describe the terminal address sets to verify, even when customer DNS uses a CNAME chain. CNAME describes an actual CNAME target to verify, not a provider alias. + +Provider-specific Go types are inline payload/status types, including `ExternalZoneClassParameters`; there are no external Zone or RecordSet CRDs. The provider's sole storage version needs no Core provider-payload version conversion. That is separate from Endpoint App conversion below. + +## ZoneClass Parameters + +`ZoneClass.spec.identityRef` is omitted. Core admission requires a nonempty reference if and only if the selected Provider version declares `identity`, and forbids the field otherwise. Empty or sentinel identity objects do not represent credentialless operation. Route 53 and Cloudflare retain their required identity references. + +`ZoneClass.spec.parameters` fields: + +| Field | Description and bounds | +| --- | --- | +| `resolver.mode` | `Cluster` or `Recursive`. Omitted resolver or mode means `Cluster`. | +| `resolver.servers` | Recursive upstreams as canonical `IPv4:port` or `[IPv6]:port` strings. Required and nonempty for `Recursive`; omitted or empty for `Cluster`. At most 3 distinct endpoints. Port is a decimal integer from 1 through 65535 with no leading zeros. No hostname, URL, scoped IPv6 address, or implicit port. | +| `expectedNameservers` | Optional, at most 16 distinct canonical lowercase ASCII DNS names, without trailing root dots, IP literals, wildcards, or underscores. Omitted/empty means skip delegation and direct-authority checks. This is not a list of recursive resolvers. | + +Use a separate ZoneClass for each domain whose expected authority differs. The platform projects its domain's `expectedNameservers` into this field; dns-api does not read PlatformDomain. Changing resolver policy invalidates prior probe success even if RecordSet generation is unchanged. + +### Schema and CEL + +The `zoneClass.schemas.parameters.openAPIV3Schema` fragment is: + +```yaml +type: object +description: DNS resolver policy for verification without DNS writes. +additionalProperties: false +properties: + resolver: + type: object + description: Resolver view used for every verification query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + expectedNameservers: + type: array + description: Expected authoritative nameservers for the exact Zone domain. + maxItems: 16 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' +``` + +Omitted-field interpretation supplies Cluster; admission does not mutate the manifest. The following `zoneClass.validationRules` complement the schema in the existing KRO CEL environment (`self` is the ZoneClass). The schema first constrains endpoint separators and decimal ports; CEL checks real IP syntax, canonical addresses, port range, and mode/server exclusivity. No provider-specific webhook or new CEL dialect is introduced. + +```yaml +- rule: >- + has(self.spec.parameters.resolver) && + has(self.spec.parameters.resolver.mode) && + self.spec.parameters.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.resolver.servers) && + size(self.spec.parameters.resolver.servers) > 0) + : (!has(self.spec.parameters.resolver) || + !has(self.spec.parameters.resolver.servers) || + size(self.spec.parameters.resolver.servers) == 0) + message: resolver.servers must be nonempty for Recursive and empty for Cluster +- rule: >- + !has(self.spec.parameters.resolver) || + !has(self.spec.parameters.resolver.servers) || + self.spec.parameters.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') + ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') + ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: resolver.servers must contain canonical IP addresses and ports from 1 through 65535 +- rule: >- + !has(self.spec.parameters.expectedNameservers) || + self.spec.parameters.expectedNameservers.all(n, !isIP(n)) + message: expectedNameservers must contain DNS names, not IP addresses +``` + +Canonical server strings and names make `uniqueItems` a semantic duplicate check. DNS answers are normalized before comparison; manifests with noncanonical expectations are rejected rather than silently rewritten. Provider admission compiles these expressions as boolean rules and implementation tests exercise both omitted-field and malformed-input cases through the actual pinned CEL runtime. + +Example: + +```yaml +apiVersion: dns.appthrust.io/v1alpha1 +kind: ZoneClass +metadata: + namespace: platform + name: customer-example +spec: + provider: + name: external.dns.appthrust.io + version: v1alpha1 + controllerName: external.dns.appthrust.io/controller + parameters: + resolver: + mode: Recursive + servers: + - 192.0.2.53:53 + - '[2001:db8::53]:53' + expectedNameservers: + - ns1.example.net + - ns2.example.net + allowedZones: + namespaces: + from: Same +``` + +These documentation addresses are not public resolver defaults. + +### Controller Responsibility + +The external ZoneClass controller matches controllerName and Provider binding, resolves the storage version, validates converted parameters, and sets `Accepted`. It makes no DNS queries and resolves no identity. Invalid configuration uses the existing `InvalidParameters`, `InvalidIdentityRef`, or `InvalidProvider` reasons; missing storage-version material follows the existing Unknown contract. A present `identityRef` on an external ZoneClass, or an absent one on a Route 53/Cloudflare class after a Provider change, is reported controller-side as `Accepted=False/InvalidIdentityRef`. + +The external ZoneUnit controller watches ZoneUnit, matching ZoneClass, Provider, and namespace-policy changes. It reconciles only the configured provider/storage version through the matching class. Core remains the sole composer and public Zone/RecordSet status writer. The provider respects `Allowed`, `DeletionRequested`, claim UID, and claim observedGeneration; it does not probe denied ledger items or use old deletion receipts for a new incarnation. + +The local read-only test/runtime boundary is `Resolver.Probe(ctx context.Context, request ProbeRequest) (ProbeResult, error)`, with injected clock and jitter. Requests contain checked names, expected RRsets, and effective resolver policy; results contain per-view RCODE, answers, AA, TTL/SOA, and authority evidence. There are no DNS Create/Update/Delete methods. The provider does not use a generic cloud provider interface or call a cloud API. + +## Zone Semantics + +An external Zone is an authorized logical suffix. It need not be a separately hosted or delegated zone. Without `expectedNameservers`, a customer may verify records under a suffix of an existing authority. No hosted-zone ID, adoption target, creation policy, or deletion policy is needed. + +`Zone Accepted=True` confirms the binding and policy, not DNS reachability. `Zone Programmed=True` means the verifier is configured and, if requested, the exact Zone's nameserver constraint has passed. It is independent of record success: a missing application record must not make the Zone unconfigured or create a Zone ↔ RecordSet readiness cycle. Conversely, failed or unknown required nameserver checks prevent child RecordSets becoming Programmed=True, even if their recursive answers match. + +With `expectedNameservers`, the exact `Zone.spec.domainName` must be a zone apex in the selected view. A parent authority's NS set cannot be relabelled as a successful delegation check for a nondelegated suffix. `Zone.status.nameServers` contains only a completed observed NS set, not copied expected values; omit it if no set was observed. Conditions and probe times distinguish last observation from current success. + +## RecordSet Verification + +For every active allowed name, collect its A and AAAA desired ledger items together. Normalize addresses to their IP values, normalize DNS answer names to lowercase without the root dot, deduplicate, and sort for deterministic diagnostics. Probe **both** address families. The observed A set must equal the expected A set, and the observed AAAA set must equal the expected AAAA set in every required view. A missing desired family means an empty expected set, not permission to ignore that family's answers. NODATA for an empty expected family is acceptable; NXDOMAIN for a name with expected data is not. + +Set equality is exact: missing, unexpected, or additional-family addresses prevent success. Do not accept subsets, any matching VIP, an address in an allocated pool, or a union assembled from disagreeing resolvers. The two address-family claims share the combined result, so an A-only success cannot hide an unexpected AAAA answer. Adding/removing a sibling family changes the combined expectation and invalidates the surviving claim's earlier receipt. + +For IP intents, a customer CNAME chain is allowed only when terminal address sets satisfy the same predicate. Follow at most 8 CNAME links total, detect repeated canonical names, and reject loops. A chain limit or loop is not an address match. Preserve relevant chain TTLs for scheduling. A CNAME to a dual-stack target for an IPv4-only intent is a mismatch by design; the customer must point at an address set equal to the published one. + +For CNAME intent, the first canonical CNAME target at the checked owner must equal `spec.cname.target`. Another name with the same terminal addresses is not equivalent. Follow the expected target within the same 8-link/loop bounds and require usable terminal A or AAAA resolution in every required recursive view. Both families are inspected; address equality is not asserted against an IP intent that does not exist. Required views must agree on the observed chain and terminal sets. A CNAME returned together with incompatible same-owner data is not successful verification. CNAME at `name: "@"` remains rejected by core. + +### Wildcard Witness + +A wildcard RecordSet is verified by a concrete witness, not a query for the literal `*`. Replace the leftmost wildcard label with a stable DNS-safe label derived from the Zone identity and the sorted active claims' UID/generation tuple. All A/AAAA items for that wildcard use the same witness, including when they were created in different orders. Repeated timer checks of unchanged intent reuse it; recreation or changed desired intent changes it. + +Expose `checkedName` and `sampledWildcard=true` in public data. This proves only the sampled name. Existing explicit owners or closer enclosers can override wildcard synthesis, so every exact application hostname is also independently probed. It does not prove all descendant names or TLS wildcard coverage. + +For the platform's dedicated-member topology, customer records are `B` and `*.B` for the PMC public edge and `*..B` for that Project's actual serving Gateway addresses. A wildcard pointing at the union of reserved member VIPs cannot encode Project placement and must fail when it differs from the intended targets. A single wildcard is adequate only when all covered names deliberately share the same serving address set. Cross-namespace wildcard claims require an explicit `allowedRecordSets.records` grant on the Zone ([Core API](core-api.md#allowedrecordsets)); the suffix match alone does not authorize them. + +## Resolver Policy + +| Policy | Required observation | Scope and failure behavior | +| --- | --- | --- | +| `Cluster` (default) | Read pod `/etc/resolv.conf` nameservers and query each independently using DNS wire queries with RD=1. Use absolute FQDNs, without search-path expansion. Apply the same maximum of 3 distinct upstreams; empty, invalid, or oversized configuration fails closed. | Proves only the controller/PMC resolver view. Do not treat resolv.conf order as failover or silently discard an upstream. | +| `Recursive` | Query every configured IP:port endpoint independently with RD=1; all must match. | Supports operator-selected private/split-horizon or public views. No fallback to cluster DNS or hardcoded public resolvers after failure. | +| Nonempty `expectedNameservers` | Additionally query the exact Zone apex NS and SOA through every selected recursive view, compare the normalized NS set exactly, and require the SOA owner to identify that apex. Resolve every expected NS host through those views, then query every discovered authority address with RD=0. Require AA and the expected apex NS/SOA and in-zone record data. | Every required authority must agree with expected data; a failed authority is not ignored. At most 2 distinct addresses per expected NS after deduplication across views; an excess is an explicit probe-limit failure, not successful sampling. When a CNAME leaves the asserted zone, verify its in-zone authoritative link and follow the remaining chain through the chosen recursive view, not by demanding that the original authority serve the foreign zone. | +| Empty `expectedNameservers` | Recursive view only. | No delegation or direct-authority verification claim. | + +Direct-authority checks also fail closed for an in-suffix delegation whose data the expected authority does not authoritatively serve; the provider does not infer and trust an additional child authority. Configure a separate Zone/Class for a separately delegated child. + +Use a DNS wire client exposing RCODE, AA, TTL, SOA, and TC (for example `miekg/dns`), rather than `net.Resolver.LookupIPAddr` alone. Send QUERY only, UDP first and TCP on truncation. Resolve NS host addresses through configured resolvers, not an implicit OS fallback. No DNSSEC validation, root-to-parent iterative delegation proof, or public/global reachability guarantee is claimed: the recursive view and network are trusted inputs. + +Use the minimum relevant positive TTL across the completed chain; negative cache TTL is `min(SOA TTL, SOA.MINIMUM)` for NXDOMAIN/NODATA when an SOA is present. Expose observed TTLs/negative expiry for diagnostics. A matching authority plus stale recursive NXDOMAIN remains unverified until the recursive view also matches. Do not flush shared caches, change customer TTLs, install an additional process-wide answer cache, or interpret a polling cap as cache expiry. + +## Conditions and Public Data + +A valid claim keeps `Accepted=True` while DNS is wrong or unavailable. Only `Programmed` reflects probe observations. Each receipt uses the exact claim UID and observedGeneration. No provider-owned `Published` or Endpoint `Ready` condition is added; the existing endpoint controller aggregates nonempty generated RecordSets with current Accepted/Programmed evidence. + +RecordSet `Programmed` reason and stable message templates: + +| Status | Reason | Message | +| --- | --- | --- | +| True | `ResolutionVerified` | `DNS matches expected targets in every required resolver view; no DNS changes were made.` | +| False | `DNSNameNotFound` | `DNS returned NXDOMAIN from ; expected .` | +| False | `DNSNoData` | `DNS returned NOERROR with no answers; expected .` | +| False | `ResolutionPartial` | `DNS is missing expected targets ; observed .` | +| False | `ResolutionMismatch` | `DNS targets differ: missing ; unexpected .` | +| False | `ResolutionInconsistent` | `DNS differs between required resolver/authority views .` | +| False | `NameserverMismatch` | `DNS nameservers differ: expected ; observed .` | +| Unknown | `DNSProbeFailed` | `DNS verification unavailable for from : .` | + +`ResolutionPartial` means a nonempty proper subset with no unexpected values. Empty required-type answers use DNSNoData; any unexpected value uses ResolutionMismatch. `` includes typed values (A, AAAA, or CNAME). CNAME loops, incompatible data, and a wrong first target are ResolutionMismatch, with the observed chain included in bounded public data. `` is one of `timeout`, `SERVFAIL`, `REFUSED`, `transport`, `invalid-response`, `resolver-config`, `chain-limit`, or `probe-limit`; raw errors/packets are not embedded in messages. + +A required incomplete/failed probe is Unknown/DNSProbeFailed, never last-known success. Transient `Unknown/DNSProbeFailed` immediately drops EndpointRecordSet `Ready` through the existing generated-RecordSet aggregation; there is no grace window. Among completed observations, differing required views take precedence as ResolutionInconsistent; a consistently wrong expected NS set is NameserverMismatch; otherwise use the negative/partial/mismatch result. Normalize recursive and authoritative observations to the checked owner and terminal data before comparing, so normal AA/TTL differences are not disagreements. All-empty-family NODATA is not an inconsistency. Select the first failing view in canonical order for single-view message templates. + +Zone `Programmed` reasons: + +| Status | Reason | Message | +| --- | --- | --- | +| True | `VerificationConfigured` | `DNS verifier configured for ; authoritative nameserver checks were not requested.` | +| True | `NameserversVerified` | `DNS nameservers match expectations in every required view; no DNS changes were made.` | +| False | `NameserverMismatch` | The nameserver mismatch template above; includes a non-apex NS expectation. | +| False | `ResolutionInconsistent` | The inconsistent-view template with the Zone domain as ``. | +| Unknown | `DNSProbeFailed` | The probe-failure template with the Zone domain as ``. | + +Register exactly these extra status/reason combinations in the Provider's respective Zone and RecordSet `additionalConditionReasons`. Propagate a required Zone authority failure/unknown to affected RecordSets. Before any completed observation, use the existing Unknown/Reconciling condition. Unit-level Programmed uses only the fixed allowlist (`validator.go` ZoneUnit status): True/`Programmed` when every active observation succeeds; False/`ExternalResourceNotFound` when the first failing target is `DNSNameNotFound`; False/`ExternalResourceMismatch` for every other completed mismatch (`DNSNoData`, `ResolutionPartial`, `ResolutionMismatch`, `ResolutionInconsistent`, `NameserverMismatch`); Unknown/`ProviderUnavailable` for `DNSProbeFailed`. The provider implements the same precedence as the Route 53 unit aggregate (`zone_status.go`): any False target wins; Unknown only when no target is False. Target reasons are mapped, never copied, into unit-level status. + +Core projection preserves the selected current provider condition's `Message` along with Status/Reason for both Zone and RecordSet, including an empty message; it must not replace diagnostics with the reason string. Core-generated waiting/rejection conditions retain their own messages. Existing UID/generation guards and acceptance precedence remain in force. + +`Zone.status.provider.data` and `RecordSet.status.provider.data` expose: + +- `resolverMode`, `checkedName`, and, for RecordSets, `sampledWildcard`. +- Sorted typed `actual`, `missing`, and `extra` values and bounded CNAME-chain/view diagnostics. Each list is capped at 16 entries with total counts and an explicit truncation flag; equality is evaluated on complete observations, never truncated status. +- `observedTTLSeconds`, `negativeTTLSeconds`, and `negativeExpiryTime` when the corresponding evidence exists; no invented zero TTL for missing SOA. +- `lastProbeTime` (last completed attempt, successful or failed) and `nextProbeTime` (approximate next planned check). + +Messages are capped at 2048 bytes, use deterministic sorted list previews with omitted counts, and contain no credentials or raw packets. Successful timestamps/next-probe updates do not emit repeated success Events or change LastTransitionTime when condition status is unchanged. Events report transitions only; status remains the source of truth. + +Private `ZoneUnit.status.provider.state` holds advisory due times, fair-resumption cursor, backoff, and the resolver-policy/desired-name fingerprints. A probe receipt is bound to the claim tuple, all sibling-family desired inputs, the Zone binding, and effective normalized Class/Provider/resolver configuration. Before committing an asynchronous result, recheck that those inputs still match; otherwise discard it and queue current intent. Class/Provider policy changes and resolv.conf changes invalidate old success. Restart reconstructs scheduling from current claims and does not trust obsolete private state. + +## Scheduling and Resource Bounds + +DNS edits and recursive cache expiry have no Kubernetes watch. A timer is unavoidable: a customer may fix DNS without changing any Kubernetes object. Probe immediately on new/changed intent or effective policy and keep rechecking successful claims to detect drift. Kubernetes watches complement rather than replace timers. A later failed probe removes Programmed=True. + +| Outcome | Base next-check interval | +| --- | --- | +| Pending, partial, or mismatch | 30 seconds | +| Success | Minimum relevant positive TTL, clamped to 30–300 seconds | +| NXDOMAIN/NODATA | Negative TTL, clamped to 30–300 seconds | +| Missing relevant TTL | 30 seconds | +| Unavailable probe | Backoff 30 → 60 → 120 → 300 seconds; reset after a completed non-error observation or changed inputs | + +Apply ±10% injected jitter, then clamp again to 30–300 seconds. These are polling bounds, not an external propagation deadline or a wall-clock freshness promise. Customer caches can outlive the polling cap. A stopped controller cannot withdraw a previously published condition; expose probe times and monitor controller health. Consumers that need freshness treat a claim as stale when `now > nextProbeTime + 300 s`; conditions alone do not encode this. Existing EndpointRecordSet aggregation reads conditions, not that timestamp threshold; it immediately drops Ready on an observed Unknown/DNSProbeFailed, without a grace window. + +Every exchange has a 2-second timeout. Each name has a 10-second deadline that starts when its first exchange is admitted by the limiter; limiter wait time is not charged to the name. Zone-apex NS/SOA queries and expected-NS host resolution are performed once per Zone per probe cycle and shared by all names in that cycle, keyed by the resolver-policy fingerprint; only in-zone A/AAAA/CNAME queries are per name. The manager-wide limiter is sized so that 4 concurrent names with the maximum authority fan-out (16 NS × 2 addresses × 2 qtypes) complete inside the name deadline (default 40 QPS, burst 80), and is a manager flag. Exhausted deadlines or limits are Unknown, never success from a partial sample. + +The manager admits at most 4 concurrent names. The limiter flags are `--external-dns-qps` (default `40`) and `--external-dns-burst` (default `80`); they apply across all ZoneUnits. The per-cycle Zone evidence is not a process-wide answer cache and is invalidated by a resolver-policy change. Limiter queue delay remains observable separately from admitted probe duration. + +Process at most 32 due names per ZoneUnit reconcile. Resume fairly through private state and use a 1-second continuation while previously unprocessed due work remains; this exception schedules other due names, not another probe of a completed name before its 30-second floor. Recompute the next scheduled reconcile from the earliest due time when the backlog is drained. Queue saturation may exceed the nominal cadence and must be visible in queue-delay metrics. The provider's own unchanged status events must not trigger fresh probes; keep the core watch that projects genuine status updates. Patch status/receipts, not claim spec or arbitrary timestamps on every watch event. + +## Deletion + +External owns no DNS object. For a matching-UID ledger item with `DeletionRequested=true`, stop probes and mark `DeletionCompleted=true` after Kubernetes bookkeeping. Complete ZoneUnit finalization without a DNS delete call and without waiting for the customer to remove a wildcard. Existing core claim/endpoint finalizers still wait for the appropriate Kubernetes receipts and generated-child disappearance. Old-incarnation deletion completion must not finalize a replacement claim. + +**Deletion completion is not verified DNS withdrawal.** Neither claim disappearance, observedGeneration, a completed verifier finalizer, nor a timer establishes that retired targets have disappeared from DNS or caches. A separate `Withdrawn` condition/receipt contract is required before retired VIP reuse; its schema, persistence, consumers, and cache-safety protocol are outside this provider's scope. The provider does not emit or accept `Withdrawn`. Platform member replacement/deletion under external remains unsupported until that contract exists; allocations must not be released based on this bookkeeping-only completion. + +## Admission and Security + +Admission performs no network/DNS queries. Reuse Provider OpenAPI/CEL and core webhook rules for shape, immutable references, supported types/bodies, ordinary TTL range, and object-local namespace-policy declarations. Cross-object grants and ownership remain core composition checks. Reject external identityRef and nonempty options/adoption, including Route 53 alias and Cloudflare proxy fields. Keep all ordinary CNAME/record ownership protections. + +Only operators may create/update Providers and resolver-bearing ZoneClasses. Tenants cannot choose arbitrary DNS destinations by creating a RecordSet. Explicit private/RFC1918 resolvers are allowed for on-premises use; DNS egress controls must allow the intended recursive and optional authority endpoints, not blanket-ban private IPs or silently change the view. Resolver-address errors fail closed. + +External requires no Secret get/list/watch, identity-resource access, IAM/STS calls, token access, or credential environment configuration. Kubernetes API authentication and mounted webhook TLS serving certificates are not customer DNS credentials; they do not require the controller to read the Kubernetes Secret API. An external-only chart deployment must remove cloud provider RBAC and Secret informers, not just omit cloud Provider manifests. Existing core/endpoint resources, namespace reads, status/finalizers, Events, leader-election leases, and the enabled conversion API's create permission remain. + +## Endpoint Conversion Decision + +**Decided for stage 1: retain the provider-specific aggregated Endpoint RecordSet Conversion API; reject schema-equality-inferred built-in identity conversion.** No Core Provider or EndpointProviderCapability API field is added for an inferred bypass. + +Grounding at source revision `87aa1dc`: + +- `internal/go/apps/endpoint/controllers/endpointrecordset/controller.go:298-374` always creates the capability's conversion GVR (apart from an injected test function), requires a REST config, checks response UID/result/nonempty fragments, and rejects changed names or unsupported output types. +- `pkg/go/api/endpoint/v1alpha1/endpointprovider_capability_types.go:21-41` requires `spec.conversion` with group/resource and an optional version. There is no built-in or identity mode. +- `internal/go/providers/route53/conversion/handler.go:81-186` classifies targets, splits IPv4/IPv6, chooses TTL, rejects unsupported mixtures, and maps a hostname to aliases unless CNAME is explicitly requested. Its standard bodies do not establish identical conversion policy. +- Core Provider storage conversion operates on `{options, adoption}` (and other provider-owned payloads), not a full endpoint target list or RecordSetSpecFragment. Same storage version already avoids Core payload conversion, but does not transform endpoint intent. + +Schema equality therefore cannot prove semantic identity: input targets and output fragments have different shapes/cardinalities, and optional alias policy can differ despite shared standard record bodies. A future in-process standard-record converter is technically possible, but would need an explicit Endpoint App capability discriminator and a versioned shaping contract; it belongs to the App, not Core. It cannot be safely inferred from missing identity, A/AAAA/CNAME support, or schema similarity. That additional API is not needed for stage 1. The chosen tradeoff retains APIService/TLS operational cost and the existing discovery/authentication/RBAC/UID validation path instead of creating a second implicit convention. + +The exact capability uses the existing API unchanged: + +```yaml +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: external-v1alpha1 +spec: + provider: + name: external.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions +``` + +The matching APIService is `v1alpha1.endpoint.external.dns.appthrust.io`. A stateless handler on the existing manager webhook server serves discovery and POST-only resource creation at `/apis/endpoint.external.dns.appthrust.io/v1alpha1/endpointrecordsetconversions`; it does not persist conversion objects. Reuse the existing request/result/UID framing, never the Route 53 alias behavior. Conversion performs no DNS queries and has no credentials. + +For IP-only targets, emit nonempty A and/or AAAA fragments in that order after canonicalization, deduplication, and sorting. For exactly one distinct hostname target, emit CNAME; reject apex CNAME, mixed IP/hostname targets, and multiple distinct hostnames. Preserve `input.name`, including wildcard form. An absent preferredRecordType or CNAME preference is permitted for hostname targets; a preference incompatible with target kinds is rejected. Every emitted standard fragment has `ttl: 300`, no options/adoption, and its matching core body, including CNAME, unlike the Route 53 handler's 60-second CNAME TTL. The TTL satisfies the existing schema; external does not set customer TTL or require observed TTL equality. The endpoint controller currently derives `preferredRecordType` only from the `endpoint.dns.appthrust.io/route53-record-type` annotation; external accepts the forwarded value but does not introduce a second annotation. Renaming that annotation is an Endpoint App change outside this document. + +Conversion failures use `InvalidInput` for malformed input and `UnsupportedTarget` for unsupported target/preference combinations, with `retryable=false`; success uses `Converted`. Mixed targets use `External endpoint conversion does not support mixed hostname and IP targets`; multiple hostname targets use `External endpoint conversion requires exactly one hostname target`; apex uses `External endpoint conversion does not support CNAME at the zone apex`. UID, nonempty-fragment, name, and type checks remain mandatory in the endpoint controller. Failure never produces an empty ready endpoint. + +## Packaging and Metrics + +`providers.external.enabled` defaults to false. `--external-controller-enabled` gates external ZoneClass/ZoneUnit registration and its conversion handler. Provider/controller-name/version flags and environment variables follow existing provider bindings: `--external-controller-name` / `EXTERNAL_CONTROLLER_NAME`, `--external-provider-name` / `EXTERNAL_PROVIDER_NAME`, and `--external-provider-version` / `EXTERNAL_PROVIDER_VERSION`. + +Provider values must actually gate registrations and provider-only privileges as specified in [Controllers and Webhooks](controllers-webhooks.md#provider-enablement-required-contract-not-implemented-at-87aa1dc). Enabling external installs its Provider, capability, APIService, conversion-create RBAC, and handler together. Do not render an external capability that cannot be served. External enablement requires webhook TLS with a valid CA bundle and certificate; the chart `fail`s rendering when `providers.external.enabled=true` and either `webhook.enabled=false` or `certManager.enabled=false`; a user-supplied `webhook.caBundle` is not provided in this revision. Do not copy an `insecureSkipTLSVerify` fallback. No external conversion-disable sub-option is needed. Core admission remains available regardless of which providers are enabled. + +Synchronize generated API types/deepcopy, both CRD distributions, Go identity-using callers, `docs/design/ui.md`, and `pkg/ts/ui/src/views/platform/ZoneClassForm.tsx` when making identityRef optional. The UI supports external ZoneClass creation in stage 1: it selects the Provider without an identity, omits `identityRef` and the identity picker, and exposes external resolver parameters. A new chart with an old ZoneClass CRD still rejects credentialless classes; separate CRD carriers must be upgraded too. Helm and Kustomize admission wiring must agree, including ZoneClass status and enabled identity webhooks. Existing Gateway/Service watcher switches and the single controller ServiceAccount binding remain independent. + +Use the existing manager metrics registry/server: + +| Metric | Type and labels | +| --- | --- | +| `dns_external_probes_total{outcome,qtype}` | Counter; qtype is A, AAAA, CNAME, NS, or SOA; outcome is verified, mismatch, negative, or failed. | +| `dns_external_probe_duration_seconds{outcome}` | Histogram of completed probe durations. | +| `dns_external_pending_records{reason}` | Gauge of active unverified record claims, using only the closed reason set above plus Reconciling. | +| `dns_external_probe_queue_delay_seconds` | Histogram of delay from due time to attempt start. | + +No hostname, namespace, IP, UID, or resolver-address labels. Do not expose packet/credential content in logs or metrics. + +## Tests + +Follow [Test Policy](test-policy.md): exhaustive deterministic unit/envtest coverage, representative black-box success paths only for kest/conformance. The external success path is `tests/e2e/external/external-a-record-lifecycle.test.ts`, using an in-kind authoritative CoreDNS `file` fixture whose zone the harness edits to model the customer; it asserts mismatch→match→drift without public DNS. No public DNS, cloud credentials, or real customer resources are used in external-provider CI. Controlled local DNS fixtures may be changed by the test harness to model the customer; the provider itself must remain read-only. + +### Unit and Local Transport + +Inject a fake Resolver, clock, and jitter. Cover exact sets, order/duplicates, canonical IPv4/IPv6, unexpected opposite family, independent per-host outcomes, CNAME first-target identity and terminal resolution, eight-link boundary/loops, shared wildcard witness and explicit-owner overrides. Cover NXDOMAIN, empty expected-family NODATA, partial/mismatch, disagreeing views/authorities, nondelegated suffix, missing authoritative AA, foreign CNAME targets, and every failure mapping. + +Use fake time for positive/negative SOA TTLs, missing TTL, clamping/jitter, 30/60/120/300 backoff, policy-change reset, mismatch→match without Kubernetes writes, and drift after success. Cover rate/concurrency limits, limiter-admission deadline start, per-Zone NS/SOA and NS-host work sharing/invalidation, finite per-pass work, fair continuation, observer status events not becoming probe loops, and no success from incomplete fan-out. Verify UID/generation/sibling/policy fingerprints, same-name recreation, stale results, Allowed=false, and incarnation-matched deletion completion. + +Run transport tests against a loopback UDP/TCP fixture for RCODE/AA/SOA parsing, truncation fallback, malformed replies, and cancellation. Assert only QUERY packets, never UPDATE, and no implicit resolver fallback or cloud client calls. Test conversion HTTP framing, discovery, UID, typed fragments, unsupported targets/preferences, and TTL compatibility without DNS access. + +### Envtest + +Install the real regenerated CRDs and Provider manifest, enable core admission and external Class/Unit and endpoint controllers, and inject a controlled resolver. Create Class → Zone → EndpointRecordSet and assert generated RecordSets and exact current conditions through mismatch → match → drift without spec updates. A configured Zone must remain independent of child failures; failed nameserver checks must block otherwise-matching children. Assert diagnostic Message projection for both Zone and RecordSet, zero/stale-child readiness rejection, immediate Endpoint Ready loss on Unknown/DNSProbeFailed without a grace window, cross-namespace wildcard grants, finalizers, mapped unit-level reasons, and deletion bookkeeping without DNS mutation. + +Exercise external identityRef rejection, Route 53/Cloudflare missing-identity rejection, controller-side InvalidIdentityRef after Provider identity changes, canonical parameter/CEL validation, invalid status reasons, storage-version handling, and old-schema incompatibility. Tests must use real CRD/admission paths, not just direct helpers. Separately configure envtest aggregation, request-header authentication, serving TLS/CA, and RBAC to exercise the real APIService conversion path including denied/unavailable/malformed cases. An injected conversion function does not prove that wiring. + +### Chart and Registration Contracts + +Cover external-only, each cloud-provider enable combination, all-disabled, and independent Gateway/Service source switches. Assert flags, actual controller/identity-webhook/conversion registrations, rendered Provider/capability/APIService agreement, valid CA wiring, rendering rejection when webhook or cert-manager is disabled for external, and Helm/Kustomize admission parity. External-only must have no cloud identity or Secret RBAC/informers; disabled source controllers must not restore their unrelated permissions. Retain the single intended controller ServiceAccount binding and CRD-copy synchronization contracts. + +Platform acceptance uses an explicitly authorized run with real customer-controlled DNS and remote-member HTTPPublish Ready, including wrong-target/wrong-NS recovery. Forced-IP HTTP, TLS verification bypass, borrowed synthesis domains, public DNS in CI, and unexecuted test plans are not verification evidence. diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index ec9ed1c..c438812 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -5,3 +5,6 @@ This section tracks unresolved design decisions. When an item is resolved, refle - [ ] Define conformance test distribution, execution method, configuration file schema, and support level representation. - [ ] Define shared ownership of hosted zones by multiple platforms and ownership tag naming. - [ ] Define non-Go devbox tool versions. +- [ ] Define the operator-controlled recursive view authoritative for each external deployment's clients and members. Cluster mode proves only the PMC view; dns-api does not infer a shared split-horizon view. +- [ ] Validate the external provider's 16-NS/2-address fan-out bounds and committed defaults (10-second name deadline from limiter admission; manager-wide 40 QPS / burst 80, with per-Zone NS/SOA and NS-host work amortized per probe cycle) against expected Zone sizes and DNS topologies. Larger configurations fail closed or incur queue delay, never relax exact-set verification. +- [ ] Assign coordinated CRD-carrier, admission/TLS, and chart upgrade ownership in consuming installations. External requires cert-manager-enabled TLS and synchronized optional-identity UI/CRDs; Provider enablement alone does not establish readiness. diff --git a/docs/design/overview.md b/docs/design/overview.md index 867575d..1db5bc3 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -19,7 +19,7 @@ The `Provider` resource is the Core Provider discovery contract. Core reads Core ## Initial Scope -The initial implementation targets Route 53 public hosted zones. dns-api handles Public DNS only. Private DNS, VPC association, split-horizon DNS, and DNS propagation checks are out of scope initially. The API is not Route 53-specific and must allow future providers such as Google Cloud DNS, Cloudflare, and Azure DNS. +The initial Route 53 milestone targets public hosted zones. Private DNS, VPC association, split-horizon DNS, and DNS propagation checks are outside that milestone. The external verification provider adds DNS-query observation of customer-managed records in operator-selected public or private resolver views under the existing `Programmed` condition, without new condition types. The API is not Route 53-specific; provider contracts define their supported scope. The initial milestone is the ability to use a development AWS profile to create public hosted zones, standard A records, standard AAAA records, standard TXT records, standard CNAME records, standard MX records, standard CAA records, delegated NS records, and Route 53 ALIAS records from `Provider`, `ZoneClass`, `Route53Identity`, `Zone`, and `RecordSet`. DNS propagation checks are not part of this milestone. @@ -29,11 +29,11 @@ Core API resources: - `ZoneClass`: a DNS zone creation menu. - `Provider`: provider capability, schema, validation, and display metadata. -- `Zone`: a management unit corresponding to a hosted zone / zone on the DNS provider. +- `Zone`: a management unit corresponding to a hosted zone / zone on a writing DNS provider, or a logical suffix for external verification. - `RecordSet`: a DNS record set. - `ZoneUnit`: the zone-scoped desired-state and ownership ledger built from accepted `Zone` and `RecordSet` claims. - Provider-specific identity CRDs: which provider account, IAM role, or service account is used for operations. -Provider identity details live in provider-specific identity CRDs. `ZoneClass.spec.identityRef` selects a provider identity by name, and `ZoneClass.spec.parameters` holds provider-specific policy as an inline object. The core API has no provider-specific account or credential schema. The provider version declares the identity resource kind so UI, documentation, and generic reference display can discover which identity kind a `ZoneClass` uses. +Provider identity details live in provider-specific identity CRDs. `ZoneClass.spec.identityRef` selects a provider identity by name when the Provider version declares one; credentialless providers omit it. `ZoneClass.spec.parameters` holds provider-specific policy as an inline object. The core API has no provider-specific account or credential schema. When an identity is declared, the provider version supplies its resource kind so UI, documentation, and generic reference display can discover which identity kind a `ZoneClass` uses. -Standard condition types are `Accepted` and `Programmed`. `Accepted` is the result of composition acceptance and provider acceptance. `Programmed` describes whether the external provider's observed state matches desired state. Conditions such as `Propagated` or `Resolvable` that require DNS-query observation are not part of the initial core API. +Standard condition types are `Accepted` and `Programmed`. `Accepted` is the result of composition acceptance and provider acceptance. `Programmed` describes whether the external provider's observed state matches desired state. Conditions such as `Propagated` or `Resolvable` that require DNS-query observation are not part of the initial core API. DNS-query observation is a provider-specific `Programmed` criterion, not a core condition. diff --git a/docs/design/provider-extension.md b/docs/design/provider-extension.md index 976eb63..187fe75 100644 --- a/docs/design/provider-extension.md +++ b/docs/design/provider-extension.md @@ -16,6 +16,8 @@ The manager exposes startup flags and matching environment variables for the ini `route53-controller-name` and `cloudflare-controller-name` configure the controller name compared with `ZoneClass.spec.controllerName`. Defaults are `route53.dns.appthrust.io/controller` and `cloudflare.dns.appthrust.io/controller`. Provider controllers must not hard-code controller name as their only supported runtime binding; forks and multi-instance deployments can use these settings to claim a different `ZoneClass.spec.controllerName` while still using the same Provider contract. +The external provider uses the same binding convention with `--external-controller-name` / `EXTERNAL_CONTROLLER_NAME`, `--external-provider-name` / `EXTERNAL_PROVIDER_NAME`, and `--external-provider-version` / `EXTERNAL_PROVIDER_VERSION`, defaulting to `external.dns.appthrust.io/controller`, `external.dns.appthrust.io`, and `v1alpha1`. Provider binding flags select a contract; they are not enable switches. Manager registration, chart enable values, identity admission, conversion handlers, and provider-only RBAC must be gated together as defined in [Provider Enablement](controllers-webhooks.md#provider-enablement-required-contract-not-implemented-at-87aa1dc). + `Provider.spec` fields: - `display.name`: provider display name. Required. @@ -26,6 +28,7 @@ The manager exposes startup flags and matching environment variables for the ini - `versions[].served`: whether the version can be referenced on create or spec update. - `versions[].storage`: whether the version is the provider storage version used in `ZoneUnit`. - `versions[].deprecated`: whether the UI and validation messages should treat the version as deprecated. +- `versions[].identity`: optional identity declaration. Omitted for credentialless providers; an empty identity object is not valid. - `versions[].identity.resource.group`: API group of the provider identity resource selected by `ZoneClass.spec.identityRef`. - `versions[].identity.resource.kind`: kind of the provider identity resource selected by `ZoneClass.spec.identityRef`. - `versions[].identity.resource.scope`: identity resource scope. Initial supported value: `Namespaced`. @@ -137,7 +140,7 @@ Provider admission error messages identify the failing field path and state that Provider admission validates conversion CEL expressions separately. A conversion CEL expression must compile in the Provider conversion environment, where `self` is the provider-owned payload object and the expression result must be an object that can be validated against the storage target schema after defaulting. Conversion CEL expressions do not return `bool`. -`versions[].identity.resource` is required for provider versions that use provider identity CRDs. The initial dns-api providers use identity CRDs, so Route 53 and Cloudflare both declare it. The declaration is discovery and contract metadata: the core API can validate the `identityRef.name` shape and UI can link to the correct identity kind, while the provider controller remains responsible for resolving the identity object, checking provider-specific readiness, and applying provider-specific credential rules. +`versions[].identity.resource` is required when `versions[].identity` is present. Route 53 and Cloudflare declare it; the external verification provider omits it. The declaration is discovery and contract metadata: core admission requires a nonempty `ZoneClass.spec.identityRef.name` if and only if the selected version declares identity, and rejects `identityRef` otherwise. It does not manufacture an identity for credentialless providers. UI can link to the declared identity kind, while the provider controller remains responsible for resolving that object, checking provider-specific readiness, and applying credential rules. Provider admission validates `versions[].recordSet.supportedTypes` independently of any `RecordSet`. It rejects unknown types and types that do not have a core `RecordSet.spec` body in the current API version. A provider package must not claim `SRV` or another future type until the core API adds the matching body field, validation rules, examples, and provider schema support. This keeps `RecordSet.spec.type`, the required body field, and Provider capability metadata consistent. @@ -200,13 +203,26 @@ Provider runtime interfaces are local to each provider controller package. A pro - Route 53 uses a hosted zone and record-set change-batch model. Its runtime interface may combine hosted zone operations, change polling, tagging, record-set listing, and record-set batch changes. - Cloudflare uses separate zone and DNS record APIs. Its runtime interfaces may separate zone operations from DNS record operations. -Provider factories are also provider-specific. They resolve provider identity resources, credentials, SDK clients, and provider-specific readiness requirements before returning the local runtime interface. The core API only requires that `ZoneClass.spec.identityRef.name` points to the identity kind declared by the selected Provider version; the provider controller owns runtime identity resolution. +Provider factories are also provider-specific. When an identity is declared, they resolve provider identity resources, credentials, SDK clients, and provider-specific readiness requirements before returning the local runtime interface. Core requires `ZoneClass.spec.identityRef.name` to select the identity kind declared by the selected Provider version and forbids the reference when no identity is declared; the provider controller owns runtime identity resolution. A credentialless verifying provider instead uses a local read-only resolver interface, with no identity factory or DNS mutation methods. Fake providers are test boundaries, not public extension points. Unit tests and envtest-style controller tests use fake implementations of the provider package interfaces to verify finalizers, conditions, events, status provider data, adoption checks, provider error mapping, and retry behavior without calling real cloud APIs. These fakes should stay close to the controller package unless repeated cross-provider test logic proves that a shared helper removes real duplication. Common code may be shared for provider reference parsing, Provider version resolution, condition reason naming, status provider data encoding, and validation helpers. It must not force Route 53, Cloudflare, or future providers into a single CRUD-shaped DNS provider abstraction. -Example: +### External Verify-Only Runtime + +`internal/go/providers/external` implements the credentialless `external.dns.appthrust.io/v1alpha1` contract. Its manifest advertises exactly A, AAAA, and CNAME. ZoneClass parameters choose Cluster pod DNS or up to three explicit Recursive IP:port views and optionally up to sixteen expected authoritative nameservers. All configured views must agree; there is no OS or public-resolver fallback. Provider OpenAPI/CEL rejects noncanonical endpoints, noncanonical nameservers, nonempty adoption/options, and incompatible resolver settings. + +The read-only boundary is `probe.Resolver.Probe(context.Context, probe.ProbeRequest) (probe.ProbeResult, error)`. The wire implementation sends QUERY only, uses UDP then TCP on truncation, follows at most eight CNAME links, and checks both address families against complete exact sets. A wildcard uses a stable concrete witness derived from the ZoneUnit identity, Zone reference, and sorted claim UID/generation tuple; it is sampled evidence, not a guarantee about every descendant name. The provider consumes the core ledger, never writes claim specs/status, and never reads DNS credentials. + +Manager wiring uses `zoneclass.ZoneClassReconciler.SetupWithManager`, `zoneunit.NewReconciler(client.Client, zoneunit.Options)` followed by `SetupWithManager`, and `conversion.NewHandler().Register(webhookServer)`. `Options` carries binding strings, optional injected Resolver/Clock/Jitter/EffectivePolicy, and QPS/Burst (defaults 40/80). The controller admits at most four names concurrently across units and thirty-two due names per pass. One cycle-local Zone probe shares NS/SOA and NS-host work across its names. It persists advisory due times/fingerprints and resumes a backlog after one second; completed names retain a 30–300 second polling floor/cap, ±10% jitter, and failure backoff of 30/60/120/300 seconds. + +Public verification data includes bounded typed actual/missing/extra previews, chain/views, observed TTL and optional SOA negative expiry, checkedName, sampledWildcard, and last/next probe times. A later failed observation clears success. ZoneUnit aggregate reasons map to core's fixed allowlist, with False taking precedence over Unknown. Deletion completes only UID-bound Kubernetes bookkeeping: it never removes customer DNS and is not proof that a retired VIP is safe to reuse. + +The endpoint conversion group is `endpoint.external.dns.appthrust.io/v1alpha1`, resource `endpointrecordsetconversions`. It preserves request UID and name, emits sorted canonical A then AAAA fragments or one CNAME fragment, and uses TTL 300 without modifying customer TTLs. Mixed IP/hostname targets, multiple distinct hostname targets, incompatible preferences, and apex CNAME are rejected. Provider and capability declarations are in `internal/go/providers/external/manifest`; manager/chart registration is separate from these runtime packages. + + +### Provider Declaration Example ```yaml apiVersion: dns.appthrust.io/v1alpha1 diff --git a/docs/design/test-policy.md b/docs/design/test-policy.md index 33d004c..b5bd94b 100644 --- a/docs/design/test-policy.md +++ b/docs/design/test-policy.md @@ -8,6 +8,14 @@ Conformance test distribution, execution method, configuration file schema, and envtest is used as integration testing with Kubernetes API server, etcd, CRDs, admission webhook, and reconciler. envtest does not operate real Route 53 or other external resources. Provider calls are replaced with fake or mock implementations. It checks Kubernetes resources, conditions, events, and finalizers. For TXT records, envtest or provider unit tests cover `spec.txt.values` validation, `_` in record names, core rejection of newline, tab, control-character, and non-ASCII TXT values through `forbid-txt-non-printable-ascii`, and Route 53 quote / escape / 255-octet chunk comparison as logical TXT values. For CNAME records, envtest or provider unit tests cover zone apex rejection, target syntax validation, standard-body exclusivity, and CNAME same-name conflict with other record types. For MX records, envtest or provider unit tests cover preference range validation, exchange syntax validation, duplicate pair rejection, Null MX validation, standard-body exclusivity, and Route 53 value parsing as unordered preference / exchange pairs. For CAA records, envtest or provider unit tests cover flags range validation, tag grammar validation, value presence, duplicate tuple rejection, standard-body exclusivity, and Route 53 quote / escape parsing as unordered flags / tag / value tuples. For delegated NS records, envtest or provider unit tests cover apex rejection, wildcard rejection, name server syntax validation, duplicate rejection, standard-body exclusivity, and Route 53 name server value comparison as unordered DNS name sets. +External provider: unit tests inject a fake Resolver/clock; a loopback UDP/TCP DNS fixture covers wire parsing; envtest injects controlled answers. kest `tests/e2e/external/external-a-record-lifecycle.test.ts` runs against an in-kind authoritative fixture (CoreDNS `file` plugin, not cluster service discovery) whose zone the harness edits to model the customer; it asserts mismatch→match→drift and never queries public DNS. + +External provider unit tests live in `internal/go/providers/external` and `pkg/go/api/external`. They use an injected Resolver, clock, and jitter for exact sets, sibling-family and policy fingerprints, wildcard witnesses, bounded passes, backoff, mismatch/recovery/drift, stale-result rejection, and UID-bound deletion. Loopback UDP/TCP fixtures exercise DNS wire parsing, truncation fallback, SOA negative TTLs, RD/AA, cancellation, and QUERY-only behavior. No public DNS, cloud credentials, or customer DNS resources are used. Manifest tests compile and evaluate the declared rules through the pinned KRO CEL environment; conversion tests cover HTTP framing, UID, typed fragments, TTL 300, and failures. + +External no-op reconciliation tests also round-trip the private scheduling JSON with reordered keys and changed formatting. Before a due time, unchanged typed receipts must retain their stored representation, issue no DNS probe, and perform no status patch; resourceVersion and probe counts are both asserted for successful and failed observations. + +After integrating the optional-identity core API, the focused command is `go test ./internal/go/providers/external/... ./pkg/go/api/external/...`. The runtime packages alone do not establish manager/chart registration, aggregated APIService authentication/TLS/RBAC, or end-to-end EndpointRecordSet readiness. Those remain separate integration acceptance checks, not claims inferred from fake-resolver tests. + Provider validation tests cover the `forbid-txt-non-printable-ascii` disable switch as a core named validation. The built-in Route 53 and Cloudflare Provider manifests do not disable it. A provider test fixture may disable it to prove that the core webhook can be relaxed by a future provider version after that provider defines escape, unescape, display, and comparison behavior. Route 53 provider unit tests keep a defensive controller-side rejection path for non-printable ASCII TXT values even though the built-in webhook path rejects them earlier. envtest or webhook unit tests cover status subresource validation. For `RecordSet/status`, tests reject `status.observedGeneration` values greater than the `RecordSet.metadata.generation`, reject `status.zone.ref` namespace or name values that differ from `spec.zoneRef` after namespace defaulting, and reject any `status.zone.controllerName` field. Tests also accept a valid `status.zone.ref` as the controller-resolved `Zone` reference. CRD schema or generated-type checks must show that `RecordSet.status.zone.controllerName` is absent from the API surface. @@ -141,7 +149,7 @@ kest assertions are limited to API-contract behavior observable by users. Primar `ZoneUnit` ownership keys, stable record-set map behavior, finalizers, and controller internal intermediate states are tested in conformance tests or envtest. Initial kest success paths do not directly inspect internal `ZoneUnit` ownership details. -Failure paths, DNS propagation checks, adoption, conflict, drift, external AWS resource alias targets, alias target discovery, and long TXT value chunking are not part of initial kest scenarios. TXT quote, escape, and 255-octet chunking details are covered by envtest and provider implementation unit tests. +Failure paths, DNS propagation checks, adoption, conflict, drift, external AWS resource alias targets, alias target discovery, and long TXT value chunking are not part of the initial writing-provider kest scenarios. TXT quote, escape, and 255-octet chunking details are covered by envtest and provider implementation unit tests. The external provider's representative success path explicitly includes mismatch→match→drift because verification without DNS writes is its public contract; exhaustive DNS failures remain unit/envtest coverage. ## kest Implementation Rules @@ -165,7 +173,7 @@ Local Cloudflare kest reads `CF_ACCOUNT_ID` and `CF_API_TOKEN` from the environm Route 53 `INSYNC` checks wait up to 5 minutes. Short retry intervals are handled by kest action options; fixed sleeps are not used. -kest uses `ZoneClass.spec.parameters.zoneDeletionPolicy=Delete`. Hosted zones created by kest receive standard ownership tags plus `appthrust.io/test-scope=kest` and `appthrust.io/test-id=` from `ZoneClass.spec.parameters.tags`. The controller has no kest-specific behavior; it only uses normal tag support. +Writing-provider kest uses `ZoneClass.spec.parameters.zoneDeletionPolicy=Delete`. Route 53 hosted zones created by kest receive standard ownership tags plus `appthrust.io/test-scope=kest` and `appthrust.io/test-id=` from `ZoneClass.spec.parameters.tags`. The controller has no kest-specific behavior; it only uses normal tag support. External kest has no zone deletion policy or provider-owned DNS resources: the harness owns and cleans up its dedicated authoritative fixture, and deleting Kubernetes claims does not delete the fixture's DNS data. Leftover external resources from failures are not covered by the kest scenario itself. If `KEST_PRESERVE_ON_FAILURE=1` stops cleanup or a failure leaves a hosted zone, manual cleanup uses `appthrust.io/test-scope` and `appthrust.io/test-id`. diff --git a/docs/design/ui.md b/docs/design/ui.md index 1221d67..9fb481e 100644 --- a/docs/design/ui.md +++ b/docs/design/ui.md @@ -434,11 +434,13 @@ Delete confirmation shows that provider identities still referenced by `ZoneClas ## Zone Classes -`ZoneClass` is a provider-backed DNS zone capability published as platform policy to application namespaces. The initial UI handles only Public DNS. It does not show private hosted zone or VPC selection. +`ZoneClass` is a provider-backed DNS zone capability published as platform policy to application namespaces. Credentialless providers can expose resolver policy without creating or adopting hosted zones. -Creation starts with selecting a provider identity. Provider identities are grouped by provider and shown as cards. The `ZoneClass` is created in the same namespace as the selected provider identity. +Creation selects a provider identity for versions that declare `identity`, or the Provider itself for credentialless versions. Identity-backed classes share the selected identity's namespace. Credentialless creation skips identity selection, offers a namespace selector, and checks create permission in the selected namespace. -The `ZoneClass` form handles `Name`, `Description`, `provider.name`, `provider.version`, `controllerName`, `identityRef`, provider policy parameters such as `zoneCreationPolicy`, `zoneDeletionPolicy`, `sameNameZonePolicy`, and `allowedZones`. `provider` and `identityRef.name` are derived from the selected provider identity and Provider version identity resource declaration. `provider` is shown as read-only provider name and version fields. `controllerName` is selected from the provider controller instance for the selected provider and is required. `identityRef` is shown as the selected provider identity, not as a raw group / kind / namespace editor. Hosted zone type is not selectable. +The `ZoneClass` form handles `Name`, `Description`, `provider.name`, `provider.version`, `controllerName`, optional `identityRef`, provider policy parameters, and `allowedZones`. Provider and controller binding remain read-only after selection. The selected Provider version determines whether the form shows identity information and includes `identityRef` in the preview/submitted manifest; credentialless versions omit it, even if an old identity name remains in form state. Route 53 and Cloudflare retain their identity selection and policy controls. + +Credentialless provider parameters use the existing JSON-object editor fallback. For External DNS this exposes `resolver.mode`, `resolver.servers`, and `expectedNameservers`; `{}` uses the Provider's omitted-field defaults. The form neither shows nor injects writing-provider creation/deletion policy or tags in this flow. Admission remains authoritative for the selected Provider's schema and CEL. `sameNameZonePolicy` is shown only when `zoneCreationPolicy=Create`. The UI label is `Duplicate hosted zone policy`. When `zoneCreationPolicy=Deny`, this policy is hidden. @@ -450,7 +452,7 @@ Route 53 `ZoneClass.spec.parameters.tags` is edited as a YAML mapping of `key: v `ZoneClass` detail follows the shared resource detail pattern. The header actions are `Edit` and the resource actions menu. `Delete ZoneClass` is inside the resource actions menu and opens the delete confirmation screen; it is not a standalone header button. -The `ZoneClass` detail vitals strip shows `Status`, `Used by`, `Namespace`, `Name`, `Provider`, and `Provider identity`. `Status` is derived from `ZoneClass` conditions. `Used by` is the count of `Zone` resources referencing the `ZoneClass`. `Provider identity` links to the same-namespace provider identity detail page when the referenced identity is visible; otherwise it shows the known identity name as plain text. The vitals strip is the canonical place for these fields, so the body does not repeat `Provider` and `Provider Identity` in a separate basic information grid. +The `ZoneClass` detail vitals strip shows `Status`, `Used by`, `Namespace`, `Name`, and `Provider`, plus `Provider identity` only when the selected Provider version declares `identity`. Credentialless classes omit the identity health row. `Status` is derived from `ZoneClass` conditions. `Used by` is the count of referencing `Zone` resources. When present, `Provider identity` links to the same-namespace identity detail page when visible; otherwise it shows the known name as plain text. `ZoneClass` detail tabs are ordered `Zones`, `Status`, `Policy`, and `Manifest`. diff --git a/go.mod b/go.mod index cf873d2..cb72b87 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,9 @@ require ( github.com/aws/smithy-go v1.25.1 github.com/google/cel-go v0.27.0 github.com/kubernetes-sigs/kro v0.9.2 + github.com/miekg/dns v1.1.68 + github.com/prometheus/client_golang v1.23.2 + golang.org/x/time v0.14.0 k8s.io/api v0.35.0 k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apimachinery v0.35.0 @@ -64,7 +67,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect @@ -78,13 +80,14 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect diff --git a/go.sum b/go.sum index 5945ad8..2ee97df 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go b/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go index 697cae6..20791f6 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_controller_test.go @@ -610,7 +610,7 @@ func route53ZoneClassForZoneUnitTest() *dnsv1alpha1.ZoneClass { Spec: dnsv1alpha1.ZoneClassSpec{ Provider: dnsv1alpha1.ProviderReference{Name: "route53.dns.appthrust.io", Version: "v1alpha1"}, ControllerName: "route53.dns.appthrust.io/controller", - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, + IdentityRef: &dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromAll}, }, diff --git a/internal/go/core/controllers/zoneunit/zoneunit_status.go b/internal/go/core/controllers/zoneunit/zoneunit_status.go index f20e0e8..8e74113 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_status.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_status.go @@ -15,7 +15,7 @@ func (r *ZoneUnitCompositionReconciler) projectZoneUnitStatus(ctx context.Contex if unit.Status.Zone != nil { accepted := projectedAccepted(unit.Spec.Zone.ObservedGeneration, zone.Status.Conditions, unit.Status.Zone.Conditions) programmed := projectedProgrammed(unit.Spec.Zone.ObservedGeneration, accepted.Status, unit.Status.Zone.Conditions) - if err := r.setZoneClaimStatus(ctx, zone, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, unit.Status.Zone); err != nil { + if err := r.setZoneClaimStatusWithMessages(ctx, zone, accepted.Status, accepted.Reason, accepted.Message, programmed.Status, programmed.Reason, programmed.Message, unit.Status.Zone); err != nil { return err } } @@ -60,7 +60,7 @@ func (r *ZoneUnitCompositionReconciler) projectZoneUnitStatus(ctx context.Contex } accepted := projectedAccepted(desiredItem.ObservedGeneration, recordSet.Status.Conditions, providerConditions) programmed := projectedProgrammed(desiredItem.ObservedGeneration, accepted.Status, providerConditions) - if err := r.setRecordSetClaimStatusWithZone(ctx, recordSet, accepted.Status, accepted.Reason, programmed.Status, programmed.Reason, recordSetZoneStatus(zone, zoneClass), &providerStatus); err != nil { + if err := r.setRecordSetClaimStatusWithZoneAndMessages(ctx, recordSet, accepted.Status, accepted.Reason, accepted.Message, programmed.Status, programmed.Reason, programmed.Message, recordSetZoneStatus(zone, zoneClass), &providerStatus); err != nil { return err } } @@ -76,31 +76,32 @@ func retainedNotAllowedRecordSetItem(item dnsv1alpha1.ZoneUnitRecordSetSpec) boo } type projectedCondition struct { - Status metav1.ConditionStatus - Reason string + Status metav1.ConditionStatus + Reason string + Message string } func projectedAccepted(observedGeneration int64, compositionConditions, providerConditions []metav1.Condition) projectedCondition { provider := conditionAtGeneration(providerConditions, string(dnsv1alpha1.ConditionAccepted), observedGeneration) if provider != nil { - return projectedCondition{Status: provider.Status, Reason: provider.Reason} + return projectedCondition{Status: provider.Status, Reason: provider.Reason, Message: provider.Message} } composition := conditionAtGeneration(compositionConditions, string(dnsv1alpha1.ConditionAccepted), observedGeneration) if composition != nil && composition.Status != metav1.ConditionTrue { - return projectedCondition{Status: composition.Status, Reason: composition.Reason} + return projectedCondition{Status: composition.Status, Reason: composition.Reason, Message: composition.Message} } - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "OwnerStateNotResolved"} + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "OwnerStateNotResolved", Message: "OwnerStateNotResolved"} } func projectedProgrammed(observedGeneration int64, accepted metav1.ConditionStatus, providerConditions []metav1.Condition) projectedCondition { if accepted != metav1.ConditionTrue { - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "Reconciling"} } provider := conditionAtGeneration(providerConditions, string(dnsv1alpha1.ConditionProgrammed), observedGeneration) if provider == nil { - return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling"} + return projectedCondition{Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "Reconciling"} } - return projectedCondition{Status: provider.Status, Reason: provider.Reason} + return projectedCondition{Status: provider.Status, Reason: provider.Reason, Message: provider.Message} } func conditionAtGeneration(conditions []metav1.Condition, conditionType string, observedGeneration int64) *metav1.Condition { diff --git a/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go b/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go index 3a152a3..c7d0504 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_status_freshness_test.go @@ -40,8 +40,8 @@ func TestZoneUnitCompositionReconcilerDoesNotRebadgeStaleSameUIDProviderStatus(t ObservedGeneration: 1, Zone: &dnsv1alpha1.ZoneUnitZoneStatus{ Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "stale acceptance", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "stale programming", ObservedGeneration: 1}, }, }, RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ @@ -51,8 +51,8 @@ func TestZoneUnitCompositionReconcilerDoesNotRebadgeStaleSameUIDProviderStatus(t ObservedGeneration: 1, Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: []byte(`{"recordID":"current"}`)}}, Conditions: []metav1.Condition{ - {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}, - {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionTrue, Reason: "Accepted", Message: "stale acceptance", ObservedGeneration: 1}, + {Type: string(dnsv1alpha1.ConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "stale programming", ObservedGeneration: 1}, }, }}, }, @@ -86,6 +86,13 @@ func TestZoneUnitCompositionReconcilerDoesNotRebadgeStaleSameUIDProviderStatus(t } assertConditionAtGeneration(t, staleZoneProjection.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionUnknown, "OwnerStateNotResolved", zone.Generation) assertConditionAtGeneration(t, staleZoneProjection.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionUnknown, "Reconciling", zone.Generation) + for _, conditions := range [][]metav1.Condition{staleProjection.Status.Conditions, staleZoneProjection.Status.Conditions} { + for _, condition := range conditions { + if condition.Message != condition.Reason { + t.Fatalf("stale provider diagnostic leaked into current condition: %#v", condition) + } + } + } var currentUnit dnsv1alpha1.ZoneUnit if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(unit), ¤tUnit); err != nil { @@ -156,6 +163,98 @@ func TestZoneUnitCompositionReconcilerDoesNotRebadgeStaleSameUIDProviderStatus(t assertRawEqual(t, recovered.Status.Provider.Data, map[string]any{"recordID": "current"}) } +func TestZoneUnitProjectionPreservesCurrentProviderMessages(t *testing.T) { + for _, test := range []struct { + name string + accepted metav1.ConditionStatus + programmed metav1.ConditionStatus + message string + }{ + {name: "verified", accepted: metav1.ConditionTrue, programmed: metav1.ConditionTrue, message: "DNS matches every required view; no DNS changes were made."}, + {name: "mismatch", accepted: metav1.ConditionTrue, programmed: metav1.ConditionFalse, message: "DNS targets differ: unexpected AAAA."}, + {name: "unavailable", accepted: metav1.ConditionTrue, programmed: metav1.ConditionUnknown, message: "DNS verification unavailable: timeout."}, + {name: "empty", accepted: metav1.ConditionTrue, programmed: metav1.ConditionTrue}, + {name: "rejected", accepted: metav1.ConditionFalse, programmed: metav1.ConditionTrue, message: "Provider identity is not usable."}, + {name: "unresolved", accepted: metav1.ConditionUnknown, programmed: metav1.ConditionTrue, message: "Provider identity is not resolved."}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + zone := zone("app", "example-com", "example.com") + zone.Generation = 2 + recordSet := aRecordSet("app", "www", "app", "example-com", "www") + recordSet.Generation = 2 + recordSet.UID = types.UID("current") + oldConditions := []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", Message: "old acceptance", ObservedGeneration: 2}, + {Type: "Programmed", Status: metav1.ConditionTrue, Reason: "Programmed", Message: "old success", ObservedGeneration: 2}, + } + zone.Status.Conditions = append([]metav1.Condition(nil), oldConditions...) + recordSet.Status.Conditions = append([]metav1.Condition(nil), oldConditions...) + acceptedReason := "Accepted" + if test.accepted == metav1.ConditionFalse { + acceptedReason = "InvalidIdentityRef" + } else if test.accepted == metav1.ConditionUnknown { + acceptedReason = "IdentityNotResolved" + } + programmedReason := "Programmed" + if test.programmed == metav1.ConditionFalse { + programmedReason = "ExternalResourceMismatch" + } else if test.programmed == metav1.ConditionUnknown { + programmedReason = "ProviderUnavailable" + } + conditions := []metav1.Condition{ + {Type: "Accepted", Status: test.accepted, Reason: acceptedReason, Message: test.message, ObservedGeneration: 2}, + {Type: "Programmed", Status: test.programmed, Reason: programmedReason, Message: test.message, ObservedGeneration: 2}, + } + unit := &dnsv1alpha1.ZoneUnit{ + Spec: dnsv1alpha1.ZoneUnitSpec{ + Zone: dnsv1alpha1.ZoneUnitZoneSpec{ObservedGeneration: 2}, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{zoneUnitRecordSetItem(recordSet, providercontract.Payload{})}, + }, + Status: dnsv1alpha1.ZoneUnitStatus{ + Zone: &dnsv1alpha1.ZoneUnitZoneStatus{Conditions: conditions}, + RecordSets: []dnsv1alpha1.ZoneUnitRecordSetStatus{{ + RecordSetNamespace: recordSet.Namespace, + RecordSetName: recordSet.Name, + RecordSetUID: recordSet.UID, + ObservedGeneration: 2, + Conditions: conditions, + }}, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(zone, recordSet). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}). + Build() + reconciler := &ZoneUnitCompositionReconciler{Client: k8sClient} + if err := reconciler.projectZoneUnitStatus(ctx, zone, nil, []dnsv1alpha1.RecordSet{*recordSet}, unit); err != nil { + t.Fatalf("projectZoneUnitStatus: %v", err) + } + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zone), zone); err != nil { + t.Fatalf("get Zone: %v", err) + } + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), recordSet); err != nil { + t.Fatalf("get RecordSet: %v", err) + } + for _, projected := range [][]metav1.Condition{zone.Status.Conditions, recordSet.Status.Conditions} { + accepted := assertCondition(t, projected, "Accepted", test.accepted, acceptedReason) + if accepted.Message != test.message { + t.Fatalf("Accepted message = %q, want %q", accepted.Message, test.message) + } + programmedStatus, reason, message := test.programmed, programmedReason, test.message + if test.accepted != metav1.ConditionTrue { + programmedStatus, reason, message = metav1.ConditionUnknown, "Reconciling", "Reconciling" + } + programmed := assertCondition(t, projected, "Programmed", programmedStatus, reason) + if programmed.Message != message { + t.Fatalf("Programmed message = %q, want %q", programmed.Message, message) + } + } + }) + } +} + func assertConditionAtGeneration(t *testing.T, conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string, generation int64) { t.Helper() condition := assertCondition(t, conditions, conditionType, status, reason) diff --git a/internal/go/core/webhook/validator.go b/internal/go/core/webhook/validator.go index 2beae9e..0fea100 100644 --- a/internal/go/core/webhook/validator.go +++ b/internal/go/core/webhook/validator.go @@ -337,13 +337,15 @@ func (v *CoreValidator) validateZoneClass(ctx context.Context, zoneClass *dnsv1a if err != nil { return err } - if strings.TrimSpace(zoneClass.Spec.IdentityRef.Name) == "" { - return errors.New("spec.identityRef.name is required") - } if providerVersion.Identity != nil { + if zoneClass.Spec.IdentityRef == nil || strings.TrimSpace(zoneClass.Spec.IdentityRef.Name) == "" { + return errors.New("spec.identityRef.name is required") + } if err := validateProviderIdentityResource(providerVersion.Identity.Resource, "Provider.identity.resource"); err != nil { return err } + } else if zoneClass.Spec.IdentityRef != nil { + return errors.New("spec.identityRef is forbidden when the Provider version does not declare identity") } if err := validateNamespacePolicy(zoneClass.Spec.AllowedZones.Namespaces, "spec.allowedZones.namespaces"); err != nil { return err diff --git a/internal/go/core/webhook/validator_test.go b/internal/go/core/webhook/validator_test.go index 4f2ebe5..a1336b0 100644 --- a/internal/go/core/webhook/validator_test.go +++ b/internal/go/core/webhook/validator_test.go @@ -1437,6 +1437,47 @@ func TestCloudflareProviderIDSchemaValidation(t *testing.T) { }) } +func TestZoneClassValidationRequiresIdentityOnlyWhenDeclared(t *testing.T) { + for _, test := range []struct { + name string + hasIdentity bool + identityRef *dnsv1alpha1.LocalObjectReference + wantErr string + }{ + {name: "route53 missing", hasIdentity: true, wantErr: "spec.identityRef.name is required"}, + {name: "route53 empty", hasIdentity: true, identityRef: &dnsv1alpha1.LocalObjectReference{}, wantErr: "spec.identityRef.name is required"}, + {name: "route53 blank", hasIdentity: true, identityRef: &dnsv1alpha1.LocalObjectReference{Name: " "}, wantErr: "spec.identityRef.name is required"}, + {name: "route53 present", hasIdentity: true, identityRef: &dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}}, + {name: "credentialless absent"}, + {name: "credentialless empty", identityRef: &dnsv1alpha1.LocalObjectReference{}, wantErr: "spec.identityRef is forbidden when the Provider version does not declare identity"}, + {name: "credentialless present", identityRef: &dnsv1alpha1.LocalObjectReference{Name: "sentinel"}, wantErr: "spec.identityRef is forbidden when the Provider version does not declare identity"}, + } { + t.Run(test.name, func(t *testing.T) { + provider := route53Provider(t) + zoneClass := route53ZoneClass(t) + if !test.hasIdentity { + // Select a credentialless served version beside the identity-backed version. + version := *provider.Spec.Versions[0].DeepCopy() + version.Name = "v1credentialless" + version.Storage = false + version.Identity = nil + provider.Spec.Versions = append(provider.Spec.Versions, version) + zoneClass.Spec.Provider.Version = version.Name + } + zoneClass.Spec.IdentityRef = test.identityRef + validator := newTestValidator(t, provider) + err := validator.validateZoneClass(context.Background(), zoneClass) + if test.wantErr == "" { + if err != nil { + t.Fatalf("validateZoneClass returned error: %v", err) + } + } else if err == nil || err.Error() != test.wantErr { + t.Fatalf("validateZoneClass error = %v, want %q", err, test.wantErr) + } + }) + } +} + func TestZoneClassValidationAppliesExtensionCELRules(t *testing.T) { ctx := context.Background() extension := route53Provider(t) @@ -1791,8 +1832,8 @@ func TestProviderValidationChecksSchemaDescriptions(t *testing.T) { } func TestBuiltInProviderManifestsPassValidation(t *testing.T) { - for _, name := range []string{"route53_extensions.yaml", "cloudflare_extensions.yaml"} { - t.Run(name, func(t *testing.T) { + for _, name := range []string{"route53_extensions.yaml", "cloudflare_extensions.yaml", filepath.Join("..", "external", "external_extensions.yaml")} { + t.Run(filepath.Base(name), func(t *testing.T) { provider := builtInProviderManifest(t, name) if err := validateProvider(&provider); err != nil { t.Fatalf("validateProvider(%s) returned error: %v", name, err) @@ -2136,7 +2177,7 @@ func route53ZoneClass(t *testing.T) *dnsv1alpha1.ZoneClass { Spec: dnsv1alpha1.ZoneClassSpec{ Provider: route53v1alpha1.ProviderRef, ControllerName: "route53.dns.appthrust.io/controller", - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, + IdentityRef: &dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, Parameters: raw(t, map[string]any{}), AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromSame}, diff --git a/internal/go/providers/cloudflare/controllers/zoneclass/controller.go b/internal/go/providers/cloudflare/controllers/zoneclass/controller.go index 2b76f74..28e5aa3 100644 --- a/internal/go/providers/cloudflare/controllers/zoneclass/controller.go +++ b/internal/go/providers/cloudflare/controllers/zoneclass/controller.go @@ -80,7 +80,7 @@ func (r *ZoneClassReconciler) acceptZoneClass(ctx context.Context, zoneClass *dn if zoneDeletionPolicy(params) != cloudflarev1alpha1.ZoneDeletionPolicyDelete && zoneDeletionPolicy(params) != cloudflarev1alpha1.ZoneDeletionPolicyRetain { return metav1.ConditionFalse, "InvalidParameters", "zoneDeletionPolicy must be Delete or Retain", nil } - if zoneClass.Spec.IdentityRef.Name == "" { + if zoneClass.Spec.IdentityRef == nil || zoneClass.Spec.IdentityRef.Name == "" { return metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required", nil } @@ -147,7 +147,7 @@ func (r *ZoneClassReconciler) mapIdentityToZoneClasses(ctx context.Context, obj if err != nil || zoneClass.Spec.ControllerName != r.controllerName() || provider == nil || version == nil || provider.Name != r.providerReference().Name { continue } - if zoneClass.Spec.IdentityRef.Name == identity.Name { + if zoneClass.Spec.IdentityRef != nil && zoneClass.Spec.IdentityRef.Name == identity.Name { requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zoneClass)}) } } diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go b/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go index 71dd6bf..0bdb9a9 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/recordset.go @@ -627,7 +627,7 @@ func cloudflareRecordValueKey(record CloudflareDNSRecord) string { } func (r *recordSetReconciler) identityForZoneClass(ctx context.Context, recordSet *dnsv1alpha1.RecordSet, zoneClass *dnsv1alpha1.ZoneClass) (*cloudflarev1alpha1.CloudflareIdentity, bool, error) { - if zoneClass.Spec.IdentityRef.Name == "" { + if zoneClass.Spec.IdentityRef == nil || zoneClass.Spec.IdentityRef.Name == "" { return nil, false, r.setAccepted(ctx, recordSet, metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required") } var identity cloudflarev1alpha1.CloudflareIdentity diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/zone.go b/internal/go/providers/cloudflare/controllers/zoneunit/zone.go index 6ed3e8a..7dbc4c5 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/zone.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/zone.go @@ -249,7 +249,7 @@ func (r *ZoneReconciler) acceptZone(ctx context.Context, zone *dnsv1alpha1.Zone) } func (r *ZoneReconciler) acceptIdentityRef(ctx context.Context, zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass) (*cloudflarev1alpha1.CloudflareIdentity, bool, error) { - if zoneClass.Spec.IdentityRef.Name == "" { + if zoneClass.Spec.IdentityRef == nil || zoneClass.Spec.IdentityRef.Name == "" { return nil, false, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required") } var identity cloudflarev1alpha1.CloudflareIdentity @@ -849,7 +849,7 @@ func (r *ZoneReconciler) mapIdentityToZoneUnits(ctx context.Context, obj client. if err != nil || zoneClass.Spec.ControllerName != r.controllerName() || !cloudflareProviderVersionMatches(provider, version, r.providerReference()) { continue } - if zoneClass.Spec.IdentityRef.Name == identity.Name { + if zoneClass.Spec.IdentityRef != nil && zoneClass.Spec.IdentityRef.Name == identity.Name { requests = appendUniqueRequests(requests, r.zoneUnitRequestsForZoneClass(ctx, zoneClass.Namespace, zoneClass.Name)...) } } diff --git a/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go b/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go index 2b2dd5b..6963897 100644 --- a/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go +++ b/internal/go/providers/cloudflare/controllers/zoneunit/zone_test.go @@ -39,6 +39,29 @@ func TestCloudflareZoneClassReconcilerAcceptsRootIdentityRef(t *testing.T) { assertCloudflareCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") } +func TestCloudflareZoneClassReconcilerRejectsMissingIdentityRef(t *testing.T) { + ctx := context.Background() + zoneClass := cloudflareZoneClass("platform", "cloudflare-public") + zoneClass.Spec.IdentityRef = nil + k8sClient := fake.NewClientBuilder(). + WithScheme(cloudflareTestScheme(t)). + WithObjects(cloudflareProvider(), zoneClass). + WithStatusSubresource(&dnsv1alpha1.ZoneClass{}). + Build() + reconciler := &zoneclass.ZoneClassReconciler{Client: k8sClient} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zoneClass)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + var got dnsv1alpha1.ZoneClass + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zoneClass), &got); err != nil { + t.Fatalf("get ZoneClass: %v", err) + } + assertCloudflareCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "InvalidIdentityRef") + if message := got.Status.Conditions[0].Message; message != "spec.identityRef.name is required" { + t.Fatalf("Accepted message = %q", message) + } +} + func TestCloudflareZoneReconcilerIgnoresRoute53ZoneUnit(t *testing.T) { ctx := context.Background() unit := cloudflareZoneUnit("app", "apps-example-com") @@ -516,7 +539,7 @@ func cloudflareZoneClass(namespace, name string) *dnsv1alpha1.ZoneClass { Spec: dnsv1alpha1.ZoneClassSpec{ Provider: cloudflarev1alpha1.ProviderRef, ControllerName: DefaultControllerName, - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "cloudflare-ci"}, + IdentityRef: &dnsv1alpha1.LocalObjectReference{Name: "cloudflare-ci"}, Parameters: runtime.RawExtension{Raw: raw}, AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromAll}, diff --git a/internal/go/providers/external/config/config.go b/internal/go/providers/external/config/config.go new file mode 100644 index 0000000..531a98e --- /dev/null +++ b/internal/go/providers/external/config/config.go @@ -0,0 +1,105 @@ +// Package config implements static external-provider configuration, without DNS I/O. +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "net/netip" + "regexp" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" +) + +var namePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) + +func ValidName(name string) bool { + _, ipErr := netip.ParseAddr(name) + return len(name) > 0 && len(name) <= 253 && namePattern.MatchString(name) && ipErr != nil +} + +func Binding(name, version string) dnsv1alpha1.ProviderReference { + if name == "" { + name = externalv1alpha1.ProviderName + } + if version == "" { + version = externalv1alpha1.ProviderVersion + } + return dnsv1alpha1.ProviderReference{Name: name, Version: version} +} + +func ControllerName(name string) string { + if name == "" { + return externalv1alpha1.ControllerName + } + return name +} + +// HasIdentity never dereferences IdentityRef. Only the optional reference's nil +// value is absent; a pre-cutover value reference or an empty object is present. +func HasIdentity(class *dnsv1alpha1.ZoneClass) bool { + return any(class.Spec.IdentityRef) != (*dnsv1alpha1.LocalObjectReference)(nil) +} + +func Parameters(provider *dnsv1alpha1.Provider, version *dnsv1alpha1.ProviderVersion, class *dnsv1alpha1.ZoneClass) (externalv1alpha1.ExternalZoneClassParameters, error) { + raw, err := providercontract.ConvertZoneClassParametersToStorage(provider, version, class) + if err != nil { + return externalv1alpha1.ExternalZoneClassParameters{}, err + } + return Decode(raw) +} + +func Decode(raw runtime.RawExtension) (externalv1alpha1.ExternalZoneClassParameters, error) { + var params externalv1alpha1.ExternalZoneClassParameters + if len(raw.Raw) != 0 { + decoder := json.NewDecoder(bytes.NewReader(raw.Raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(¶ms); err != nil { + return params, fmt.Errorf("invalid external parameters: %w", err) + } + } + if params.Resolver.Mode == "" { + params.Resolver.Mode = externalv1alpha1.ResolverModeCluster + } + switch params.Resolver.Mode { + case externalv1alpha1.ResolverModeCluster: + if len(params.Resolver.Servers) != 0 { + return params, fmt.Errorf("resolver.servers must be empty for Cluster") + } + case externalv1alpha1.ResolverModeRecursive: + if err := ValidateServers(params.Resolver.Servers); err != nil { + return params, err + } + default: + return params, fmt.Errorf("resolver.mode must be Cluster or Recursive") + } + if len(params.ExpectedNameservers) > 16 { + return params, fmt.Errorf("expectedNameservers must contain at most 16 names") + } + for i, name := range params.ExpectedNameservers { + if !ValidName(name) || slices.Contains(params.ExpectedNameservers[:i], name) { + return params, fmt.Errorf("expectedNameservers must contain distinct canonical DNS names") + } + } + slices.Sort(params.Resolver.Servers) + slices.Sort(params.ExpectedNameservers) + return params, nil +} + +func ValidateServers(servers []string) error { + if len(servers) == 0 || len(servers) > 3 { + return fmt.Errorf("resolver.servers must contain 1 through 3 endpoints") + } + for i, server := range servers { + address, err := netip.ParseAddrPort(server) + if err != nil || address.Port() == 0 || address.Addr().Zone() != "" || address.String() != server || strings.Contains(server, "%") || slices.Contains(servers[:i], server) { + return fmt.Errorf("resolver.servers must contain distinct canonical IP addresses and ports from 1 through 65535") + } + } + return nil +} diff --git a/internal/go/providers/external/controllers/zoneclass/controller.go b/internal/go/providers/external/controllers/zoneclass/controller.go new file mode 100644 index 0000000..a4f1858 --- /dev/null +++ b/internal/go/providers/external/controllers/zoneclass/controller.go @@ -0,0 +1,111 @@ +package zoneclass + +import ( + "context" + "errors" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + "github.com/appthrust/dns-api/internal/go/providers/external/config" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +type ZoneClassReconciler struct { + client.Client + // Reader provides real pagination for Provider dependency fan-out. + Reader client.Reader + ControllerName string + ProviderName string + ProviderVersion string +} + +func (r *ZoneClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var class dnsv1alpha1.ZoneClass + if err := r.Get(ctx, req.NamespacedName, &class); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if class.Spec.ControllerName != config.ControllerName(r.ControllerName) || !class.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + var provider dnsv1alpha1.Provider + if err := r.Get(ctx, client.ObjectKey{Name: class.Spec.Provider.Name}, &provider); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + status, reason, message := r.accept(&class, &provider) + base := class.DeepCopy() + meta.SetStatusCondition(&class.Status.Conditions, metav1.Condition{Type: "Accepted", Status: status, Reason: reason, Message: message, ObservedGeneration: class.Generation}) + if equality.Semantic.DeepEqual(base.Status, class.Status) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, r.Status().Patch(ctx, &class, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + +func (r *ZoneClassReconciler) accept(class *dnsv1alpha1.ZoneClass, provider *dnsv1alpha1.Provider) (metav1.ConditionStatus, string, string) { + binding := config.Binding(r.ProviderName, r.ProviderVersion) + version, served := providercontract.ServedVersion(provider, class.Spec.Provider.Version) + if provider.Name != binding.Name || !served { + return metav1.ConditionFalse, "InvalidProvider", "ZoneClass provider is not handled by external controller" + } + storage, resolved := providercontract.StorageVersion(provider) + if !resolved { + return metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved" + } + if storage.Name != binding.Version || version.Identity != nil || storage.Identity != nil { + return metav1.ConditionFalse, "InvalidProvider", "external provider requires its configured credentialless storage version" + } + if config.HasIdentity(class) { + return metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef must be omitted for external DNS" + } + if _, err := config.Parameters(provider, version, class); err != nil { + var payloadErr *providercontract.PayloadError + if errors.As(err, &payloadErr) && payloadErr.Kind == providercontract.ProviderContractNotReady { + return metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved" + } + return metav1.ConditionFalse, "InvalidParameters", err.Error() + } + return metav1.ConditionTrue, "Accepted", "ZoneClass is accepted by external controller" +} + +func (r *ZoneClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Client == nil { + r.Client = mgr.GetClient() + } + r.Reader = mgr.GetAPIReader() + return ctrl.NewControllerManagedBy(mgr).Named("external-zoneclass"). + For(&dnsv1alpha1.ZoneClass{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProvider), builder.WithPredicates(predicate.GenerationChangedPredicate{})).Complete(r) +} + +func (r *ZoneClassReconciler) mapProvider(ctx context.Context, obj client.Object) []reconcile.Request { + reader := r.Reader + if reader == nil { + reader = r.Client + } + var requests []reconcile.Request + continuation := "" + for { + var classes dnsv1alpha1.ZoneClassList + if err := reader.List(ctx, &classes, client.Limit(256), client.Continue(continuation)); err != nil { + return nil + } + for i := range classes.Items { + class := &classes.Items[i] + if class.Spec.Provider.Name == obj.GetName() && class.Spec.ControllerName == config.ControllerName(r.ControllerName) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(class)}) + } + } + continuation = classes.Continue + if continuation == "" { + return requests + } + } +} diff --git a/internal/go/providers/external/controllers/zoneclass/controller_test.go b/internal/go/providers/external/controllers/zoneclass/controller_test.go new file mode 100644 index 0000000..602763b --- /dev/null +++ b/internal/go/providers/external/controllers/zoneclass/controller_test.go @@ -0,0 +1,98 @@ +package zoneclass + +import ( + "context" + "encoding/json" + "testing" + + "github.com/appthrust/dns-api/internal/go/providers/external/manifest" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func classFixture() *dnsv1alpha1.ZoneClass { + return &dnsv1alpha1.ZoneClass{ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "external", Generation: 1}, Spec: dnsv1alpha1.ZoneClassSpec{Provider: dnsv1alpha1.ProviderReference{Name: externalv1alpha1.ProviderName, Version: externalv1alpha1.ProviderVersion}, ControllerName: externalv1alpha1.ControllerName, Parameters: runtime.RawExtension{Raw: []byte(`{}`)}}} +} + +func TestClassAcceptanceAndIdentityRejection(t *testing.T) { + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + r := &ZoneClassReconciler{} + class := classFixture() + status, reason, message := r.accept(class, provider) + if status != metav1.ConditionTrue || reason != "Accepted" { + t.Fatalf("accept=%s/%s %s (requires optional identityRef core cutover)", status, reason, message) + } + for _, identity := range []string{`{}`, `{"name":"forbidden"}`} { + // JSON keeps this test source compatible with both sides of the optional-pointer cutover. + if err := json.Unmarshal([]byte(`{"spec":{"identityRef":`+identity+`}}`), class); err != nil { + t.Fatal(err) + } + status, reason, _ = r.accept(class, provider) + if status != metav1.ConditionFalse || reason != "InvalidIdentityRef" { + t.Fatalf("identity=%s accept=%s/%s", identity, status, reason) + } + } + class = classFixture() + class.Spec.Parameters.Raw = []byte(`{"resolver":{"mode":"Recursive"}}`) + if status, reason, _ := r.accept(class, provider); status != metav1.ConditionFalse || reason != "InvalidParameters" { + t.Fatalf("accept=%s/%s", status, reason) + } + provider.Spec.Versions[0].Storage = false + if status, reason, _ := r.accept(class, provider); status != metav1.ConditionUnknown || reason != "ProviderStorageVersionNotResolved" { + t.Fatalf("accept=%s/%s", status, reason) + } +} + +func TestClassScopeAndUnchangedStatus(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + class := classFixture() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider, class).WithStatusSubresource(&dnsv1alpha1.ZoneClass{}).Build() + r := &ZoneClassReconciler{Client: c} + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(class)} + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + condition := meta.FindStatusCondition(class.Status.Conditions, "Accepted") + if condition == nil || condition.Status != metav1.ConditionTrue || condition.ObservedGeneration != 1 { + t.Fatalf("condition=%#v", condition) + } + version := class.ResourceVersion + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + if class.ResourceVersion != version { + t.Fatal("unchanged class status was patched") + } + r.ControllerName = "other.example/controller" + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + if class.ResourceVersion != version { + t.Fatal("foreign controller changed class") + } +} diff --git a/internal/go/providers/external/controllers/zoneunit/controller.go b/internal/go/providers/external/controllers/zoneunit/controller.go new file mode 100644 index 0000000..2672fee --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/controller.go @@ -0,0 +1,391 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "errors" + "math/rand/v2" + "slices" + "time" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + "github.com/appthrust/dns-api/internal/go/core/providercontract" + "github.com/appthrust/dns-api/internal/go/providers/external/config" + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Options struct { + ControllerName string + ProviderName string + ProviderVersion string + QPS float64 + Burst int + Resolver probe.Resolver + Clock func() time.Time + // Jitter returns a value in [-1,1], scaled to +/-10 percent. + Jitter func() float64 + // EffectivePolicy resolves pod DNS without using the OS DNS lookup fallback. + EffectivePolicy func(externalv1alpha1.ExternalZoneClassParameters) (externalv1alpha1.ExternalZoneClassParameters, error) +} + +type ZoneReconciler struct { + client.Client + Options Options + // Reader is a live read boundary for rejecting results whose inputs changed during DNS I/O. + Reader client.Reader +} + +func NewReconciler(k8sClient client.Client, options Options) (*ZoneReconciler, error) { + if options.Resolver == nil { + wire, err := probe.NewWireResolver(probe.WireOptions{QPS: options.QPS, Burst: options.Burst}) + if err != nil { + return nil, err + } + options.Resolver = wire + if options.EffectivePolicy == nil { + options.EffectivePolicy = wire.EffectivePolicy + } + } + if options.Clock == nil { + options.Clock = time.Now + } + if options.Jitter == nil { + options.Jitter = func() float64 { return rand.Float64()*2 - 1 } + } + return &ZoneReconciler{Client: k8sClient, Reader: k8sClient, Options: options}, nil +} + +type environment struct { + class dnsv1alpha1.ZoneClass + provider dnsv1alpha1.Provider + policy externalv1alpha1.ExternalZoneClassParameters + fingerprint string + policyError error +} + +type acceptance struct { + Status metav1.ConditionStatus + Reason, Message string +} + +func (r *ZoneReconciler) observe(ctx context.Context, c client.Client, unit *dnsv1alpha1.ZoneUnit) (*environment, *acceptance, error) { + env := &environment{} + ref := unit.Spec.Zone.ZoneClassRef + if err := c.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, &env.class); err != nil { + if apierrors.IsNotFound(err) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidZoneClassRef", "referenced ZoneClass was not found"}, nil + } + return nil, nil, err + } + if env.class.Spec.ControllerName != config.ControllerName(r.Options.ControllerName) { + return nil, nil, nil + } + binding := config.Binding(r.Options.ProviderName, r.Options.ProviderVersion) + if unit.Spec.Provider != binding || env.class.Spec.Provider.Name != binding.Name { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "ZoneUnit provider is not handled by external controller"}, nil + } + if err := c.Get(ctx, client.ObjectKey{Name: binding.Name}, &env.provider); err != nil { + if apierrors.IsNotFound(err) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "referenced Provider was not found"}, nil + } + return nil, nil, err + } + version, served := providercontract.ServedVersion(&env.provider, env.class.Spec.Provider.Version) + storage, resolved := providercontract.StorageVersion(&env.provider) + if !resolved { + return nil, &acceptance{metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved"}, nil + } + if !served || storage.Name != binding.Version || version.Identity != nil || storage.Identity != nil { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "external provider requires its configured credentialless storage version"}, nil + } + if config.HasIdentity(&env.class) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef must be omitted for external DNS"}, nil + } + condition := meta.FindStatusCondition(env.class.Status.Conditions, "Accepted") + if !env.class.DeletionTimestamp.IsZero() || condition == nil || condition.Status != metav1.ConditionTrue || condition.ObservedGeneration != env.class.Generation { + return nil, &acceptance{metav1.ConditionFalse, "ZoneClassNotAccepted", "referenced ZoneClass is not accepted"}, nil + } + params, err := config.Parameters(&env.provider, version, &env.class) + if err != nil { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProviderPayload", "external resolver parameters are invalid"}, nil + } + if allowed, err := declaration.ZoneAllowedByClass(ctx, c, &env.class, unit.Spec.Zone.Ref.Namespace); err != nil { + return nil, nil, err + } else if !allowed { + return nil, &acceptance{metav1.ConditionFalse, "NotAllowedByZoneClass", "Zone namespace is not allowed by ZoneClass"}, nil + } + if !emptyObject(unit.Spec.Zone.Adoption) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProviderPayload", "external DNS does not support adoption"}, nil + } + env.policy = params + if r.Options.EffectivePolicy != nil { + env.policy, env.policyError = r.Options.EffectivePolicy(params) + } + namespaceNames := []string{unit.Spec.Zone.Ref.Namespace} + for _, item := range unit.Spec.RecordSets { + namespaceNames = append(namespaceNames, item.RecordSetNamespace) + } + slices.Sort(namespaceNames) + namespaceNames = slices.Compact(namespaceNames) + namespacePolicies := make(map[string]map[string]string, len(namespaceNames)) + for _, name := range namespaceNames { + var namespace corev1.Namespace + if err := c.Get(ctx, client.ObjectKey{Name: name}, &namespace); err != nil && !apierrors.IsNotFound(err) { + return nil, nil, err + } + namespacePolicies[name] = namespace.Labels + } + env.fingerprint = probe.Fingerprint(struct { + ClassUID string + ClassSpec dnsv1alpha1.ZoneClassSpec + ProviderUID string + ProviderSpec dnsv1alpha1.ProviderSpec + UnitUID string + Zone dnsv1alpha1.ZoneUnitZoneSpec + Policy externalv1alpha1.ExternalZoneClassParameters + NamespacePolicies map[string]map[string]string + }{string(env.class.UID), env.class.Spec, string(env.provider.UID), env.provider.Spec, string(unit.UID), unit.Spec.Zone, env.policy, namespacePolicies}) + return env, nil, nil +} + +func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(ctx, request.NamespacedName, &unit); err != nil { + if apierrors.IsNotFound(err) { + probe.SetPending(request.NamespacedName.String(), nil) + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if unit.Spec.Provider.Name != config.Binding(r.Options.ProviderName, r.Options.ProviderVersion).Name { + return ctrl.Result{}, nil + } + env, rejected, err := r.observe(ctx, r.Client, &unit) + if err != nil { + return ctrl.Result{}, err + } + if env == nil && rejected == nil { + return ctrl.Result{}, nil + } + base := unit.DeepCopy() + prepareReceipts(&unit) + if !unit.DeletionTimestamp.IsZero() { + // No provider finalizer is necessary: there is no external object to delete. + for i := range unit.Status.RecordSets { + unit.Status.RecordSets[i].DeletionCompleted = unit.Spec.RecordSets[i].RecordSetUID != "" + } + probe.SetPending(request.NamespacedName.String(), nil) + return ctrl.Result{}, r.patch(ctx, base, &unit) + } + if rejected != nil { + r.reject(&unit, *rejected) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.patch(ctx, base, &unit) + } + now := r.Options.Clock() + state := loadState(&unit, env.fingerprint) + groups := groupNames(&unit, env.fingerprint) + active := make(map[string]bool, len(groups)) + for _, group := range groups { + active[group.Name] = true + currentReceipt := true + for _, index := range group.Items { + item := &unit.Status.RecordSets[index] + condition := meta.FindStatusCondition(item.Conditions, "Programmed") + if condition == nil || condition.ObservedGeneration != item.ObservedGeneration { + currentReceipt = false + } + if meta.FindStatusCondition(item.Conditions, "Accepted") == nil { + setCondition(&item.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "External DNS record intent is accepted.", item.ObservedGeneration, now) + } + } + if !currentReceipt || state.Names[group.Name].Fingerprint != group.Fingerprint { + delete(state.Names, group.Name) + for _, index := range group.Items { + resetProgrammed(&unit.Status.RecordSets[index], now) + } + } + } + prunedSchedule := false + for name := range state.Names { + if !active[name] { + delete(state.Names, name) + prunedSchedule = true + } + } + due := selectDue(groups, state, now) + zoneCondition := meta.FindStatusCondition(unit.Status.Zone.Conditions, "Programmed") + zoneDue := state.Zone.Fingerprint != env.fingerprint || !state.Zone.Next.After(now) || zoneCondition == nil || zoneCondition.ObservedGeneration != unit.Spec.Zone.ObservedGeneration + var evidence *probe.ZoneEvidence + zoneDecision := currentDecision(unit.Status.Zone.Conditions) + zoneProbed := zoneDue || (len(due) != 0 && zoneDecision.Status == metav1.ConditionTrue) + if zoneProbed { + zoneRequest := probe.ProbeRequest{Zone: unit.Spec.Zone.DomainName, Name: unit.Spec.Zone.DomainName, Policy: env.policy, ZoneOnly: true} + switch { + case env.policyError != nil: + zoneDecision = probe.Failed(zoneRequest.Zone, "configured-resolvers", "resolver-config") + case len(env.policy.ExpectedNameservers) == 0: + zoneDecision = probe.EvaluateZone(zoneRequest, nil) + default: + observed, probeErr := r.Options.Resolver.Probe(ctx, zoneRequest) + evidence = observed.Zone + zoneDecision = probe.EvaluateZone(zoneRequest, evidence) + if probeErr != nil { + zoneDecision = probe.Failed(zoneRequest.Zone, "required-views", failureCode(probeErr)) + } + } + completed := r.Options.Clock() + state.Zone = advance(state.Zone, env.fingerprint, zoneDecision, completed, r.Options.Jitter()) + setZoneResult(&unit, env.policy, zoneDecision, completed, state.Zone.Next) + } + invalidGroups := map[string]bool{} + for _, groupIndex := range due { + group := groups[groupIndex] + old := state.Names[group.Name] + dueTime := old.Next + if dueTime.IsZero() { + dueTime = now + } + valid, reason, message := validateGroup(&unit, group) + decision := zoneDecision + if !valid { + invalidGroups[group.Name] = true + for _, index := range group.Items { + setCondition(&unit.Status.RecordSets[index].Conditions, "Accepted", metav1.ConditionFalse, reason, message, unit.Spec.RecordSets[index].ObservedGeneration, now) + resetProgrammed(&unit.Status.RecordSets[index], now) + } + decision = probe.Decision{Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: message} + } else if zoneDecision.Status == metav1.ConditionTrue { + probeRequest := probe.ProbeRequest{Name: group.CheckedName, Zone: unit.Spec.Zone.DomainName, Expected: group.Expected, Policy: env.policy, Evidence: evidence, DueTime: dueTime} + observed, probeErr := r.Options.Resolver.Probe(ctx, probeRequest) + decision = probe.Evaluate(probeRequest, observed) + if probeErr != nil { + decision = probe.Failed(group.CheckedName, "required-views", failureCode(probeErr)) + } + } + completed := r.Options.Clock() + scheduled := advance(old, group.Fingerprint, decision, completed, r.Options.Jitter()) + state.Names[group.Name] = scheduled + if valid { + setGroupResult(&unit, group, env.policy, decision, completed, scheduled.Next) + } + state.Cursor = group.Name + } + if zoneProbed && zoneDecision.Status != metav1.ConditionTrue { + for _, group := range groups { + if invalidGroups[group.Name] { + continue + } + // Withdraw every old success immediately, including names outside this pass. + for _, index := range group.Items { + item := &unit.Status.RecordSets[index] + item.Provider = publicData(env.policy, group.CheckedName, group.SampledWildcard, zoneDecision, r.Options.Clock(), state.Zone.Next) + setCondition(&item.Conditions, "Programmed", zoneDecision.Status, zoneDecision.Reason, zoneDecision.Message, item.ObservedGeneration, r.Options.Clock()) + } + } + } + // DNS I/O is an asynchronous observation boundary. Live inputs must still agree. + reader := readerClient{Client: r.Client, reader: r.Reader} + var current dnsv1alpha1.ZoneUnit + if err := reader.Get(ctx, request.NamespacedName, ¤t); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if current.UID != unit.UID || current.Generation != unit.Generation || !equality.Semantic.DeepEqual(current.Spec, unit.Spec) || !current.DeletionTimestamp.IsZero() { + return ctrl.Result{RequeueAfter: time.Second}, nil + } + fresh, blocked, err := r.observe(ctx, reader, ¤t) + if err != nil { + return ctrl.Result{}, err + } + if blocked != nil || fresh == nil || fresh.fingerprint != env.fingerprint || (fresh.policyError != nil) != (env.policyError != nil) { + return ctrl.Result{RequeueAfter: time.Second}, nil + } + // Preserve the stored RawExtension unless scheduling actually changed. + // Re-encoding unchanged state can reorder API-normalized JSON keys and + // make the status equality guard issue an otherwise empty patch. + if zoneProbed || len(due) != 0 || prunedSchedule { + stateBytes, err := json.Marshal(state) + if err != nil { + return ctrl.Result{}, err + } + unit.Status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: stateBytes}} + } + setAggregate(&unit, r.Options.Clock()) + r.updatePending(&unit) + return ctrl.Result{RequeueAfter: nextRequeue(groups, state, r.Options.Clock())}, r.patch(ctx, base, &unit) +} + +func validateGroup(unit *dnsv1alpha1.ZoneUnit, group nameGroup) (bool, string, string) { + if group.Expected.CNAME != "" && len(group.Expected.A)+len(group.Expected.AAAA) != 0 { + return false, "InvalidProviderPayload", "CNAME must not coexist with address records" + } + for _, index := range group.Items { + item := unit.Spec.RecordSets[index] + if !item.IsAllowed() || item.DeletionRequested || item.RecordSetUID == "" || item.ObservedGeneration <= 0 { + return false, "DeniedByPolicy", "record claim is not allowed or not bound to a current incarnation" + } + if !emptyObject(item.Options) || !emptyObject(item.Adoption) || item.TTL == nil || *item.TTL < 1 { + return false, "InvalidProviderPayload", "external DNS requires a standard TTL and no options or adoption" + } + valid := false + switch item.Type { + case dnsv1alpha1.RecordTypeA: + valid = item.A != nil && len(item.A.Addresses) != 0 && item.AAAA == nil && item.CNAME == nil + case dnsv1alpha1.RecordTypeAAAA: + valid = item.AAAA != nil && len(item.AAAA.Addresses) != 0 && item.A == nil && item.CNAME == nil + case dnsv1alpha1.RecordTypeCNAME: + valid = item.CNAME != nil && item.CNAME.Target != "" && item.Name != "@" && item.A == nil && item.AAAA == nil + } + if !valid || item.TXT != nil || item.MX != nil || item.CAA != nil || item.NS != nil { + return false, "InvalidProviderPayload", "external DNS supports only standard A, AAAA and CNAME bodies" + } + } + return true, "", "" +} + +func emptyObject(raw runtime.RawExtension) bool { + if len(raw.Raw) == 0 { + return true + } + var value map[string]json.RawMessage + return json.Unmarshal(raw.Raw, &value) == nil && len(value) == 0 +} + +func (r *ZoneReconciler) patch(ctx context.Context, base, unit *dnsv1alpha1.ZoneUnit) error { + unit.Status.ObservedGeneration = unit.Generation + if equality.Semantic.DeepEqual(base.Status, unit.Status) { + return nil + } + return r.Status().Patch(ctx, unit, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + +func failureCode(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + switch err.Error() { + case "resolver-config", "invalid-response", "probe-limit", "chain-limit": + return err.Error() + } + return "transport" +} + +type readerClient struct { + client.Client + reader client.Reader +} + +func (c readerClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.reader == nil { + return c.Client.Get(ctx, key, obj, opts...) + } + return c.reader.Get(ctx, key, obj, opts...) +} diff --git a/internal/go/providers/external/controllers/zoneunit/controller_test.go b/internal/go/providers/external/controllers/zoneunit/controller_test.go new file mode 100644 index 0000000..902f845 --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/controller_test.go @@ -0,0 +1,452 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/manifest" + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeResolver struct { + requests []probe.ProbeRequest + answer func(probe.ProbeRequest) (probe.ProbeResult, error) +} + +func (r *fakeResolver) Probe(_ context.Context, request probe.ProbeRequest) (probe.ProbeResult, error) { + r.requests = append(r.requests, request) + return r.answer(request) +} + +func matching(request probe.ProbeRequest) (probe.ProbeResult, error) { + ttl := uint32(120) + view := probe.View{ID: "recursive/192.0.2.53:53", A: request.Expected.A, AAAA: request.Expected.AAAA, TTL: &ttl} + if request.Expected.CNAME != "" { + view.Chain = []string{request.Expected.CNAME} + view.A = []string{"192.0.2.10"} + } + return probe.ProbeResult{Views: []probe.View{view}}, nil +} + +func fixtureUnit(count int) *dnsv1alpha1.ZoneUnit { + unit := &dnsv1alpha1.ZoneUnit{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "example", UID: "unit-1", Generation: 1}, Spec: dnsv1alpha1.ZoneUnitSpec{ + Provider: dnsv1alpha1.ProviderReference{Name: externalv1alpha1.ProviderName, Version: externalv1alpha1.ProviderVersion}, + Zone: dnsv1alpha1.ZoneUnitZoneSpec{Ref: dnsv1alpha1.ObjectReference{Namespace: "app", Name: "example"}, ObservedGeneration: 1, DomainName: "example.test", ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: "app", Name: "external"}}, + }} + ttl := int32(300) + for i := range count { + name := fmt.Sprintf("host-%03d", i) + unit.Spec.RecordSets = append(unit.Spec.RecordSets, dnsv1alpha1.ZoneUnitRecordSetSpec{RecordSetNamespace: "app", RecordSetName: name, RecordSetUID: types.UID(name), ObservedGeneration: 1, Name: name, Type: dnsv1alpha1.RecordTypeA, TTL: &ttl, A: &dnsv1alpha1.ARecordSet{Addresses: []string{"192.0.2.10"}}}) + } + return unit +} + +func fixtureClass() *dnsv1alpha1.ZoneClass { + return &dnsv1alpha1.ZoneClass{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "external", UID: "class-1", Generation: 1}, Spec: dnsv1alpha1.ZoneClassSpec{ + Provider: dnsv1alpha1.ProviderReference{Name: externalv1alpha1.ProviderName, Version: externalv1alpha1.ProviderVersion}, ControllerName: externalv1alpha1.ControllerName, + Parameters: runtime.RawExtension{Raw: []byte(`{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53"]}}`)}, + }, Status: dnsv1alpha1.ZoneClassStatus{Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1, LastTransitionTime: metav1.NewTime(time.Unix(1, 0))}}}} +} + +func newFixture(t *testing.T, count int, resolver *fakeResolver) (*ZoneReconciler, client.Client, *time.Time, ctrl.Request) { + t.Helper() + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + provider.UID = "provider-1" + unit := fixtureUnit(count) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider, fixtureClass(), unit, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app"}}).WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}, &dnsv1alpha1.ZoneClass{}).Build() + now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC) + reconciler, err := NewReconciler(c, Options{Resolver: resolver, Clock: func() time.Time { return now }, Jitter: func() float64 { return 0 }}) + if err != nil { + t.Fatal(err) + } + return reconciler, c, &now, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(unit)} +} + +func reconcileOnce(t *testing.T, r *ZoneReconciler, request ctrl.Request) (ctrl.Result, dnsv1alpha1.ZoneUnit) { + t.Helper() + result, err := r.Reconcile(context.Background(), request) + if err != nil { + t.Fatal(err) + } + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + return result, unit +} + +func assertProgrammed(t *testing.T, conditions []metav1.Condition, status metav1.ConditionStatus, reason string) *metav1.Condition { + t.Helper() + condition := meta.FindStatusCondition(conditions, "Programmed") + if condition == nil || condition.Status != status || condition.Reason != reason { + t.Fatalf("Programmed = %#v, want %s/%s", condition, status, reason) + } + return condition +} + +func TestMismatchRecoveryDriftAndNoStatusLoop(t *testing.T) { + resolver := &fakeResolver{answer: func(request probe.ProbeRequest) (probe.ProbeResult, error) { + result, err := matching(request) + result.Views[0].A = []string{"192.0.2.99"} + return result, err + }} + r, _, now, request := newFixture(t, 1, resolver) + _, first := reconcileOnce(t, r, request) + assertProgrammed(t, first.Status.Zone.Conditions, metav1.ConditionTrue, "VerificationConfigured") + assertProgrammed(t, first.Status.RecordSets[0].Conditions, metav1.ConditionFalse, "ResolutionMismatch") + _, unchanged := reconcileOnce(t, r, request) + if len(resolver.requests) != 1 || unchanged.ResourceVersion != first.ResourceVersion { + t.Fatalf("unchanged status triggered work or a status patch: probes=%d (want 1), resourceVersion=%q -> %q", len(resolver.requests), first.ResourceVersion, unchanged.ResourceVersion) + } + resolver.answer = matching + *now = now.Add(30 * time.Second) + _, ready := reconcileOnce(t, r, request) + condition := assertProgrammed(t, ready.Status.RecordSets[0].Conditions, metav1.ConditionTrue, "ResolutionVerified") + transition := condition.LastTransitionTime + *now = now.Add(120 * time.Second) + _, readyAgain := reconcileOnce(t, r, request) + if !assertProgrammed(t, readyAgain.Status.RecordSets[0].Conditions, metav1.ConditionTrue, "ResolutionVerified").LastTransitionTime.Equal(&transition) { + t.Fatal("success timestamp changed transition time") + } + resolver.answer = func(probe.ProbeRequest) (probe.ProbeResult, error) { + return probe.ProbeResult{}, errors.New("private raw transport details") + } + *now = now.Add(120 * time.Second) + _, failed := reconcileOnce(t, r, request) + assertProgrammed(t, failed.Status.RecordSets[0].Conditions, metav1.ConditionUnknown, "DNSProbeFailed") + assertProgrammed(t, failed.Status.Conditions, metav1.ConditionUnknown, "ProviderUnavailable") + if failed.Status.RecordSets[0].RecordSetUID != "host-000" || failed.Status.RecordSets[0].ObservedGeneration != 1 { + t.Fatal("unbound receipt") + } +} + +func TestPolicyAndSiblingInvalidation(t *testing.T) { + resolver := &fakeResolver{answer: matching} + r, c, _, request := newFixture(t, 1, resolver) + reconcileOnce(t, r, request) + var class dnsv1alpha1.ZoneClass + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "app", Name: "external"}, &class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"resolver":{"mode":"Recursive","servers":["192.0.2.54:53"]}}`) + if err := c.Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + resolver.answer = func(request probe.ProbeRequest) (probe.ProbeResult, error) { + result, err := matching(request) + result.Views[0].ID = "recursive/192.0.2.54:53" + return result, err + } + _, unit := reconcileOnce(t, r, request) + if len(resolver.requests) != 2 { + t.Fatal("policy change retained previous success") + } + family := unit.Spec.RecordSets[0] + family.RecordSetName, family.RecordSetUID, family.Type, family.A, family.AAAA = "v6", "v6-uid", dnsv1alpha1.RecordTypeAAAA, nil, &dnsv1alpha1.AAAARecordSet{Addresses: []string{"2001:db8::1"}} + unit.Spec.RecordSets = append(unit.Spec.RecordSets, family) + unit.Generation++ + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, got := reconcileOnce(t, r, request) + if len(resolver.requests) != 3 || len(resolver.requests[2].Expected.AAAA) != 1 { + t.Fatal("sibling family did not invalidate combined observation") + } + for _, receipt := range got.Status.RecordSets { + assertProgrammed(t, receipt.Conditions, metav1.ConditionTrue, "ResolutionVerified") + } +} + +func TestBoundedFairPassAndDeletion(t *testing.T) { + resolver := &fakeResolver{answer: matching} + r, c, now, request := newFixture(t, 65, resolver) + result, unit := reconcileOnce(t, r, request) + if len(resolver.requests) != 32 || result.RequeueAfter != time.Second { + t.Fatalf("requests=%d result=%#v", len(resolver.requests), result) + } + assertProgrammed(t, unit.Status.RecordSets[64].Conditions, metav1.ConditionUnknown, "Reconciling") + *now = now.Add(time.Second) + reconcileOnce(t, r, request) + *now = now.Add(time.Second) + _, unit = reconcileOnce(t, r, request) + if len(resolver.requests) != 65 { + t.Fatalf("requests=%d, want each name once", len(resolver.requests)) + } + unit.Spec.RecordSets[0].DeletionRequested = true + denied := false + unit.Spec.RecordSets[1].Allowed = &denied + unit.Generation++ + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, got := reconcileOnce(t, r, request) + if !got.Status.RecordSets[0].DeletionCompleted || got.Status.RecordSets[0].RecordSetUID != unit.Spec.RecordSets[0].RecordSetUID { + t.Fatal("deletion receipt missing incarnation") + } + if len(resolver.requests) != 65 { + t.Fatal("denied/deleted names were probed") + } + unit = got + unit.Spec.RecordSets[0].RecordSetUID = "replacement" + unit.Spec.RecordSets[0].DeletionRequested = false + unit.Generation++ + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, got = reconcileOnce(t, r, request) + if got.Status.RecordSets[0].DeletionCompleted || got.Status.RecordSets[0].RecordSetUID != "replacement" { + t.Fatal("old deletion finalized replacement") + } +} + +func TestStaleAsynchronousResultsDiscarded(t *testing.T) { + resolver := &fakeResolver{} + r, c, _, request := newFixture(t, 1, resolver) + resolver.answer = func(input probe.ProbeRequest) (probe.ProbeResult, error) { + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + unit.Spec.RecordSets[0].ObservedGeneration++ + unit.Spec.RecordSets[0].A.Addresses = []string{"192.0.2.20"} + unit.Generation++ + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + return matching(input) + } + result, unit := reconcileOnce(t, r, request) + if result.RequeueAfter != time.Second || len(unit.Status.RecordSets) != 0 { + t.Fatalf("stale result committed: %#v", unit.Status) + } +} + +func TestWildcardWitnessAndBackoff(t *testing.T) { + unit := fixtureUnit(2) + unit.Spec.RecordSets[0].Name, unit.Spec.RecordSets[1].Name = "*.project", "*.project" + first := groupNames(unit, "policy-a")[0] + slices.Reverse(unit.Spec.RecordSets) + second := groupNames(unit, "policy-b")[0] + if first.CheckedName != second.CheckedName || !first.SampledWildcard || first.Fingerprint == second.Fingerprint { + t.Fatal("unstable witness or policy fingerprint") + } + unit.Spec.RecordSets[0].ObservedGeneration++ + if groupNames(unit, "policy-b")[0].CheckedName == first.CheckedName { + t.Fatal("changed intent reused witness") + } + now := time.Unix(100, 0) + state := schedule{} + failure := probe.Failed("www.example", "view", "timeout") + for _, want := range []time.Duration{30, 60, 120, 300, 300} { + state = advance(state, "same", failure, now, 0) + if state.Next.Sub(now) != want*time.Second { + t.Fatalf("backoff=%v want=%v", state.Next.Sub(now), want) + } + } + state = advance(state, "changed", failure, now, 0) + if state.Failures != 1 || state.Next.Sub(now) != 30*time.Second { + t.Fatal("changed input did not reset backoff") + } + zero, high, minute := uint32(0), uint32(3600), uint32(60) + for _, tc := range []struct { + decision probe.Decision + jitter float64 + want time.Duration + }{ + {probe.Decision{Status: metav1.ConditionTrue, TTL: &zero}, -1, 30 * time.Second}, + {probe.Decision{Status: metav1.ConditionTrue, TTL: &high}, 1, 300 * time.Second}, + {probe.Decision{Status: metav1.ConditionFalse, Reason: "DNSNoData", NegativeTTL: &minute}, 0, time.Minute}, + {probe.Decision{Status: metav1.ConditionTrue, TTL: &minute}, 1, 66 * time.Second}, + } { + if got := nextInterval(tc.decision, 0, tc.jitter); got != tc.want { + t.Fatalf("interval=%v want=%v", got, tc.want) + } + } +} + +func TestAggregateAllowlistAndFalsePrecedence(t *testing.T) { + unit := fixtureUnit(2) + prepareReceipts(unit) + now := time.Unix(100, 0) + setCondition(&unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "VerificationConfigured", "", 1, now) + setCondition(&unit.Status.RecordSets[0].Conditions, "Programmed", metav1.ConditionUnknown, "DNSProbeFailed", "", 1, now) + setCondition(&unit.Status.RecordSets[1].Conditions, "Programmed", metav1.ConditionFalse, "DNSNameNotFound", "", 1, now) + setAggregate(unit, now) + assertProgrammed(t, unit.Status.Conditions, metav1.ConditionFalse, "ExternalResourceNotFound") + for _, reason := range []string{"DNSNoData", "ResolutionPartial", "ResolutionMismatch", "ResolutionInconsistent", "NameserverMismatch"} { + status, mapped := UnitReason(probe.Decision{Status: metav1.ConditionFalse, Reason: reason}) + if status != metav1.ConditionFalse || mapped != "ExternalResourceMismatch" { + t.Fatalf("mapping=%s/%s", status, mapped) + } + } + data := publicData(externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Mode: externalv1alpha1.ResolverModeRecursive}}, "www.example", false, probe.Decision{}, now, now.Add(time.Minute)) + var public externalv1alpha1.ExternalVerificationData + if err := json.Unmarshal(data.Data.Raw, &public); err != nil { + t.Fatal(err) + } + if public.NegativeTTLSeconds != nil || public.NegativeExpiryTime != nil { + t.Fatal("invented negative TTL") + } +} + +func TestZoneAuthorityEvidenceSharedAndBlocksRecords(t *testing.T) { + resolver := &fakeResolver{} + r, c, now, request := newFixture(t, 2, resolver) + var class dnsv1alpha1.ZoneClass + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "app", Name: "external"}, &class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53"]},"expectedNameservers":["ns.example.test"]}`) + if err := c.Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + wrong := false + zoneCalls := 0 + resolver.answer = func(input probe.ProbeRequest) (probe.ProbeResult, error) { + if input.ZoneOnly { + zoneCalls++ + ns := "ns.example.test" + if wrong { + ns = "wrong.example.test" + } + return probe.ProbeResult{Zone: &probe.ZoneEvidence{ + Zone: input.Zone, PolicyFingerprint: probe.Fingerprint(input.Policy), Authorities: []string{"192.0.2.54:53"}, + Views: []probe.AuthorityObservation{ + {ID: "recursive/192.0.2.53:53", Nameservers: []string{ns}, SOAOwner: input.Zone}, + {ID: "authority/192.0.2.54:53", Nameservers: []string{ns}, SOAOwner: input.Zone, Authoritative: true}, + }, + }}, nil + } + if input.Evidence == nil { + t.Fatal("per-Zone evidence was not shared") + } + result, err := matching(input) + authority := result.Views[0] + authority.ID, authority.Authoritative = "authority/192.0.2.54:53/via/192.0.2.53:53", true + result.Views = append(result.Views, authority) + return result, err + } + _, unit := reconcileOnce(t, r, request) + if zoneCalls != 1 || len(resolver.requests) != 3 { + t.Fatalf("zoneCalls=%d probes=%d", zoneCalls, len(resolver.requests)) + } + assertProgrammed(t, unit.Status.Zone.Conditions, metav1.ConditionTrue, "NameserversVerified") + for _, receipt := range unit.Status.RecordSets { + assertProgrammed(t, receipt.Conditions, metav1.ConditionTrue, "ResolutionVerified") + } + wrong = true + *now = now.Add(30 * time.Second) + _, unit = reconcileOnce(t, r, request) + if zoneCalls != 2 || len(resolver.requests) != 4 { + t.Fatal("failed authority check still probed child records") + } + for _, receipt := range unit.Status.RecordSets { + assertProgrammed(t, receipt.Conditions, metav1.ConditionFalse, "NameserverMismatch") + } + if len(unit.Status.Zone.NameServers) != 1 || unit.Status.Zone.NameServers[0] != "wrong.example.test" { + t.Fatal("expected NS was published as observed evidence") + } +} + +func TestNamespacePolicyChangeInvalidatesSuccess(t *testing.T) { + resolver := &fakeResolver{answer: matching} + r, c, _, request := newFixture(t, 1, resolver) + reconcileOnce(t, r, request) + var namespace corev1.Namespace + if err := c.Get(context.Background(), client.ObjectKey{Name: "app"}, &namespace); err != nil { + t.Fatal(err) + } + namespace.Labels = map[string]string{"policy": "changed"} + if err := c.Update(context.Background(), &namespace); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, request) + if len(resolver.requests) != 2 { + t.Fatal("namespace-policy fingerprint was not invalidated") + } +} + +func TestStaleConditionGenerationCannotUseScheduledSuccess(t *testing.T) { + resolver := &fakeResolver{answer: matching} + r, c, _, request := newFixture(t, 1, resolver) + _, unit := reconcileOnce(t, r, request) + for i := range unit.Status.RecordSets[0].Conditions { + if unit.Status.RecordSets[0].Conditions[i].Type == "Programmed" { + unit.Status.RecordSets[0].Conditions[i].ObservedGeneration = 0 + } + } + if err := c.Status().Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, current := reconcileOnce(t, r, request) + if len(resolver.requests) != 2 { + t.Fatal("stale condition reused a future scheduling receipt") + } + condition := assertProgrammed(t, current.Status.RecordSets[0].Conditions, metav1.ConditionTrue, "ResolutionVerified") + if condition.ObservedGeneration != 1 { + t.Fatal("stale result was rebadged instead of reprobed") + } +} + +func TestUnchangedSchedulerJSONDoesNotPatch(t *testing.T) { + for _, probeFails := range []bool{false, true} { + t.Run(fmt.Sprintf("probeFails=%t", probeFails), func(t *testing.T) { + resolver := &fakeResolver{answer: matching} + if probeFails { + resolver.answer = func(probe.ProbeRequest) (probe.ProbeResult, error) { + return probe.ProbeResult{}, errors.New("transport") + } + } + r, c, now, request := newFixture(t, 1, resolver) + _, first := reconcileOnce(t, r, request) + + // Model an API round-trip that changes JSON formatting/key order, + // but neither desired inputs nor the persisted scheduling receipt. + var state map[string]any + if err := json.Unmarshal(first.Status.Provider.State.Raw, &state); err != nil { + t.Fatal(err) + } + encoded, err := json.MarshalIndent(state, "", " ") + if err != nil { + t.Fatal(err) + } + first.Status.Provider.State.Raw = encoded + if err := c.Status().Update(context.Background(), &first); err != nil { + t.Fatal(err) + } + version := first.ResourceVersion + for range 3 { + *now = now.Add(time.Second) + _, unchanged := reconcileOnce(t, r, request) + if len(resolver.requests) != 1 || unchanged.ResourceVersion != version { + t.Fatalf("unchanged scheduling receipt: probes=%d (want 1), resourceVersion=%q -> %q", len(resolver.requests), version, unchanged.ResourceVersion) + } + } + }) + } +} diff --git a/internal/go/providers/external/controllers/zoneunit/model.go b/internal/go/providers/external/controllers/zoneunit/model.go new file mode 100644 index 0000000..a0a81eb --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/model.go @@ -0,0 +1,173 @@ +package zoneunit + +import ( + "encoding/json" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const MaxNamesPerPass = 32 + +type schedule struct { + Fingerprint string `json:"fingerprint"` + Next time.Time `json:"next"` + Failures int `json:"failures,omitempty"` +} + +type schedulerState struct { + Policy string `json:"policy"` + Cursor string `json:"cursor,omitempty"` + Zone schedule `json:"zone"` + Names map[string]schedule `json:"names"` +} + +type nameGroup struct { + Name string + CheckedName string + SampledWildcard bool + Items []int + Expected probe.RRsets + Fingerprint string +} + +func groupNames(unit *dnsv1alpha1.ZoneUnit, policy string) []nameGroup { + byName := map[string][]int{} + for i, item := range unit.Spec.RecordSets { + if item.IsAllowed() && !item.DeletionRequested && item.RecordSetUID != "" { + byName[item.Name] = append(byName[item.Name], i) + } + } + groups := make([]nameGroup, 0, len(byName)) + for name, indices := range byName { + slices.SortFunc(indices, func(a, b int) int { + return strings.Compare(itemKey(unit.Spec.RecordSets[a]), itemKey(unit.Spec.RecordSets[b])) + }) + items := make([]dnsv1alpha1.ZoneUnitRecordSetSpec, 0, len(indices)) + group := nameGroup{Name: name, Items: indices} + for _, index := range indices { + item := unit.Spec.RecordSets[index] + items = append(items, item) + if item.A != nil { + group.Expected.A = append(group.Expected.A, item.A.Addresses...) + } + if item.AAAA != nil { + group.Expected.AAAA = append(group.Expected.AAAA, item.AAAA.Addresses...) + } + if item.CNAME != nil { + group.Expected.CNAME = probe.CanonicalName(item.CNAME.Target) + } + } + group.Expected.A, group.Expected.AAAA = probe.Addresses(group.Expected.A), probe.Addresses(group.Expected.AAAA) + group.Fingerprint = probe.Fingerprint(struct { + Policy string + Items []dnsv1alpha1.ZoneUnitRecordSetSpec + }{policy, items}) + group.CheckedName = unit.Spec.Zone.DomainName + if name != "@" { + group.CheckedName = name + "." + group.CheckedName + } + if name == "*" || strings.HasPrefix(name, "*.") { + group.SampledWildcard = true + // Resolver policy does not alter the witness: only the Zone and sorted claim tuple do. + tuple := make([]string, 0, len(items)) + for _, item := range items { + tuple = append(tuple, probe.Fingerprint(struct { + UID string + Generation int64 + }{string(item.RecordSetUID), item.ObservedGeneration})) + } + slices.Sort(tuple) + witness := probe.Fingerprint(struct { + UnitUID string + Zone dnsv1alpha1.ObjectReference + Claims []string + }{string(unit.UID), unit.Spec.Zone.Ref, tuple}) + group.CheckedName = "dns-verify-" + witness[:24] + strings.TrimPrefix(group.CheckedName, "*") + } + groups = append(groups, group) + } + slices.SortFunc(groups, func(a, b nameGroup) int { return strings.Compare(a.Name, b.Name) }) + return groups +} + +func itemKey(item dnsv1alpha1.ZoneUnitRecordSetSpec) string { + return item.RecordSetNamespace + "/" + item.RecordSetName +} + +func loadState(unit *dnsv1alpha1.ZoneUnit, policy string) schedulerState { + state := schedulerState{} + if unit.Status.Provider != nil { + _ = json.Unmarshal(unit.Status.Provider.State.Raw, &state) + } + if state.Policy != policy { + state = schedulerState{Policy: policy} + } + if state.Names == nil { + state.Names = map[string]schedule{} + } + return state +} + +func nextInterval(decision probe.Decision, failures int, jitter float64) time.Duration { + seconds := 30.0 + switch { + case decision.Status == metav1.ConditionUnknown: + steps := [...]float64{30, 60, 120, 300} + seconds = steps[min(max(failures-1, 0), len(steps)-1)] + case decision.Status == metav1.ConditionTrue && decision.TTL != nil: + seconds = float64(*decision.TTL) + case (decision.Reason == "DNSNameNotFound" || decision.Reason == "DNSNoData") && decision.NegativeTTL != nil: + seconds = float64(*decision.NegativeTTL) + } + seconds = max(30, min(300, seconds)) + seconds *= 1 + max(-1, min(1, jitter))*0.1 + return time.Duration(max(30, min(300, seconds)) * float64(time.Second)) +} + +func advance(previous schedule, fingerprint string, decision probe.Decision, now time.Time, jitter float64) schedule { + failures := 0 + if decision.Status == metav1.ConditionUnknown { + if previous.Fingerprint == fingerprint { + failures = previous.Failures + } + failures++ + } + return schedule{Fingerprint: fingerprint, Failures: failures, Next: now.Add(nextInterval(decision, failures, jitter))} +} + +func selectDue(groups []nameGroup, state schedulerState, now time.Time) []int { + indices := make([]int, 0, MaxNamesPerPass) + start := 0 + for start < len(groups) && groups[start].Name <= state.Cursor { + start++ + } + for offset := 0; offset < len(groups) && len(indices) < MaxNamesPerPass; offset++ { + index := (start + offset) % len(groups) + group := groups[index] + scheduled := state.Names[group.Name] + if scheduled.Fingerprint != group.Fingerprint || !scheduled.Next.After(now) { + indices = append(indices, index) + } + } + return indices +} + +func nextRequeue(groups []nameGroup, state schedulerState, now time.Time) time.Duration { + next := 300 * time.Second + if !state.Zone.Next.IsZero() { + next = min(next, state.Zone.Next.Sub(now)) + } + for _, group := range groups { + scheduled := state.Names[group.Name] + if scheduled.Fingerprint != group.Fingerprint || !scheduled.Next.After(now) { + return time.Second + } + next = min(next, scheduled.Next.Sub(now)) + } + return max(time.Second, next) +} diff --git a/internal/go/providers/external/controllers/zoneunit/status.go b/internal/go/providers/external/controllers/zoneunit/status.go new file mode 100644 index 0000000..8c0de7c --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/status.go @@ -0,0 +1,156 @@ +package zoneunit + +import ( + "encoding/json" + "slices" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func setCondition(conditions *[]metav1.Condition, kind string, status metav1.ConditionStatus, reason, message string, generation int64, now time.Time) { + meta.SetStatusCondition(conditions, metav1.Condition{Type: kind, Status: status, Reason: reason, Message: message, ObservedGeneration: generation, LastTransitionTime: metav1.NewTime(now)}) +} + +func prepareReceipts(unit *dnsv1alpha1.ZoneUnit) { + old := make(map[string]dnsv1alpha1.ZoneUnitRecordSetStatus, len(unit.Status.RecordSets)) + for _, item := range unit.Status.RecordSets { + old[item.RecordSetNamespace+"/"+item.RecordSetName] = item + } + unit.Status.RecordSets = make([]dnsv1alpha1.ZoneUnitRecordSetStatus, len(unit.Spec.RecordSets)) + for i, item := range unit.Spec.RecordSets { + status := old[itemKey(item)] + if status.RecordSetUID != item.RecordSetUID || status.ObservedGeneration != item.ObservedGeneration { + status = dnsv1alpha1.ZoneUnitRecordSetStatus{} + } + status.RecordSetNamespace, status.RecordSetName, status.RecordSetUID, status.ObservedGeneration = item.RecordSetNamespace, item.RecordSetName, item.RecordSetUID, item.ObservedGeneration + status.DeletionCompleted = item.DeletionRequested && item.RecordSetUID != "" + if item.DeletionRequested || !item.IsAllowed() || item.RecordSetUID == "" { + status.Provider = nil + status.Conditions = nil + } + unit.Status.RecordSets[i] = status + } + if unit.Status.Zone == nil { + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{} + } +} + +func resetProgrammed(status *dnsv1alpha1.ZoneUnitRecordSetStatus, now time.Time) { + status.Provider = nil + setCondition(&status.Conditions, "Programmed", metav1.ConditionUnknown, "Reconciling", "DNS verification is pending current inputs.", status.ObservedGeneration, now) +} + +func currentDecision(conditions []metav1.Condition) probe.Decision { + condition := meta.FindStatusCondition(conditions, "Programmed") + if condition == nil { + return probe.Decision{Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "DNS verification is pending."} + } + return probe.Decision{Status: condition.Status, Reason: condition.Reason, Message: condition.Message} +} + +func publicData(policy externalv1alpha1.ExternalZoneClassParameters, name string, sampled bool, decision probe.Decision, now, next time.Time) *dnsv1alpha1.ProviderStatus { + data := externalv1alpha1.ExternalVerificationData{ + ResolverMode: policy.Resolver.Mode, CheckedName: name, SampledWildcard: sampled, + Actual: probe.Preview(decision.Actual), Missing: probe.Preview(decision.Missing), Extra: probe.Preview(decision.Extra), + CNAMEChain: probe.Preview(decision.Chain), Views: probe.Preview(decision.Views), + ObservedTTLSeconds: decision.TTL, NegativeTTLSeconds: decision.NegativeTTL, + LastProbeTime: timePointer(now), NextProbeTime: timePointer(next), + } + if decision.NegativeTTL != nil { + data.NegativeExpiryTime = timePointer(now.Add(time.Duration(*decision.NegativeTTL) * time.Second)) + } + encoded, _ := json.Marshal(data) + return &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: encoded}} +} + +func timePointer(value time.Time) *metav1.Time { result := metav1.NewTime(value); return &result } + +func setZoneResult(unit *dnsv1alpha1.ZoneUnit, policy externalv1alpha1.ExternalZoneClassParameters, decision probe.Decision, now, next time.Time) { + zone := unit.Status.Zone + zone.NameServers = slices.Clone(decision.Nameservers) + zone.Provider = publicData(policy, unit.Spec.Zone.DomainName, false, decision, now, next) + setCondition(&zone.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "External DNS Zone binding is accepted.", unit.Spec.Zone.ObservedGeneration, now) + setCondition(&zone.Conditions, "Programmed", decision.Status, decision.Reason, decision.Message, unit.Spec.Zone.ObservedGeneration, now) +} + +func setGroupResult(unit *dnsv1alpha1.ZoneUnit, group nameGroup, policy externalv1alpha1.ExternalZoneClassParameters, decision probe.Decision, now, next time.Time) { + data := publicData(policy, group.CheckedName, group.SampledWildcard, decision, now, next) + for _, index := range group.Items { + item := &unit.Status.RecordSets[index] + item.Provider = data + setCondition(&item.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "External DNS record intent is accepted.", item.ObservedGeneration, now) + setCondition(&item.Conditions, "Programmed", decision.Status, decision.Reason, decision.Message, item.ObservedGeneration, now) + } +} + +// UnitReason is the explicit boundary to core's fixed ZoneUnit reason allowlist. +func UnitReason(decision probe.Decision) (metav1.ConditionStatus, string) { + if decision.Status == metav1.ConditionTrue { + return metav1.ConditionTrue, "Programmed" + } + if decision.Reason == "DNSProbeFailed" { + return metav1.ConditionUnknown, "ProviderUnavailable" + } + if decision.Status == metav1.ConditionUnknown { + return metav1.ConditionUnknown, "Reconciling" + } + if decision.Reason == "DNSNameNotFound" { + return metav1.ConditionFalse, "ExternalResourceNotFound" + } + return metav1.ConditionFalse, "ExternalResourceMismatch" +} + +func setAggregate(unit *dnsv1alpha1.ZoneUnit, now time.Time) { + decision := currentDecision(unit.Status.Zone.Conditions) + for i, status := range unit.Status.RecordSets { + item := unit.Spec.RecordSets[i] + if !item.IsAllowed() || item.DeletionRequested { + continue + } + candidate := currentDecision(status.Conditions) + if decision.Status != metav1.ConditionFalse && (candidate.Status == metav1.ConditionFalse || (candidate.Status == metav1.ConditionUnknown && decision.Status == metav1.ConditionTrue)) { + decision = candidate + } + } + status, reason := UnitReason(decision) + setCondition(&unit.Status.Conditions, "Programmed", status, reason, decision.Message, unit.Generation, now) +} + +func (r *ZoneReconciler) reject(unit *dnsv1alpha1.ZoneUnit, rejected acceptance) { + now := r.Options.Clock() + unit.Status.Provider = nil + unit.Status.Zone.Provider = nil + unit.Status.Zone.NameServers = nil + setCondition(&unit.Status.Zone.Conditions, "Accepted", rejected.Status, rejected.Reason, rejected.Message, unit.Spec.Zone.ObservedGeneration, now) + setCondition(&unit.Status.Zone.Conditions, "Programmed", metav1.ConditionUnknown, "Reconciling", rejected.Message, unit.Spec.Zone.ObservedGeneration, now) + for i := range unit.Status.RecordSets { + if unit.Spec.RecordSets[i].DeletionRequested { + continue + } + setCondition(&unit.Status.RecordSets[i].Conditions, "Accepted", metav1.ConditionFalse, "ZoneNotAccepted", rejected.Message, unit.Status.RecordSets[i].ObservedGeneration, now) + resetProgrammed(&unit.Status.RecordSets[i], now) + } + setAggregate(unit, now) + r.updatePending(unit) +} + +func (r *ZoneReconciler) updatePending(unit *dnsv1alpha1.ZoneUnit) { + counts := map[string]int{} + for i, status := range unit.Status.RecordSets { + item := unit.Spec.RecordSets[i] + if !item.IsAllowed() || item.DeletionRequested { + continue + } + decision := currentDecision(status.Conditions) + if decision.Status != metav1.ConditionTrue { + counts[decision.Reason]++ + } + } + probe.SetPending(unit.Namespace+"/"+unit.Name, counts) +} diff --git a/internal/go/providers/external/controllers/zoneunit/watches.go b/internal/go/providers/external/controllers/zoneunit/watches.go new file mode 100644 index 0000000..288941b --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/watches.go @@ -0,0 +1,74 @@ +package zoneunit + +import ( + "context" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func (r *ZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Reader = mgr.GetAPIReader() + return ctrl.NewControllerManagedBy(mgr).Named("external-zoneunit"). + WithOptions(controller.Options{MaxConcurrentReconciles: 4}). + For(&dnsv1alpha1.ZoneUnit{}, builder.WithPredicates(predicate.Funcs{UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() || !e.ObjectOld.GetDeletionTimestamp().Equal(e.ObjectNew.GetDeletionTimestamp()) + }})). + Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { + ref := unit.Spec.Zone.ZoneClassRef + return ref.Namespace == obj.GetNamespace() && ref.Name == obj.GetName() + }) + })). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { return unit.Spec.Provider.Name == obj.GetName() }) + }), builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { + if unit.Spec.Zone.Ref.Namespace == obj.GetName() { + return true + } + for _, item := range unit.Spec.RecordSets { + if item.RecordSetNamespace == obj.GetName() { + return true + } + } + return false + }) + }), builder.WithPredicates(predicate.LabelChangedPredicate{})). + Complete(r) +} + +func (r *ZoneReconciler) mapUnits(ctx context.Context, matches func(*dnsv1alpha1.ZoneUnit) bool) []reconcile.Request { + // Real API pagination bounds each dependency read; cache List does not provide + // continuation tokens and custom informer indexes are not API field selectors. + reader := r.Reader + if reader == nil { + reader = r.Client + } + var requests []reconcile.Request + continuation := "" + for { + var units dnsv1alpha1.ZoneUnitList + if err := reader.List(ctx, &units, client.Limit(256), client.Continue(continuation)); err != nil { + return nil + } + for i := range units.Items { + if matches(&units.Items[i]) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&units.Items[i])}) + } + } + continuation = units.Continue + if continuation == "" { + return requests + } + } +} diff --git a/internal/go/providers/external/conversion/handler.go b/internal/go/providers/external/conversion/handler.go new file mode 100644 index 0000000..9c1ec6b --- /dev/null +++ b/internal/go/providers/external/conversion/handler.go @@ -0,0 +1,141 @@ +package conversion + +import ( + "encoding/json" + "fmt" + "net/http" + "net/netip" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/providers/external/config" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + conversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + GroupName = "endpoint.external.dns.appthrust.io" + Version = "v1alpha1" + Resource = "endpointrecordsetconversions" +) + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +func (h *Handler) Register(mux interface{ Register(string, http.Handler) }) { + mux.Register("/apis/"+GroupName+"/"+Version, h) + mux.Register("/apis/"+GroupName+"/"+Version+"/"+Resource, h) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := "/apis/" + GroupName + "/" + Version + switch { + case r.Method == http.MethodGet && r.URL.Path == path: + writeJSON(w, http.StatusOK, metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "APIResourceList"}, + GroupVersion: GroupName + "/" + Version, + APIResources: []metav1.APIResource{{Name: Resource, Kind: "EndpointRecordSetConversion", Verbs: []string{"create"}}}, + }) + case r.Method == http.MethodPost && r.URL.Path == path+"/"+Resource: + defer r.Body.Close() + var request conversionv1alpha1.EndpointRecordSetConversion + response := conversionv1alpha1.EndpointRecordSetConversion{TypeMeta: metav1.TypeMeta{APIVersion: GroupName + "/" + Version, Kind: "EndpointRecordSetConversion"}} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + response.Status.Result = failure("InvalidInput", "request body is malformed") + writeJSON(w, http.StatusBadRequest, response) + return + } + response.ObjectMeta, response.Spec, response.Status.UID = request.ObjectMeta, request.Spec, request.Spec.UID + fragments, result := convertEndpoint(request.Spec.Input) + response.Status.Result = result + if len(fragments) > 0 { + response.Status.Output = &endpointv1alpha1.EndpointRecordSetConversionOutput{Fragments: fragments} + } + writeJSON(w, http.StatusCreated, response) + default: + http.NotFound(w, r) + } +} + +func convertEndpoint(input endpointv1alpha1.EndpointRecordSetConversionInput) ([]endpointv1alpha1.RecordSetSpecFragment, conversionv1alpha1.EndpointRecordSetConversionResult) { + if input.Name == "" { + return nil, failure("InvalidInput", "input.name is required") + } + if len(input.Targets) == 0 { + return nil, failure("InvalidInput", "input.targets must not be empty") + } + var ipv4, ipv6, hosts []string + for _, target := range input.Targets { + switch target.Type { + case endpointv1alpha1.EndpointTargetTypeIPAddress: + ip, err := netip.ParseAddr(target.Value) + if err != nil || ip.Zone() != "" { + return nil, failure("InvalidInput", fmt.Sprintf("target %q is not a valid IP address", target.Value)) + } + ip = ip.Unmap() + if ip.Is4() { + ipv4 = append(ipv4, ip.String()) + } else { + ipv6 = append(ipv6, ip.String()) + } + case endpointv1alpha1.EndpointTargetTypeHostname: + name := strings.TrimSuffix(strings.ToLower(target.Value), ".") + if !config.ValidName(name) { + return nil, failure("InvalidInput", "hostname target must be a valid DNS name") + } + hosts = append(hosts, name) + default: + return nil, failure("InvalidInput", "unsupported target type "+string(target.Type)) + } + } + slices.Sort(ipv4) + ipv4 = slices.Compact(ipv4) + slices.Sort(ipv6) + ipv6 = slices.Compact(ipv6) + slices.Sort(hosts) + hosts = slices.Compact(hosts) + if len(hosts) > 0 && len(ipv4)+len(ipv6) > 0 { + return nil, failure("UnsupportedTarget", "External endpoint conversion does not support mixed hostname and IP targets") + } + if len(hosts) > 1 { + return nil, failure("UnsupportedTarget", "External endpoint conversion requires exactly one hostname target") + } + ttl := int32(300) + if len(hosts) == 1 { + if input.Name == "@" { + return nil, failure("UnsupportedTarget", "External endpoint conversion does not support CNAME at the zone apex") + } + if input.PreferredRecordType != "" && input.PreferredRecordType != endpointv1alpha1.EndpointRecordSetTypeCNAME { + return nil, failure("UnsupportedTarget", "preferredRecordType must be CNAME for a hostname target") + } + return []endpointv1alpha1.RecordSetSpecFragment{{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeCNAME, TTL: &ttl, CNAME: &dnsv1alpha1.CNAMERecordSet{Target: hosts[0]}}}, success() + } + if input.PreferredRecordType != "" && !((input.PreferredRecordType == endpointv1alpha1.EndpointRecordSetTypeA && len(ipv6) == 0) || (input.PreferredRecordType == endpointv1alpha1.EndpointRecordSetTypeAAAA && len(ipv4) == 0)) { + return nil, failure("UnsupportedTarget", "preferredRecordType is incompatible with IP targets") + } + fragments := make([]endpointv1alpha1.RecordSetSpecFragment, 0, 2) + if len(ipv4) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeA, TTL: &ttl, A: &dnsv1alpha1.ARecordSet{Addresses: ipv4}}) + } + if len(ipv6) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeAAAA, TTL: &ttl, AAAA: &dnsv1alpha1.AAAARecordSet{Addresses: ipv6}}) + } + return fragments, success() +} + +func failure(reason, message string) conversionv1alpha1.EndpointRecordSetConversionResult { + return conversionv1alpha1.EndpointRecordSetConversionResult{Status: "Failure", Reason: reason, Message: message, Retryable: false} +} + +func success() conversionv1alpha1.EndpointRecordSetConversionResult { + return conversionv1alpha1.EndpointRecordSetConversionResult{Status: "Success", Reason: "Converted", Message: "converted endpoint targets to external DNS record sets"} +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/go/providers/external/conversion/handler_test.go b/internal/go/providers/external/conversion/handler_test.go new file mode 100644 index 0000000..6ae6a8c --- /dev/null +++ b/internal/go/providers/external/conversion/handler_test.go @@ -0,0 +1,91 @@ +package conversion + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + conversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" +) + +func TestHandlerFramingAndCanonicalTargets(t *testing.T) { + input := endpointv1alpha1.EndpointRecordSetConversionInput{Name: "*.project", Targets: []endpointv1alpha1.EndpointTarget{ + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:0db8:0:0::1"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.2"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:db8::1"}, + }} + review := conversionv1alpha1.EndpointRecordSetConversion{Spec: conversionv1alpha1.EndpointRecordSetConversionSpec{UID: "request-1", Input: input}} + body, _ := json.Marshal(review) + response := httptest.NewRecorder() + NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/apis/"+GroupName+"/"+Version+"/"+Resource, bytes.NewReader(body))) + var got conversionv1alpha1.EndpointRecordSetConversion + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusCreated || got.Status.UID != "request-1" || got.Status.Result.Reason != "Converted" || got.Status.Output == nil { + t.Fatalf("response = %d %#v", response.Code, got) + } + fragments := got.Status.Output.Fragments + if len(fragments) != 2 || fragments[0].A.Addresses[0] != "192.0.2.1" || len(fragments[1].AAAA.Addresses) != 1 || fragments[1].AAAA.Addresses[0] != "2001:db8::1" { + t.Fatalf("fragments = %#v", fragments) + } + for _, fragment := range fragments { + if fragment.Name != input.Name || fragment.TTL == nil || *fragment.TTL != 300 || len(fragment.Options.Raw) != 0 { + t.Fatalf("fragment = %#v", fragment) + } + } +} + +func TestHostnameConversionAndFailures(t *testing.T) { + host := endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "TARGET.EXAMPLE."} + ip := endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"} + cases := []struct { + name string + input endpointv1alpha1.EndpointRecordSetConversionInput + reason, message string + }{ + {"hostname", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, host}}, "Converted", ""}, + {"apex", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "@", Targets: []endpointv1alpha1.EndpointTarget{host}}, "UnsupportedTarget", "External endpoint conversion does not support CNAME at the zone apex"}, + {"mixed", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, ip}}, "UnsupportedTarget", "External endpoint conversion does not support mixed hostname and IP targets"}, + {"multiple", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, {Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "other.example"}}}, "UnsupportedTarget", "External endpoint conversion requires exactly one hostname target"}, + {"invalid address", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "bad"}}}, "InvalidInput", ""}, + {"preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ip}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeCNAME}, "UnsupportedTarget", ""}, + {"missing", endpointv1alpha1.EndpointRecordSetConversionInput{}, "InvalidInput", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fragments, result := convertEndpoint(tc.input) + if result.Reason != tc.reason || result.Retryable || (tc.message != "" && result.Message != tc.message) { + t.Fatalf("result = %#v", result) + } + if result.Status == "Success" { + if len(fragments) != 1 || fragments[0].CNAME.Target != "target.example" || *fragments[0].TTL != 300 { + t.Fatalf("fragments = %#v", fragments) + } + } else if len(fragments) != 0 { + t.Fatal("failure emitted fragments") + } + }) + } +} + +func TestDiscoveryAndMalformedRequest(t *testing.T) { + for _, tc := range []struct { + method, suffix, body string + code int + }{ + {http.MethodGet, "", "", http.StatusOK}, + {http.MethodGet, "/" + Resource, "", http.StatusNotFound}, + {http.MethodPost, "/" + Resource, "{", http.StatusBadRequest}, + } { + response := httptest.NewRecorder() + NewHandler().ServeHTTP(response, httptest.NewRequest(tc.method, "/apis/"+GroupName+"/"+Version+tc.suffix, bytes.NewBufferString(tc.body))) + if response.Code != tc.code { + t.Fatalf("status = %d want %d", response.Code, tc.code) + } + } +} diff --git a/internal/go/providers/external/manifest/capability.yaml b/internal/go/providers/external/manifest/capability.yaml new file mode 100644 index 0000000..9d07fd3 --- /dev/null +++ b/internal/go/providers/external/manifest/capability.yaml @@ -0,0 +1,12 @@ +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: external-v1alpha1 +spec: + provider: + name: external.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.external.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions diff --git a/internal/go/providers/external/manifest/manifest.go b/internal/go/providers/external/manifest/manifest.go new file mode 100644 index 0000000..a462f9e --- /dev/null +++ b/internal/go/providers/external/manifest/manifest.go @@ -0,0 +1,23 @@ +// Package manifest contains the external Provider and endpoint capability declarations. +package manifest + +import ( + _ "embed" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "sigs.k8s.io/yaml" +) + +//go:embed provider.yaml +var ProviderYAML []byte + +//go:embed capability.yaml +var CapabilityYAML []byte + +func Provider() (*dnsv1alpha1.Provider, error) { + var provider dnsv1alpha1.Provider + if err := yaml.UnmarshalStrict(ProviderYAML, &provider); err != nil { + return nil, err + } + return &provider, nil +} diff --git a/internal/go/providers/external/manifest/manifest_test.go b/internal/go/providers/external/manifest/manifest_test.go new file mode 100644 index 0000000..5c77755 --- /dev/null +++ b/internal/go/providers/external/manifest/manifest_test.go @@ -0,0 +1,89 @@ +package manifest + +import ( + "encoding/json" + "testing" + + "github.com/appthrust/dns-api/internal/go/core/celruntime" + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "github.com/google/cel-go/cel" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestExternalDeclarationAndCEL(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + if len(provider.Spec.Versions) != 1 || version.Identity != nil || !version.Storage || !version.Served || len(version.RecordSet.SupportedTypes) != 3 { + t.Fatalf("provider=%#v", provider) + } + if len(version.Zone.AdditionalConditionReasons) != 5 || len(version.RecordSet.AdditionalConditionReasons) != 8 { + t.Fatal("condition reason tables differ from design") + } + for _, rule := range version.ZoneClass.ValidationRules { + if err := celruntime.CompileBool(rule.Rule, cel.Variable("self", cel.DynType)); err != nil { + t.Fatalf("compile %q: %v", rule.Rule, err) + } + } + cases := []struct { + name, parameters string + valid bool + }{ + {"default", `{}`, true}, + {"empty resolver", `{"resolver":{}}`, true}, + {"recursive", `{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53","[2001:db8::53]:5353"]},"expectedNameservers":["ns.example.test"]}`, true}, + {"missing servers", `{"resolver":{"mode":"Recursive"}}`, false}, + {"cluster servers", `{"resolver":{"mode":"Cluster","servers":["192.0.2.53:53"]}}`, false}, + {"bad IP", `{"resolver":{"mode":"Recursive","servers":["999.1.1.1:53"]}}`, false}, + {"port overflow", `{"resolver":{"mode":"Recursive","servers":["192.0.2.1:65536"]}}`, false}, + {"leading zero port", `{"resolver":{"mode":"Recursive","servers":["192.0.2.1:053"]}}`, false}, + {"duplicate", `{"resolver":{"mode":"Recursive","servers":["192.0.2.1:53","192.0.2.1:53"]}}`, false}, + {"NS IP", `{"expectedNameservers":["192.0.2.1"]}`, false}, + {"noncanonical NS", `{"expectedNameservers":["NS.example.test."]}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + class := &dnsv1alpha1.ZoneClass{Spec: dnsv1alpha1.ZoneClassSpec{Parameters: runtime.RawExtension{Raw: []byte(tc.parameters)}}} + _, schemaErr := providercontract.ConvertZoneClassParametersToStorage(provider, version, class) + valid := schemaErr == nil + if valid { + var params map[string]interface{} + if err := json.Unmarshal([]byte(tc.parameters), ¶ms); err != nil { + t.Fatal(err) + } + self := map[string]interface{}{"spec": map[string]interface{}{"parameters": params}} + for _, rule := range version.ZoneClass.ValidationRules { + ok, err := celruntime.EvalBool(rule.Rule, map[string]interface{}{"self": self}, cel.Variable("self", cel.DynType)) + if err != nil || !ok { + valid = false + break + } + } + } + if valid != tc.valid { + t.Fatalf("valid=%v want=%v schemaError=%v", valid, tc.valid, schemaErr) + } + }) + } +} + +func TestNonemptyProviderPayloadsRejected(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + for _, target := range []providercontract.PayloadTarget{providercontract.TargetZone, providercontract.TargetRecordSet} { + input := providercontract.ConversionInput{Provider: provider, FromVersion: version, ToVersion: version, Target: target, Payload: providercontract.Payload{Adoption: runtime.RawExtension{Raw: []byte(`{"enabled":true}`)}}} + if _, err := providercontract.ConvertProviderPayloadToStorage(input); err == nil { + t.Fatalf("nonempty adoption accepted for %s", target) + } + } + input := providercontract.ConversionInput{Provider: provider, FromVersion: version, ToVersion: version, Target: providercontract.TargetRecordSet, Payload: providercontract.Payload{Options: runtime.RawExtension{Raw: []byte(`{"alias":{}}`)}}} + if _, err := providercontract.ConvertProviderPayloadToStorage(input); err == nil { + t.Fatal("nonempty options accepted") + } +} diff --git a/internal/go/providers/external/manifest/provider.yaml b/internal/go/providers/external/manifest/provider.yaml new file mode 100644 index 0000000..f24735f --- /dev/null +++ b/internal/go/providers/external/manifest/provider.yaml @@ -0,0 +1,162 @@ +apiVersion: dns.appthrust.io/v1alpha1 +kind: Provider +metadata: + name: external.dns.appthrust.io +spec: + display: + name: External DNS + description: Verifies customer-managed DNS without changing DNS records. + versions: + - name: v1alpha1 + served: true + storage: true + zoneClass: + schemas: + parameters: + openAPIV3Schema: + type: object + description: DNS resolver policy for verification without DNS writes. + additionalProperties: false + properties: + resolver: + type: object + description: Resolver view used for every verification query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + description: One recursive resolver endpoint as IPv4:port or [IPv6]:port. + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + expectedNameservers: + type: array + description: Expected authoritative nameservers for the exact Zone domain. + maxItems: 16 + uniqueItems: true + items: + type: string + description: One expected authoritative nameserver hostname, lowercase, without a trailing dot. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + validationRules: + - rule: >- + has(self.spec.parameters.resolver) && + has(self.spec.parameters.resolver.mode) && + self.spec.parameters.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.resolver.servers) && size(self.spec.parameters.resolver.servers) > 0) + : (!has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || size(self.spec.parameters.resolver.servers) == 0) + message: resolver.servers must be nonempty for Recursive and empty for Cluster + - rule: >- + !has(self.spec.parameters.resolver) || !has(self.spec.parameters.resolver.servers) || + self.spec.parameters.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: resolver.servers must contain canonical IP addresses and ports from 1 through 65535 + - rule: >- + !has(self.spec.parameters.expectedNameservers) || + self.spec.parameters.expectedNameservers.all(n, !isIP(n)) + message: expectedNameservers must contain DNS names, not IP addresses + zone: + schemas: + adoption: &empty + openAPIV3Schema: + type: object + additionalProperties: false + properties: {} + statusProviderData: &status + openAPIV3Schema: + type: object + description: Bounded read-only DNS verification evidence; not DNS withdrawal proof. + additionalProperties: false + properties: + resolverMode: + type: string + description: Selected recursive resolver view policy. + enum: [Cluster, Recursive] + checkedName: + type: string + description: Absolute DNS owner checked, without the root dot. + maxLength: 253 + sampledWildcard: + type: boolean + description: Whether checkedName is a stable concrete wildcard witness. + actual: &preview + type: object + description: Sorted bounded diagnostic preview; equality uses the complete observation. + additionalProperties: false + required: [total, truncated] + properties: + values: + type: array + description: Up to sixteen typed values or diagnostic entries. + maxItems: 16 + items: + type: string + description: One bounded value or diagnostic entry. + maxLength: 512 + total: + type: integer + description: Total entries before truncation. + minimum: 0 + truncated: + type: boolean + description: Whether values omits entries from the complete observation. + missing: *preview + extra: *preview + cnameChain: *preview + views: *preview + observedTTLSeconds: + type: integer + description: Minimum relevant positive TTL in seconds, when observed. + minimum: 0 + negativeTTLSeconds: + type: integer + description: Minimum of negative SOA TTL and SOA MINIMUM, when observed. + minimum: 0 + negativeExpiryTime: + type: string + format: date-time + description: Negative cache expiry suggested by the observed SOA, not the polling cap. + lastProbeTime: + type: string + format: date-time + description: Last completed attempt, including failures. + nextProbeTime: + type: string + format: date-time + description: Approximate next planned attempt; queue load can delay it. + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: VerificationConfigured, description: Verifier configured without authority checks.} + - {conditionType: Programmed, status: 'True', reason: NameserversVerified, description: Every required view verified the exact apex and nameservers.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Observed apex or nameservers differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordSet: + supportedTypes: [A, AAAA, CNAME] + schemas: + options: *empty + adoption: *empty + statusProviderData: *status + additionalConditionReasons: + - {conditionType: Programmed, status: 'True', reason: ResolutionVerified, description: Exact targets verified in every required view.} + - {conditionType: Programmed, status: 'False', reason: DNSNameNotFound, description: Required view returned NXDOMAIN.} + - {conditionType: Programmed, status: 'False', reason: DNSNoData, description: Required record type has no answers.} + - {conditionType: Programmed, status: 'False', reason: ResolutionPartial, description: A nonempty proper subset of targets was observed.} + - {conditionType: Programmed, status: 'False', reason: ResolutionMismatch, description: Targets or CNAME chain differ from expectations.} + - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} + - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} + - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} diff --git a/internal/go/providers/external/probe/evaluate.go b/internal/go/providers/external/probe/evaluate.go new file mode 100644 index 0000000..21778c8 --- /dev/null +++ b/internal/go/providers/external/probe/evaluate.go @@ -0,0 +1,300 @@ +package probe + +import ( + "fmt" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/providers/external/config" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Decision struct { + Status metav1.ConditionStatus + Reason string + Message string + Actual []string + Missing []string + Extra []string + Chain []string + Views []string + Nameservers []string + TTL *uint32 + NegativeTTL *uint32 +} + +func (d Decision) Outcome() string { + if d.Status == metav1.ConditionUnknown { + return "failed" + } + if d.Status == metav1.ConditionTrue { + return "verified" + } + if d.Reason == "DNSNameNotFound" || d.Reason == "DNSNoData" { + return "negative" + } + return "mismatch" +} + +func Failed(name, view, failure string) Decision { + switch failure { + case "timeout", "SERVFAIL", "REFUSED", "transport", "invalid-response", "resolver-config", "chain-limit", "probe-limit": + default: + failure = "transport" + } + return Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: bounded(fmt.Sprintf("DNS verification unavailable for %s from %s: %s.", name, view, failure))} +} + +func EvaluateZone(request ProbeRequest, evidence *ZoneEvidence) Decision { + return evaluateZone(request, evidence, true) +} + +func evaluateZone(request ProbeRequest, evidence *ZoneEvidence, requireAuthorities bool) Decision { + if len(request.Policy.ExpectedNameservers) == 0 { + return Decision{Status: metav1.ConditionTrue, Reason: "VerificationConfigured", Message: bounded(fmt.Sprintf("DNS verifier configured for %s; authoritative nameserver checks were not requested.", request.Zone))} + } + if evidence == nil || len(evidence.Views) < len(request.Policy.Resolver.Servers) || len(evidence.Views) == 0 { + return Failed(request.Zone, "required-views", "invalid-response") + } + for _, server := range request.Policy.Resolver.Servers { + if !slices.ContainsFunc(evidence.Views, func(view AuthorityObservation) bool { return view.ID == "recursive/"+server }) { + return Failed(request.Zone, "recursive/"+server, "invalid-response") + } + } + views := slices.Clone(evidence.Views) + slices.SortFunc(views, func(a, b AuthorityObservation) int { return strings.Compare(a.ID, b.ID) }) + decision := Decision{Status: metav1.ConditionTrue, Reason: "NameserversVerified"} + for _, view := range views { + decision.Views = append(decision.Views, view.ID) + if view.Failure != "" { + return Failed(request.Zone, view.ID, view.Failure) + } + for _, name := range view.Nameservers { + if !config.ValidName(CanonicalName(name)) { + return Failed(request.Zone, view.ID, "invalid-response") + } + } + decision.TTL = minTTL(decision.TTL, view.TTL) + decision.NegativeTTL = minTTL(decision.NegativeTTL, view.NegativeTTL) + } + first := views[0] + decision.Nameservers = Names(first.Nameservers) + decision.Actual = typed("NS", decision.Nameservers) + for _, view := range views[1:] { + if !slices.Equal(decision.Nameservers, Names(view.Nameservers)) || CanonicalName(view.SOAOwner) != CanonicalName(first.SOAOwner) { + return inconsistent(request.Zone, decision) + } + } + if CanonicalName(first.SOAOwner) != request.Zone || !slices.Equal(decision.Nameservers, Names(request.Policy.ExpectedNameservers)) { + decision.Status, decision.Reason = metav1.ConditionFalse, "NameserverMismatch" + decision.Missing, decision.Extra = difference(typed("NS", Names(request.Policy.ExpectedNameservers)), decision.Actual) + decision.Message = bounded(fmt.Sprintf("DNS %s nameservers differ: expected %s; observed %s.", request.Zone, preview(Names(request.Policy.ExpectedNameservers)), preview(decision.Nameservers))) + return decision + } + if requireAuthorities && len(evidence.Authorities) == 0 { + return Failed(request.Zone, "required-authorities", "invalid-response") + } + if requireAuthorities { + for _, authority := range evidence.Authorities { + found := false + for _, view := range views { + if view.ID == "authority/"+authority && view.Authoritative { + found = true + break + } + } + if !found { + return Failed(request.Zone, "authority/"+authority, "invalid-response") + } + } + } + decision.Message = bounded(fmt.Sprintf("DNS %s nameservers match expectations in every required view; no DNS changes were made.", request.Zone)) + return decision +} + +func Evaluate(request ProbeRequest, result ProbeResult) Decision { + if len(result.Views) == 0 || len(result.Views) < len(request.Policy.Resolver.Servers) { + return Failed(request.Name, "required-views", "invalid-response") + } + for _, server := range request.Policy.Resolver.Servers { + if !slices.ContainsFunc(result.Views, func(view View) bool { return view.ID == "recursive/"+server }) { + return Failed(request.Name, "recursive/"+server, "invalid-response") + } + } + if len(request.Policy.ExpectedNameservers) != 0 { + if request.Evidence == nil || len(request.Evidence.Authorities) == 0 { + return Failed(request.Name, "required-authorities", "invalid-response") + } + for _, authority := range request.Evidence.Authorities { + if !slices.ContainsFunc(result.Views, func(view View) bool { + return strings.HasPrefix(view.ID, "authority/"+authority+"/via/") && view.Authoritative + }) { + return Failed(request.Name, "authority/"+authority, "invalid-response") + } + } + } + views := slices.Clone(result.Views) + slices.SortFunc(views, func(a, b View) int { return strings.Compare(a.ID, b.ID) }) + decision := Decision{Status: metav1.ConditionTrue, Reason: "ResolutionVerified"} + for i := range views { + view := &views[i] + view.A, view.AAAA = Addresses(view.A), Addresses(view.AAAA) + view.Chain = slices.Clone(view.Chain) + seen := map[string]bool{CanonicalName(request.Name): true} + for j, name := range view.Chain { + name = CanonicalName(name) + view.Chain[j] = name + if seen[name] { + view.Mismatch = true + } + seen[name] = true + } + if len(view.Chain) > MaxCNAMEs { + view.Failure = "chain-limit" + } + if view.Failure != "" { + return Failed(request.Name, view.ID, view.Failure) + } + if view.RCode != dns.RcodeSuccess && view.RCode != dns.RcodeNameError { + failure := "invalid-response" + if view.RCode == dns.RcodeServerFailure { + failure = "SERVFAIL" + } + if view.RCode == dns.RcodeRefused { + failure = "REFUSED" + } + return Failed(request.Name, view.ID, failure) + } + decision.Views = append(decision.Views, view.ID) + decision.TTL = minTTL(decision.TTL, view.TTL) + decision.NegativeTTL = minTTL(decision.NegativeTTL, view.NegativeTTL) + } + if request.Evidence != nil { + for _, view := range request.Evidence.Views { + decision.TTL = minTTL(decision.TTL, view.TTL) + } + } + first := views[0] + decision.Actual = append(typed("A", first.A), typed("AAAA", first.AAAA)...) + decision.Chain = first.Chain + for _, view := range views[1:] { + if view.RCode != first.RCode || view.Mismatch != first.Mismatch || !slices.Equal(view.A, first.A) || !slices.Equal(view.AAAA, first.AAAA) || !slices.Equal(view.Chain, first.Chain) { + return inconsistent(request.Name, decision) + } + } + expected := append(typed("A", Addresses(request.Expected.A)), typed("AAAA", Addresses(request.Expected.AAAA))...) + if request.Expected.CNAME != "" { + expected = typed("CNAME", []string{CanonicalName(request.Expected.CNAME)}) + decision.Actual = nil + if len(first.Chain) > 0 { + decision.Actual = typed("CNAME", first.Chain[:1]) + } + } + decision.Missing, decision.Extra = difference(expected, decision.Actual) + if first.Mismatch { + return mismatch(request.Name, decision) + } + if first.RCode == dns.RcodeNameError { + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNameNotFound" + decision.Message = bounded(fmt.Sprintf("DNS %s returned NXDOMAIN from %s; expected %s.", request.Name, first.ID, preview(expected))) + return decision + } + if len(decision.Extra) != 0 { + return mismatch(request.Name, decision) + } + if request.Expected.CNAME != "" && len(decision.Missing) != 0 { + return mismatch(request.Name, decision) + } + qtype := "" + if len(request.Expected.A) > 0 && len(first.A) == 0 { + qtype = "A" + } + if qtype == "" && len(request.Expected.AAAA) > 0 && len(first.AAAA) == 0 { + qtype = "AAAA" + } + if request.Expected.CNAME != "" && len(first.A)+len(first.AAAA) == 0 { + qtype = "A or AAAA" + } + if qtype != "" { + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNoData" + decision.Message = bounded(fmt.Sprintf("DNS %s returned NOERROR with no %s answers; expected %s.", request.Name, qtype, preview(expected))) + return decision + } + if len(decision.Missing) != 0 { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionPartial" + decision.Message = bounded(fmt.Sprintf("DNS %s is missing expected targets %s; observed %s.", request.Name, preview(decision.Missing), preview(decision.Actual))) + return decision + } + decision.Message = bounded(fmt.Sprintf("DNS %s matches expected targets in every required resolver view; no DNS changes were made.", request.Name)) + return decision +} + +func inconsistent(name string, decision Decision) Decision { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionInconsistent" + decision.Message = bounded(fmt.Sprintf("DNS %s differs between required resolver/authority views %s.", name, preview(decision.Views))) + return decision +} + +func mismatch(name string, decision Decision) Decision { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionMismatch" + decision.Message = bounded(fmt.Sprintf("DNS %s targets differ: missing %s; unexpected %s.", name, preview(decision.Missing), preview(decision.Extra))) + return decision +} + +func typed(kind string, values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = kind + " " + value + } + return out +} + +func difference(expected, actual []string) (missing, extra []string) { + for _, value := range expected { + if !slices.Contains(actual, value) { + missing = append(missing, value) + } + } + for _, value := range actual { + if !slices.Contains(expected, value) { + extra = append(extra, value) + } + } + slices.Sort(missing) + slices.Sort(extra) + return missing, extra +} + +func Preview(values []string) externalv1alpha1.VerificationValues { + result := externalv1alpha1.VerificationValues{Total: int32(len(values)), Truncated: len(values) > 16} + result.Values = slices.Clone(values[:min(len(values), 16)]) + for i, value := range result.Values { + if len(value) > 512 { + result.Values[i] = value[:512] + result.Truncated = true + } + } + return result +} + +func preview(values []string) string { + // The message preview is smaller than public data so two previews plus a name fit 2048 bytes. + shown := values[:min(len(values), 4)] + result := "[" + strings.Join(shown, ", ") + "]" + if len(values) > len(shown) { + result += fmt.Sprintf(" (+%d omitted)", len(values)-len(shown)) + } + if len(result) > 700 { + result = result[:680] + " (truncated)" + } + return result +} + +func bounded(value string) string { + if len(value) > 2048 { + return value[:2032] + " (truncated)" + } + return value +} diff --git a/internal/go/providers/external/probe/evaluate_test.go b/internal/go/providers/external/probe/evaluate_test.go new file mode 100644 index 0000000..1535a3c --- /dev/null +++ b/internal/go/providers/external/probe/evaluate_test.go @@ -0,0 +1,118 @@ +package probe + +import ( + "fmt" + "testing" + + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExactVerification(t *testing.T) { + cases := []struct { + name string + expected RRsets + views []View + reason string + }{ + {"exact", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"normalized duplicate IPv6", RRsets{AAAA: []string{"2001:db8::1"}}, []View{{AAAA: []string{"2001:0DB8:0:0::1", "2001:db8::1"}}}, "ResolutionVerified"}, + {"unexpected family", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.1"}, AAAA: []string{"2001:db8::1"}}}, "ResolutionMismatch"}, + {"partial", RRsets{A: []string{"192.0.2.1", "192.0.2.2"}}, []View{{A: []string{"192.0.2.1"}}}, "ResolutionPartial"}, + {"wrong", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.2"}}}, "ResolutionMismatch"}, + {"NXDOMAIN", RRsets{A: []string{"192.0.2.1"}}, []View{{RCode: dns.RcodeNameError}}, "DNSNameNotFound"}, + {"NODATA", RRsets{A: []string{"192.0.2.1"}}, []View{{}}, "DNSNoData"}, + {"inconsistent", RRsets{A: []string{"192.0.2.1"}}, []View{{ID: "a", A: []string{"192.0.2.1"}}, {ID: "b", RCode: dns.RcodeNameError}}, "ResolutionInconsistent"}, + {"failure beats mismatch", RRsets{A: []string{"192.0.2.1"}}, []View{{ID: "a", A: []string{"192.0.2.2"}}, {ID: "b", Failure: "timeout"}}, "DNSProbeFailed"}, + {"SERVFAIL", RRsets{A: []string{"192.0.2.1"}}, []View{{RCode: dns.RcodeServerFailure}}, "DNSProbeFailed"}, + {"CNAME to IP", RRsets{A: []string{"192.0.2.1"}}, []View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"CNAME identity", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"wrong CNAME same IP", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"other.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, + {"unusable CNAME", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"target.example"}}}, "DNSNoData"}, + {"CNAME loop", RRsets{A: []string{"192.0.2.1"}}, []View{{Chain: []string{"target.example", "www.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, + {"incompatible CNAME", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"target.example"}, A: []string{"192.0.2.1"}, Mismatch: true}}, "ResolutionMismatch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + decision := Evaluate(ProbeRequest{Name: "www.example", Expected: tc.expected}, ProbeResult{Views: tc.views}) + if decision.Reason != tc.reason { + t.Fatalf("decision = %#v, want %s", decision, tc.reason) + } + if len(decision.Message) > 2048 { + t.Fatal("unbounded message") + } + }) + } +} + +func TestCNAMEChainBoundary(t *testing.T) { + for _, links := range []int{8, 9} { + chain := make([]string, links) + for i := range chain { + chain[i] = fmt.Sprintf("link-%d.example", i) + } + decision := Evaluate(ProbeRequest{Name: "www.example", Expected: RRsets{A: []string{"192.0.2.1"}}}, ProbeResult{Views: []View{{Chain: chain, A: []string{"192.0.2.1"}}}}) + want := "ResolutionVerified" + if links == 9 { + want = "DNSProbeFailed" + } + if decision.Reason != want { + t.Fatalf("links=%d: %#v", links, decision) + } + } +} + +func TestNameserverEvidence(t *testing.T) { + request := ProbeRequest{Zone: "example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"192.0.2.53:53"}}, ExpectedNameservers: []string{"ns.example.test"}}} + good := AuthorityObservation{ID: "recursive/192.0.2.53:53", Nameservers: []string{"NS.EXAMPLE.TEST."}, SOAOwner: "example.test"} + authority := AuthorityObservation{ID: "authority/192.0.2.54:53", Nameservers: []string{"ns.example.test"}, SOAOwner: "example.test", Authoritative: true} + cases := []struct { + name string + mutate func(*ZoneEvidence) + reason string + }{ + {"verified", func(*ZoneEvidence) {}, "NameserversVerified"}, + {"non apex", func(e *ZoneEvidence) { e.Views[0].SOAOwner = "test"; e.Views[1].SOAOwner = "test" }, "NameserverMismatch"}, + {"NS mismatch", func(e *ZoneEvidence) { + for i := range e.Views { + e.Views[i].Nameservers = []string{"wrong.example.test"} + } + }, "NameserverMismatch"}, + {"inconsistent", func(e *ZoneEvidence) { e.Views[1].Nameservers = []string{"wrong.example.test"} }, "ResolutionInconsistent"}, + {"missing AA", func(e *ZoneEvidence) { e.Views[1].Authoritative = false }, "DNSProbeFailed"}, + {"failed authority", func(e *ZoneEvidence) { e.Views[1].Failure = "REFUSED" }, "DNSProbeFailed"}, + {"missing authority", func(e *ZoneEvidence) { e.Views = e.Views[:1] }, "DNSProbeFailed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + evidence := &ZoneEvidence{Views: []AuthorityObservation{good, authority}, Authorities: []string{"192.0.2.54:53"}} + tc.mutate(evidence) + decision := EvaluateZone(request, evidence) + if decision.Reason != tc.reason { + t.Fatalf("decision = %#v, want %s", decision, tc.reason) + } + }) + } + request.Policy.ExpectedNameservers = nil + if decision := EvaluateZone(request, nil); decision.Status != metav1.ConditionTrue || len(decision.Nameservers) != 0 { + t.Fatalf("suffix = %#v", decision) + } +} + +func TestStatusPreviewDoesNotAffectEquality(t *testing.T) { + values := make([]string, 20) + for i := range values { + values[i] = fmt.Sprintf("192.0.2.%d", i+1) + } + observed := append([]string(nil), values...) + observed[19] = "198.51.100.1" + decision := Evaluate(ProbeRequest{Name: "www.example", Expected: RRsets{A: values}}, ProbeResult{Views: []View{{A: observed}}}) + if decision.Reason != "ResolutionMismatch" { + t.Fatalf("decision = %#v", decision) + } + preview := Preview(decision.Actual) + if preview.Total != 20 || !preview.Truncated || len(preview.Values) != 16 { + t.Fatalf("preview = %#v", preview) + } +} diff --git a/internal/go/providers/external/probe/limits_test.go b/internal/go/providers/external/probe/limits_test.go new file mode 100644 index 0000000..54079c6 --- /dev/null +++ b/internal/go/providers/external/probe/limits_test.go @@ -0,0 +1,163 @@ +package probe + +import ( + "context" + "fmt" + "math" + "net" + "strings" + "sync/atomic" + "testing" + + "github.com/miekg/dns" +) + +func TestWireChainBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + links int + loop bool + reason string + }{ + {"eight", 8, false, "ResolutionVerified"}, + {"nine", 9, false, "DNSProbeFailed"}, + {"loop", 2, true, "ResolutionMismatch"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + reply := new(dns.Msg) + reply.SetReply(request) + question := request.Question[0] + var link int + if _, err := fmt.Sscanf(question.Name, "link-%d.example.test.", &link); err != nil { + t.Error(err) + } + if link < tc.links { + next := link + 1 + if tc.loop && next == tc.links { + next = 0 + } + reply.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: fmt.Sprintf("link-%d.example.test.", next)}} + } else if question.Qtype == dns.TypeA { + reply.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 120}, A: net.ParseIP("192.0.2.1")}} + } + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + session := wireSession{resolver: resolver} + view := session.resolve(context.Background(), "link-0.example.test", server, "", "example.test") + decision := Evaluate(ProbeRequest{Name: "link-0.example.test", Expected: RRsets{A: []string{"192.0.2.1"}}}, ProbeResult{Views: []View{view}}) + if decision.Reason != tc.reason { + t.Fatalf("decision=%#v view=%#v", decision, view) + } + }) + } +} + +func TestForeignCNAMEUsesSelectedRecursiveView(t *testing.T) { + var authorityQueries, recursiveQueries atomic.Int32 + authority := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + authorityQueries.Add(1) + if request.RecursionDesired || request.Question[0].Name != "www.example.test." { + t.Errorf("unexpected authority query %#v", request) + } + reply := new(dns.Msg) + reply.SetReply(request) + reply.Authoritative = true + reply.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: "www.example.test.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: "target.foreign.test."}} + _ = w.WriteMsg(reply) + }) + recursive := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + recursiveQueries.Add(1) + if !request.RecursionDesired || request.Question[0].Name != "target.foreign.test." { + t.Errorf("unexpected recursive query %#v", request) + } + reply := new(dns.Msg) + reply.SetReply(request) + if request.Question[0].Qtype == dns.TypeA { + reply.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: "target.foreign.test.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 120}, A: net.ParseIP("192.0.2.1")}} + } + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + session := wireSession{resolver: resolver, authorityAnswers: map[questionKey]*dns.Msg{}} + view := session.resolve(context.Background(), "www.example.test", recursive, authority, "example.test") + if view.Failure != "" || !view.Authoritative || len(view.A) != 1 || authorityQueries.Load() != 2 || recursiveQueries.Load() != 2 { + t.Fatalf("view=%#v authority=%d recursive=%d", view, authorityQueries.Load(), recursiveQueries.Load()) + } + // Reusing this name's in-zone evidence for another recursive suffix walk sends no duplicate authority exchanges. + session.resolve(context.Background(), "www.example.test", recursive, authority, "example.test") + if authorityQueries.Load() != 2 { + t.Fatal("in-zone authority work multiplied by recursive views") + } +} + +func TestMalformedWireResponseCannotVerify(t *testing.T) { + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + reply := new(dns.Msg) + reply.SetReply(request) + reply.Opcode = dns.OpcodeUpdate + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + session := wireSession{resolver: resolver} + _, failure := session.exchange(context.Background(), server, "www.example.test", dns.TypeA, true) + if failure == "" { + t.Fatal("invalid opcode was accepted") + } +} + +func TestLimiterDefaultsAndClosedFailureCodes(t *testing.T) { + resolver, err := NewWireResolver(WireOptions{}) + if err != nil { + t.Fatal(err) + } + if resolver.limiter.Limit() != 40 || resolver.limiter.Burst() != 80 || cap(resolver.slots) != 4 { + t.Fatal("manager-wide bounds differ from contract") + } + for _, qps := range []float64{-1, math.NaN(), math.Inf(1)} { + if _, err := NewWireResolver(WireOptions{QPS: qps}); err == nil { + t.Fatal("invalid limiter accepted") + } + } + decision := Failed("www.example.test", "view", "raw secret-containing diagnostic") + if strings.Contains(decision.Message, "secret") || !strings.HasSuffix(decision.Message, "transport.") { + t.Fatalf("message=%s", decision.Message) + } +} + +func TestNameserverAddressFanoutFailsClosed(t *testing.T) { + var queries atomic.Int32 + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + queries.Add(1) + if !request.RecursionDesired || request.Opcode != dns.OpcodeQuery { + t.Error("NS discovery must be recursive QUERY") + } + reply := new(dns.Msg) + reply.SetReply(request) + question := request.Question[0] + switch question.Qtype { + case dns.TypeNS: + reply.Answer = []dns.RR{&dns.NS{Hdr: dns.RR_Header{Name: "example.test.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300}, Ns: "ns.example.test."}} + case dns.TypeSOA: + reply.Answer = []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: "example.test.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, Ns: "ns.example.test.", Mbox: "hostmaster.example.test.", Minttl: 60}} + case dns.TypeA: + for _, address := range []string{"127.0.0.2", "127.0.0.3", "127.0.0.4"} { + reply.Answer = append(reply.Answer, &dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, A: net.ParseIP(address)}) + } + } + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + request := ProbeRequest{Zone: "example.test", Name: "example.test", ZoneOnly: true} + request.Policy.Resolver.Servers = []string{server} + request.Policy.ExpectedNameservers = []string{"ns.example.test"} + result, err := resolver.Probe(context.Background(), request) + if err != nil { + t.Fatal(err) + } + decision := EvaluateZone(request, result.Zone) + if decision.Reason != "DNSProbeFailed" || !strings.Contains(decision.Message, "probe-limit") || queries.Load() != 4 { + t.Fatalf("decision=%#v queries=%d", decision, queries.Load()) + } +} diff --git a/internal/go/providers/external/probe/metrics.go b/internal/go/providers/external/probe/metrics.go new file mode 100644 index 0000000..937b3de --- /dev/null +++ b/internal/go/providers/external/probe/metrics.go @@ -0,0 +1,47 @@ +package probe + +import ( + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + probes = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_external_probes_total", Help: "Completed external DNS observations."}, []string{"outcome", "qtype"}) + duration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_external_probe_duration_seconds", Help: "Admitted external DNS probe duration."}, []string{"outcome"}) + queueDelay = prometheus.NewHistogram(prometheus.HistogramOpts{Name: "dns_external_probe_queue_delay_seconds", Help: "Scheduling and limiter admission delay for external DNS probes."}) + pending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_external_pending_records", Help: "Active unverified external record claims."}, []string{"reason"}) + pendingMu sync.Mutex + pendingUnits = map[string]map[string]int{} +) + +func init() { metrics.Registry.MustRegister(probes, duration, queueDelay, pending) } + +func ObserveQueueDelay(delay time.Duration) { queueDelay.Observe(max(0, delay.Seconds())) } + +// SetPending replaces a unit's contribution; identities are internal keys, never labels. +func SetPending(unit string, counts map[string]int) { + pendingMu.Lock() + defer pendingMu.Unlock() + for reason, count := range pendingUnits[unit] { + pending.WithLabelValues(reason).Sub(float64(count)) + } + delete(pendingUnits, unit) + next := map[string]int{} + for reason, count := range counts { + switch reason { + case "Reconciling", "DNSNameNotFound", "DNSNoData", "ResolutionPartial", "ResolutionMismatch", "ResolutionInconsistent", "NameserverMismatch", "DNSProbeFailed": + default: + reason = "Reconciling" + } + next[reason] += count + } + if len(next) != 0 { + pendingUnits[unit] = next + } + for reason, count := range next { + pending.WithLabelValues(reason).Add(float64(count)) + } +} diff --git a/internal/go/providers/external/probe/types.go b/internal/go/providers/external/probe/types.go new file mode 100644 index 0000000..3d110b2 --- /dev/null +++ b/internal/go/providers/external/probe/types.go @@ -0,0 +1,114 @@ +// Package probe observes DNS using QUERY only. It has no DNS mutation interface. +package probe + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/netip" + "slices" + "strings" + "time" + + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" +) + +type Resolver interface { + Probe(context.Context, ProbeRequest) (ProbeResult, error) +} + +type RRsets struct { + A []string `json:"a,omitempty"` + AAAA []string `json:"aaaa,omitempty"` + CNAME string `json:"cname,omitempty"` +} + +type ProbeRequest struct { + Name string + Zone string + Expected RRsets + Policy externalv1alpha1.ExternalZoneClassParameters + // DueTime lets the wire resolver measure scheduling plus limiter queue delay once. + DueTime time.Time + // ZoneOnly collects apex/NS-host work once for this Zone's probe cycle. + ZoneOnly bool + // Evidence is cycle-local, never a process-wide DNS answer cache. + Evidence *ZoneEvidence +} + +type ProbeResult struct { + Views []View + Zone *ZoneEvidence +} + +type View struct { + ID string + RCode int + A []string + AAAA []string + Chain []string + Authoritative bool + TTL *uint32 + NegativeTTL *uint32 + // Failure is a closed diagnostic code; raw transport errors never enter status. + Failure string + Mismatch bool +} + +type AuthorityObservation struct { + ID string + Nameservers []string + SOAOwner string + Authoritative bool + TTL *uint32 + NegativeTTL *uint32 + Failure string +} + +type ZoneEvidence struct { + PolicyFingerprint string + Zone string + Views []AuthorityObservation + Authorities []string +} + +func CanonicalName(name string) string { return strings.TrimSuffix(strings.ToLower(name), ".") } + +func Names(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, CanonicalName(value)) + } + slices.Sort(out) + return slices.Compact(out) +} + +func Addresses(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if ip, err := netip.ParseAddr(value); err == nil { + value = ip.Unmap().String() + } + out = append(out, value) + } + slices.Sort(out) + return slices.Compact(out) +} + +func Fingerprint(value any) string { + data, _ := json.Marshal(value) + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func minTTL(current *uint32, next *uint32) *uint32 { + if next == nil { + return current + } + if current == nil || *next < *current { + value := *next + return &value + } + return current +} diff --git a/internal/go/providers/external/probe/wire.go b/internal/go/providers/external/probe/wire.go new file mode 100644 index 0000000..ee4dd16 --- /dev/null +++ b/internal/go/providers/external/probe/wire.go @@ -0,0 +1,388 @@ +package probe + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "net/netip" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/config" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "github.com/miekg/dns" + "golang.org/x/time/rate" +) + +const ( + DefaultQPS = 40 + DefaultBurst = 80 + MaxConcurrentNames = 4 + ExchangeTimeout = 2 * time.Second + NameTimeout = 10 * time.Second + MaxCNAMEs = 8 +) + +type WireOptions struct { + QPS float64 + Burst int + ResolvConf string +} + +type WireResolver struct { + limiter *rate.Limiter + slots chan struct{} + resolvConf string +} + +func NewWireResolver(options WireOptions) (*WireResolver, error) { + if options.QPS == 0 { + options.QPS = DefaultQPS + } + if options.Burst == 0 { + options.Burst = DefaultBurst + } + if options.QPS <= 0 || math.IsNaN(options.QPS) || math.IsInf(options.QPS, 0) || options.Burst < 1 { + return nil, fmt.Errorf("external DNS QPS and burst must be positive and finite") + } + if options.ResolvConf == "" { + options.ResolvConf = "/etc/resolv.conf" + } + return &WireResolver{limiter: rate.NewLimiter(rate.Limit(options.QPS), options.Burst), slots: make(chan struct{}, MaxConcurrentNames), resolvConf: options.ResolvConf}, nil +} + +// EffectivePolicy rereads resolv.conf each reconcile so changes invalidate receipts. +func (r *WireResolver) EffectivePolicy(policy externalv1alpha1.ExternalZoneClassParameters) (externalv1alpha1.ExternalZoneClassParameters, error) { + policy.Resolver.Servers = slices.Clone(policy.Resolver.Servers) + if policy.Resolver.Mode == "" { + policy.Resolver.Mode = externalv1alpha1.ResolverModeCluster + } + if policy.Resolver.Mode == externalv1alpha1.ResolverModeCluster { + conf, err := dns.ClientConfigFromFile(r.resolvConf) + if err != nil { + return policy, errors.New("resolver-config") + } + policy.Resolver.Servers = nil + for _, host := range conf.Servers { + ip, err := netip.ParseAddr(host) + if err != nil || ip.Zone() != "" { + return policy, errors.New("resolver-config") + } + policy.Resolver.Servers = append(policy.Resolver.Servers, net.JoinHostPort(ip.String(), conf.Port)) + } + slices.Sort(policy.Resolver.Servers) + policy.Resolver.Servers = slices.Compact(policy.Resolver.Servers) + } + if config.ValidateServers(policy.Resolver.Servers) != nil { + return policy, errors.New("resolver-config") + } + slices.Sort(policy.Resolver.Servers) + return policy, nil +} + +func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeResult, error) { + if config.ValidateServers(request.Policy.Resolver.Servers) != nil { + return ProbeResult{}, errors.New("resolver-config") + } + queued := time.Now() + if !request.DueTime.IsZero() { + queued = request.DueTime + } + select { + case r.slots <- struct{}{}: + defer func() { <-r.slots }() + case <-ctx.Done(): + return ProbeResult{}, ctx.Err() + } + // Admit the first exchange before starting the name deadline. + if err := r.limiter.Wait(ctx); err != nil { + return ProbeResult{}, err + } + ObserveQueueDelay(time.Since(queued)) + ctx, cancel := context.WithTimeout(ctx, NameTimeout) + defer cancel() + start := time.Now() + session := wireSession{resolver: r, firstToken: true, authorityAnswers: map[questionKey]*dns.Msg{}, qtypes: map[uint16]int{}} + var result ProbeResult + if request.ZoneOnly { + result.Zone = session.zone(ctx, request) + } else { + for _, server := range request.Policy.Resolver.Servers { + result.Views = append(result.Views, session.resolve(ctx, request.Name, server, "", request.Zone)) + } + if len(request.Policy.ExpectedNameservers) != 0 { + if request.Evidence == nil || request.Evidence.Zone != request.Zone || request.Evidence.PolicyFingerprint != Fingerprint(request.Policy) || len(request.Evidence.Authorities) == 0 { + return ProbeResult{}, errors.New("invalid-response") + } + for _, authority := range request.Evidence.Authorities { + view := session.resolve(ctx, request.Name, request.Policy.Resolver.Servers[0], authority, request.Zone) + result.Views = append(result.Views, view) + foreign := false + for _, target := range view.Chain { + if target != request.Zone && !strings.HasSuffix(target, "."+request.Zone) { + foreign = true + break + } + } + if foreign { + for _, server := range request.Policy.Resolver.Servers[1:] { + result.Views = append(result.Views, session.resolve(ctx, request.Name, server, authority, request.Zone)) + } + } + } + } + } + decision := Evaluate(request, result) + if request.ZoneOnly { + decision = EvaluateZone(request, result.Zone) + } + outcome := decision.Outcome() + for qtype, count := range session.qtypes { + probes.WithLabelValues(outcome, dns.TypeToString[qtype]).Add(float64(count)) + } + duration.WithLabelValues(outcome).Observe(time.Since(start).Seconds()) + return result, nil +} + +type wireSession struct { + resolver *WireResolver + firstToken bool + authorityAnswers map[questionKey]*dns.Msg + qtypes map[uint16]int +} + +type questionKey struct { + server, name string + qtype uint16 +} + +func (s *wireSession) exchange(ctx context.Context, server, name string, qtype uint16, recursive bool) (*dns.Msg, string) { + key := questionKey{server, name, qtype} + if !recursive { + if reply := s.authorityAnswers[key]; reply != nil { + return reply, "" + } + } + if s.firstToken { + s.firstToken = false + } else if err := s.resolver.limiter.Wait(ctx); err != nil { + return nil, "timeout" + } + request := new(dns.Msg) + request.SetQuestion(dns.Fqdn(name), qtype) + request.RecursionDesired = recursive + request.Opcode = dns.OpcodeQuery + if s.qtypes != nil { + s.qtypes[qtype]++ + } + client := &dns.Client{Net: "udp", Timeout: ExchangeTimeout} + reply, _, err := client.ExchangeContext(ctx, request, server) + if err == nil && reply != nil && reply.Truncated { + if err = s.resolver.limiter.Wait(ctx); err == nil { + client.Net = "tcp" + reply, _, err = client.ExchangeContext(ctx, request, server) + } + } + failure := "" + if err != nil { + failure = "transport" + var netErr net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { + failure = "timeout" + } + } else if reply == nil || reply.Truncated || !reply.Response || reply.Opcode != dns.OpcodeQuery || len(reply.Question) != 1 || reply.Question[0] != request.Question[0] { + failure = "invalid-response" + } else { + switch reply.Rcode { + case dns.RcodeSuccess, dns.RcodeNameError: + case dns.RcodeServerFailure: + failure = "SERVFAIL" + case dns.RcodeRefused: + failure = "REFUSED" + default: + failure = "invalid-response" + } + } + if failure == "" && !recursive && s.authorityAnswers != nil { + s.authorityAnswers[key] = reply + } + return reply, failure +} + +func (s *wireSession) resolve(ctx context.Context, name, recursive, authority, zone string) View { + view := View{ID: "recursive/" + recursive} + if authority != "" { + view.ID = "authority/" + authority + "/via/" + recursive + } + current := CanonicalName(name) + seen := map[string]bool{} + for { + if seen[current] { + view.Mismatch = true + return view + } + seen[current] = true + server, rd := recursive, true + if authority != "" && (current == zone || strings.HasSuffix(current, "."+zone)) { + server, rd = authority, false + } + var targets, a, aaaa []string + rcodes := [2]int{} + for i, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + reply, failure := s.exchange(ctx, server, current, qtype, rd) + if failure != "" { + view.Failure = failure + return view + } + if !rd && !reply.Authoritative { + view.Failure = "invalid-response" + return view + } + if !rd { + view.Authoritative = true + } + rcodes[i] = reply.Rcode + ownerAnswers := 0 + for _, rr := range reply.Answer { + if CanonicalName(rr.Header().Name) != current { + continue + } + ttl := rr.Header().Ttl + switch value := rr.(type) { + case *dns.CNAME: + targets = append(targets, CanonicalName(value.Target)) + ownerAnswers++ + case *dns.A: + a = append(a, value.A.String()) + ownerAnswers++ + case *dns.AAAA: + aaaa = append(aaaa, value.AAAA.String()) + ownerAnswers++ + default: + continue + } + view.TTL = minTTL(view.TTL, &ttl) + } + if reply.Rcode == dns.RcodeNameError || ownerAnswers == 0 { + view.NegativeTTL = minTTL(view.NegativeTTL, negativeTTL(reply)) + } + } + if rcodes[0] != rcodes[1] { + view.Failure = "invalid-response" + return view + } + view.RCode = rcodes[0] + targets = Names(targets) + if len(targets) == 0 { + view.A, view.AAAA = Addresses(a), Addresses(aaaa) + return view + } + if len(targets) != 1 || len(a)+len(aaaa) != 0 { + view.Mismatch = true + view.Chain = append(view.Chain, targets...) + return view + } + view.Chain = append(view.Chain, targets[0]) + if len(view.Chain) > MaxCNAMEs { + view.Failure = "chain-limit" + return view + } + current = targets[0] + } +} + +func negativeTTL(reply *dns.Msg) *uint32 { + var ttl *uint32 + for _, rr := range reply.Ns { + if soa, ok := rr.(*dns.SOA); ok { + value := min(soa.Hdr.Ttl, soa.Minttl) + ttl = minTTL(ttl, &value) + } + } + return ttl +} + +func (s *wireSession) apex(ctx context.Context, server, zone string, rd bool) AuthorityObservation { + view := AuthorityObservation{ID: "recursive/" + server} + if !rd { + view.ID = "authority/" + server + } + for _, qtype := range []uint16{dns.TypeNS, dns.TypeSOA} { + reply, failure := s.exchange(ctx, server, zone, qtype, rd) + if failure != "" { + view.Failure = failure + return view + } + if !rd && !reply.Authoritative { + view.Failure = "invalid-response" + return view + } + view.Authoritative = reply.Authoritative + view.NegativeTTL = minTTL(view.NegativeTTL, negativeTTL(reply)) + for _, rr := range reply.Answer { + if CanonicalName(rr.Header().Name) != zone { + continue + } + ttl := rr.Header().Ttl + switch value := rr.(type) { + case *dns.NS: + view.Nameservers = append(view.Nameservers, CanonicalName(value.Ns)) + case *dns.SOA: + view.SOAOwner = CanonicalName(value.Hdr.Name) + default: + continue + } + view.TTL = minTTL(view.TTL, &ttl) + } + } + view.Nameservers = Names(view.Nameservers) + return view +} + +func (s *wireSession) zone(ctx context.Context, request ProbeRequest) *ZoneEvidence { + evidence := &ZoneEvidence{Zone: request.Zone, PolicyFingerprint: Fingerprint(request.Policy)} + if len(request.Policy.ExpectedNameservers) == 0 { + return evidence + } + for _, server := range request.Policy.Resolver.Servers { + evidence.Views = append(evidence.Views, s.apex(ctx, server, request.Zone, true)) + } + // A wrong delegation is completed evidence; never send to unverified authority destinations. + if evaluateZone(request, evidence, false).Status != "True" { + return evidence + } + for _, host := range request.Policy.ExpectedNameservers { + var addresses []string + for viewIndex, server := range request.Policy.Resolver.Servers { + view := s.resolve(ctx, host, server, "", "") + if view.Failure != "" || view.Mismatch || view.RCode != dns.RcodeSuccess || len(view.A)+len(view.AAAA) == 0 { + failure := view.Failure + if failure == "" { + failure = "invalid-response" + } + evidence.Views = append(evidence.Views, AuthorityObservation{ID: view.ID, Failure: failure}) + return evidence + } + evidence.Views[viewIndex].TTL = minTTL(evidence.Views[viewIndex].TTL, view.TTL) + addresses = append(addresses, view.A...) + addresses = append(addresses, view.AAAA...) + } + addresses = Addresses(addresses) + if len(addresses) > 2 { + evidence.Views = append(evidence.Views, AuthorityObservation{ID: "authority/" + host, Failure: "probe-limit"}) + return evidence + } + for _, address := range addresses { + evidence.Authorities = append(evidence.Authorities, net.JoinHostPort(address, "53")) + } + } + slices.Sort(evidence.Authorities) + evidence.Authorities = slices.Compact(evidence.Authorities) + for _, authority := range evidence.Authorities { + evidence.Views = append(evidence.Views, s.apex(ctx, authority, request.Zone, false)) + } + return evidence +} diff --git a/internal/go/providers/external/probe/wire_test.go b/internal/go/providers/external/probe/wire_test.go new file mode 100644 index 0000000..0bdc92e --- /dev/null +++ b/internal/go/providers/external/probe/wire_test.go @@ -0,0 +1,158 @@ +package probe + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "github.com/miekg/dns" +) + +func localDNS(t *testing.T, handler dns.HandlerFunc) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + packet, err := net.ListenPacket("udp", listener.Addr().String()) + if err != nil { + listener.Close() + t.Fatal(err) + } + udpReady, tcpReady := make(chan struct{}), make(chan struct{}) + udp := &dns.Server{PacketConn: packet, Handler: handler, NotifyStartedFunc: func() { close(udpReady) }} + tcp := &dns.Server{Listener: listener, Handler: handler, NotifyStartedFunc: func() { close(tcpReady) }} + go func() { _ = udp.ActivateAndServe() }() + go func() { _ = tcp.ActivateAndServe() }() + t.Cleanup(func() { _ = udp.Shutdown(); _ = tcp.Shutdown() }) + for _, ready := range []chan struct{}{udpReady, tcpReady} { + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("fixture did not start") + } + } + return listener.Addr().String() +} + +func TestWireTruncationAndNegativeSOA(t *testing.T) { + var udpQueries, tcpQueries atomic.Int32 + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + if request.Opcode != dns.OpcodeQuery || !request.RecursionDesired || !strings.HasSuffix(request.Question[0].Name, ".") { + t.Errorf("not an absolute recursive QUERY: %#v", request) + } + reply := new(dns.Msg) + reply.SetReply(request) + if _, ok := w.RemoteAddr().(*net.UDPAddr); ok { + udpQueries.Add(1) + reply.Truncated = true + } else { + tcpQueries.Add(1) + reply.Rcode = dns.RcodeNameError + reply.Ns = []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: "example.test.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 600}, Ns: "ns.example.test.", Mbox: "hostmaster.example.test.", Minttl: 120}} + } + _ = w.WriteMsg(reply) + }) + resolver, err := NewWireResolver(WireOptions{}) + if err != nil { + t.Fatal(err) + } + request := ProbeRequest{Name: "absent.example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Mode: externalv1alpha1.ResolverModeRecursive, Servers: []string{server}}}, Expected: RRsets{A: []string{"192.0.2.1"}}} + result, err := resolver.Probe(context.Background(), request) + if err != nil { + t.Fatal(err) + } + decision := Evaluate(request, result) + if decision.Reason != "DNSNameNotFound" || decision.NegativeTTL == nil || *decision.NegativeTTL != 120 { + t.Fatalf("decision = %#v", decision) + } + if udpQueries.Load() != 2 || tcpQueries.Load() != 2 { + t.Fatalf("UDP=%d TCP=%d", udpQueries.Load(), tcpQueries.Load()) + } +} + +func TestWireCNAMEAndAuthorityRD(t *testing.T) { + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + if request.Opcode != dns.OpcodeQuery { + t.Error("DNS mutation opcode") + } + if request.RecursionDesired { + t.Error("authority query requested recursion") + } + reply := new(dns.Msg) + reply.SetReply(request) + reply.Authoritative = true + question := request.Question[0] + if question.Name == "www.example.test." { + reply.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 42}, Target: "target.example.test."}} + } else if question.Qtype == dns.TypeA { + reply.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, A: net.ParseIP("192.0.2.1")}} + } + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + session := wireSession{resolver: resolver} + view := session.resolve(context.Background(), "www.example.test", "192.0.2.53:53", server, "example.test") + if view.Failure != "" || len(view.Chain) != 1 || len(view.A) != 1 || !view.Authoritative || view.TTL == nil || *view.TTL != 42 { + t.Fatalf("view = %#v", view) + } +} + +func TestWireNoFallbackAndCancellation(t *testing.T) { + var queries atomic.Int32 + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + queries.Add(1) + reply := new(dns.Msg) + reply.SetReply(request) + reply.Rcode = dns.RcodeRefused + _ = w.WriteMsg(reply) + }) + resolver, _ := NewWireResolver(WireOptions{}) + request := ProbeRequest{Name: "www.example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{server}}}} + result, err := resolver.Probe(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if len(result.Views) != 1 || result.Views[0].Failure != "REFUSED" || queries.Load() != 1 { + t.Fatalf("result = %#v, queries=%d", result, queries.Load()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := resolver.Probe(ctx, request); err == nil { + t.Fatal("canceled probe succeeded") + } + if queries.Load() != 1 { + t.Fatal("canceled probe sent DNS") + } +} + +func TestClusterPolicyRereadAndBounds(t *testing.T) { + path := filepath.Join(t.TempDir(), "resolv.conf") + if err := os.WriteFile(path, []byte("nameserver 192.0.2.53\nsearch must.not.expand\n"), 0600); err != nil { + t.Fatal(err) + } + resolver, _ := NewWireResolver(WireOptions{ResolvConf: path}) + first, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}) + if err != nil || len(first.Resolver.Servers) != 1 || first.Resolver.Servers[0] != "192.0.2.53:53" { + t.Fatalf("policy=%#v error=%v", first, err) + } + if err := os.WriteFile(path, []byte("nameserver 192.0.2.54\n"), 0600); err != nil { + t.Fatal(err) + } + second, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}) + if err != nil || Fingerprint(first) == Fingerprint(second) { + t.Fatal("resolv.conf change did not invalidate policy") + } + if err := os.WriteFile(path, []byte("nameserver 192.0.2.1\nnameserver 192.0.2.2\nnameserver 192.0.2.3\nnameserver 192.0.2.4\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}); err == nil { + t.Fatal("oversized resolver set accepted") + } +} diff --git a/internal/go/providers/route53/controllers/zoneclass/controller.go b/internal/go/providers/route53/controllers/zoneclass/controller.go index 1ff95f3..2791044 100644 --- a/internal/go/providers/route53/controllers/zoneclass/controller.go +++ b/internal/go/providers/route53/controllers/zoneclass/controller.go @@ -81,7 +81,7 @@ func (r *ZoneClassReconciler) acceptZoneClass(ctx context.Context, zoneClass *dn return metav1.ConditionFalse, "InvalidParameters", "zoneType Private requires zoneDeletionPolicy Retain", nil } } - if zoneClass.Spec.IdentityRef.Name == "" { + if zoneClass.Spec.IdentityRef == nil || zoneClass.Spec.IdentityRef.Name == "" { return metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required", nil } @@ -155,7 +155,7 @@ func (r *ZoneClassReconciler) mapIdentityToZoneClasses(ctx context.Context, obj if err != nil || zoneClass.Spec.ControllerName != r.controllerName() || provider == nil || version == nil || provider.Name != r.providerReference().Name { continue } - if zoneClass.Spec.IdentityRef.Name == identity.Name { + if zoneClass.Spec.IdentityRef != nil && zoneClass.Spec.IdentityRef.Name == identity.Name { requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&zoneClass)}) } } diff --git a/internal/go/providers/route53/controllers/zoneclass/controller_test.go b/internal/go/providers/route53/controllers/zoneclass/controller_test.go index aa287a8..4c9b9a2 100644 --- a/internal/go/providers/route53/controllers/zoneclass/controller_test.go +++ b/internal/go/providers/route53/controllers/zoneclass/controller_test.go @@ -36,6 +36,32 @@ func TestZoneClassReconcilerAcceptsRoute53Parameters(t *testing.T) { assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") } +func TestZoneClassReconcilerRejectsMissingIdentityRef(t *testing.T) { + ctx := context.Background() + zoneClass := route53ZoneClass("platform", "route53-public", nil) + zoneClass.Spec.IdentityRef = nil + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), zoneClass). + WithStatusSubresource(&dnsv1alpha1.ZoneClass{}). + Build() + reconciler := &ZoneClassReconciler{Client: k8sClient} + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(zoneClass)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + var got dnsv1alpha1.ZoneClass + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(zoneClass), &got); err != nil { + t.Fatalf("get ZoneClass: %v", err) + } + condition := assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "InvalidIdentityRef") + if condition.Message != "spec.identityRef.name is required" { + t.Fatalf("Accepted message = %q", condition.Message) + } + if requests := reconciler.mapIdentityToZoneClasses(ctx, acceptedRoute53Identity("platform", "route53-dev")); len(requests) != 0 { + t.Fatalf("identity watch mapped credentialless class: %#v", requests) + } +} + func TestZoneClassReconcilerValidatesPrivateZonePolicies(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/go/providers/route53/controllers/zoneclass/test_helpers_test.go b/internal/go/providers/route53/controllers/zoneclass/test_helpers_test.go index 33dd110..ea375ad 100644 --- a/internal/go/providers/route53/controllers/zoneclass/test_helpers_test.go +++ b/internal/go/providers/route53/controllers/zoneclass/test_helpers_test.go @@ -37,7 +37,7 @@ func route53ZoneClass(namespace, name string, tags map[string]string) *dnsv1alph Spec: dnsv1alpha1.ZoneClassSpec{ ControllerName: DefaultControllerName, Provider: route53v1alpha1.ProviderRef, - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, + IdentityRef: &dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, Parameters: runtime.RawExtension{Raw: raw}, }, } diff --git a/internal/go/providers/route53/controllers/zoneunit/zone.go b/internal/go/providers/route53/controllers/zoneunit/zone.go index f5d4f3d..37e0567 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone.go @@ -271,7 +271,7 @@ func (r *ZoneReconciler) mapIdentityToZoneUnits(ctx context.Context, obj client. if err != nil || zoneClass.Spec.ControllerName != r.controllerName() || !route53ProviderVersionMatches(provider, version, r.providerReference()) { continue } - if zoneClass.Spec.IdentityRef.Name == identity.Name { + if zoneClass.Spec.IdentityRef != nil && zoneClass.Spec.IdentityRef.Name == identity.Name { requests = appendUniqueRequests(requests, r.zoneUnitRequestsForZoneClass(ctx, zoneClass.Namespace, zoneClass.Name)...) } } @@ -380,7 +380,7 @@ func (r *ZoneReconciler) acceptZone(ctx context.Context, zone *dnsv1alpha1.Zone) } func (r *ZoneReconciler) acceptIdentityRef(ctx context.Context, zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass) (*route53v1alpha1.Route53Identity, bool, error) { - if zoneClass.Spec.IdentityRef.Name == "" { + if zoneClass.Spec.IdentityRef == nil || zoneClass.Spec.IdentityRef.Name == "" { return nil, false, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required") } diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go index 5cf9dd1..ab3d361 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go @@ -67,7 +67,7 @@ func route53ZoneClass(namespace, name string, tags map[string]string) *dnsv1alph Spec: dnsv1alpha1.ZoneClassSpec{ Provider: route53v1alpha1.ProviderRef, ControllerName: DefaultControllerName, - IdentityRef: dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, + IdentityRef: &dnsv1alpha1.LocalObjectReference{Name: "route53-dev"}, Parameters: runtime.RawExtension{Raw: raw}, AllowedZones: dnsv1alpha1.ZoneClassAllowedZones{ Namespaces: dnsv1alpha1.NamespacePolicy{From: dnsv1alpha1.NamespacesFromSame}, diff --git a/pkg/go/api/dns/v1alpha1/scheme_test.go b/pkg/go/api/dns/v1alpha1/scheme_test.go index 7190c97..21c8cef 100644 --- a/pkg/go/api/dns/v1alpha1/scheme_test.go +++ b/pkg/go/api/dns/v1alpha1/scheme_test.go @@ -1,6 +1,7 @@ package v1alpha1_test import ( + "encoding/json" "testing" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" @@ -38,3 +39,27 @@ func TestAddToSchemeRegistersCoreTypes(t *testing.T) { } } } + +func TestZoneClassOptionalIdentityWireAndDeepCopy(t *testing.T) { + zoneClass := &dnsv1alpha1.ZoneClass{} + payload, err := json.Marshal(zoneClass.Spec) + if err != nil { + t.Fatalf("marshal ZoneClass spec: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode ZoneClass spec: %v", err) + } + if _, present := fields["identityRef"]; present { + t.Fatalf("credentialless ZoneClass serialized identityRef: %s", payload) + } + if copied := zoneClass.DeepCopy(); copied.Spec.IdentityRef != nil { + t.Fatalf("DeepCopy manufactured identityRef: %#v", copied.Spec.IdentityRef) + } + zoneClass.Spec.IdentityRef = &dnsv1alpha1.LocalObjectReference{Name: "original"} + copied := zoneClass.DeepCopy() + copied.Spec.IdentityRef.Name = "changed" + if zoneClass.Spec.IdentityRef.Name != "original" { + t.Fatal("DeepCopy aliased the original identityRef") + } +} diff --git a/pkg/go/api/dns/v1alpha1/zoneclass_types.go b/pkg/go/api/dns/v1alpha1/zoneclass_types.go index 38bdddc..781dd8d 100644 --- a/pkg/go/api/dns/v1alpha1/zoneclass_types.go +++ b/pkg/go/api/dns/v1alpha1/zoneclass_types.go @@ -30,7 +30,9 @@ type ZoneClassSpec struct { ControllerName string `json:"controllerName"` // IdentityRef selects the provider identity resource in the ZoneClass namespace. - IdentityRef LocalObjectReference `json:"identityRef"` + // Required when the selected Provider version declares identity; forbidden otherwise. + // +optional + IdentityRef *LocalObjectReference `json:"identityRef,omitempty"` // Parameters contains provider-specific ZoneClass settings. // +kubebuilder:pruning:PreserveUnknownFields diff --git a/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go index 9c7ad64..b1164db 100644 --- a/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -1154,7 +1154,11 @@ func (in *ZoneClassReference) DeepCopy() *ZoneClassReference { func (in *ZoneClassSpec) DeepCopyInto(out *ZoneClassSpec) { *out = *in out.Provider = in.Provider - out.IdentityRef = in.IdentityRef + if in.IdentityRef != nil { + in, out := &in.IdentityRef, &out.IdentityRef + *out = new(LocalObjectReference) + **out = **in + } in.Parameters.DeepCopyInto(&out.Parameters) in.AllowedZones.DeepCopyInto(&out.AllowedZones) } diff --git a/pkg/go/api/external/v1alpha1/doc.go b/pkg/go/api/external/v1alpha1/doc.go new file mode 100644 index 0000000..bf66017 --- /dev/null +++ b/pkg/go/api/external/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Package v1alpha1 contains the external provider's inline verification payloads. +// +kubebuilder:object:generate=true +// +groupName=external.dns.appthrust.io +package v1alpha1 diff --git a/pkg/go/api/external/v1alpha1/register.go b/pkg/go/api/external/v1alpha1/register.go new file mode 100644 index 0000000..5bc8127 --- /dev/null +++ b/pkg/go/api/external/v1alpha1/register.go @@ -0,0 +1,19 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = ProviderName + +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: ProviderVersion} +var SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) +var AddToScheme = SchemeBuilder.AddToScheme + +// External has inline payload types only: no identity, Zone or RecordSet CRDs. +func addKnownTypes(scheme *runtime.Scheme) error { + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/go/api/external/v1alpha1/types.go b/pkg/go/api/external/v1alpha1/types.go new file mode 100644 index 0000000..d5582da --- /dev/null +++ b/pkg/go/api/external/v1alpha1/types.go @@ -0,0 +1,53 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + ProviderName = "external.dns.appthrust.io" + ProviderVersion = "v1alpha1" + ControllerName = ProviderName + "/controller" +) + +type ResolverMode string + +const ( + ResolverModeCluster ResolverMode = "Cluster" + ResolverModeRecursive ResolverMode = "Recursive" +) + +// ExternalZoneClassParameters is an inline payload, not a Kubernetes resource. +type ExternalZoneClassParameters struct { + Resolver ResolverPolicy `json:"resolver,omitempty"` + ExpectedNameservers []string `json:"expectedNameservers,omitempty"` +} + +// ResolverPolicy selects independent recursive views; servers are canonical IP:port endpoints. +type ResolverPolicy struct { + Mode ResolverMode `json:"mode,omitempty"` + Servers []string `json:"servers,omitempty"` +} + +// VerificationValues is a bounded preview. Comparisons always use the complete observation. +type VerificationValues struct { + Values []string `json:"values,omitempty"` + Total int32 `json:"total"` + Truncated bool `json:"truncated"` +} + +// ExternalVerificationData is public, non-secret evidence shared by Zone and RecordSet status. +// Values are typed (for example "A 192.0.2.1"); chain and views are bounded diagnostics. +type ExternalVerificationData struct { + ResolverMode ResolverMode `json:"resolverMode"` + CheckedName string `json:"checkedName"` + SampledWildcard bool `json:"sampledWildcard,omitempty"` + Actual VerificationValues `json:"actual"` + Missing VerificationValues `json:"missing"` + Extra VerificationValues `json:"extra"` + CNAMEChain VerificationValues `json:"cnameChain"` + Views VerificationValues `json:"views"` + ObservedTTLSeconds *uint32 `json:"observedTTLSeconds,omitempty"` + NegativeTTLSeconds *uint32 `json:"negativeTTLSeconds,omitempty"` + NegativeExpiryTime *metav1.Time `json:"negativeExpiryTime,omitempty"` + LastProbeTime *metav1.Time `json:"lastProbeTime,omitempty"` + NextProbeTime *metav1.Time `json:"nextProbeTime,omitempty"` +} diff --git a/pkg/go/api/external/v1alpha1/types_test.go b/pkg/go/api/external/v1alpha1/types_test.go new file mode 100644 index 0000000..02a15fd --- /dev/null +++ b/pkg/go/api/external/v1alpha1/types_test.go @@ -0,0 +1,26 @@ +package v1alpha1 + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" +) + +func TestInlineOnlySchemeAndDeepcopy(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"ExternalIdentity", "ExternalZone", "ExternalRecordSet"} { + if scheme.Recognizes(SchemeGroupVersion.WithKind(kind)) { + t.Fatalf("unexpected CRD kind %s", kind) + } + } + params := &ExternalZoneClassParameters{Resolver: ResolverPolicy{Mode: ResolverModeRecursive, Servers: []string{"192.0.2.53:53"}}, ExpectedNameservers: []string{"ns.example.test"}} + copy := params.DeepCopy() + copy.Resolver.Servers[0] = "192.0.2.54:53" + copy.ExpectedNameservers[0] = "other.example.test" + if params.Resolver.Servers[0] != "192.0.2.53:53" || params.ExpectedNameservers[0] != "ns.example.test" { + t.Fatal("deepcopy aliases source") + } +} diff --git a/pkg/go/api/external/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/external/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..d4b82ef --- /dev/null +++ b/pkg/go/api/external/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,110 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalVerificationData) DeepCopyInto(out *ExternalVerificationData) { + *out = *in + in.Actual.DeepCopyInto(&out.Actual) + in.Missing.DeepCopyInto(&out.Missing) + in.Extra.DeepCopyInto(&out.Extra) + in.CNAMEChain.DeepCopyInto(&out.CNAMEChain) + in.Views.DeepCopyInto(&out.Views) + if in.ObservedTTLSeconds != nil { + in, out := &in.ObservedTTLSeconds, &out.ObservedTTLSeconds + *out = new(uint32) + **out = **in + } + if in.NegativeTTLSeconds != nil { + in, out := &in.NegativeTTLSeconds, &out.NegativeTTLSeconds + *out = new(uint32) + **out = **in + } + if in.NegativeExpiryTime != nil { + in, out := &in.NegativeExpiryTime, &out.NegativeExpiryTime + *out = (*in).DeepCopy() + } + if in.LastProbeTime != nil { + in, out := &in.LastProbeTime, &out.LastProbeTime + *out = (*in).DeepCopy() + } + if in.NextProbeTime != nil { + in, out := &in.NextProbeTime, &out.NextProbeTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalVerificationData. +func (in *ExternalVerificationData) DeepCopy() *ExternalVerificationData { + if in == nil { + return nil + } + out := new(ExternalVerificationData) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalZoneClassParameters) DeepCopyInto(out *ExternalZoneClassParameters) { + *out = *in + in.Resolver.DeepCopyInto(&out.Resolver) + if in.ExpectedNameservers != nil { + in, out := &in.ExpectedNameservers, &out.ExpectedNameservers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalZoneClassParameters. +func (in *ExternalZoneClassParameters) DeepCopy() *ExternalZoneClassParameters { + if in == nil { + return nil + } + out := new(ExternalZoneClassParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolverPolicy) DeepCopyInto(out *ResolverPolicy) { + *out = *in + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolverPolicy. +func (in *ResolverPolicy) DeepCopy() *ResolverPolicy { + if in == nil { + return nil + } + out := new(ResolverPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationValues) DeepCopyInto(out *VerificationValues) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationValues. +func (in *VerificationValues) DeepCopy() *VerificationValues { + if in == nil { + return nil + } + out := new(VerificationValues) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/ts/ui/src/views/platform/PlatformZoneClassDetailPage.tsx b/pkg/ts/ui/src/views/platform/PlatformZoneClassDetailPage.tsx index fd52533..9433032 100644 --- a/pkg/ts/ui/src/views/platform/PlatformZoneClassDetailPage.tsx +++ b/pkg/ts/ui/src/views/platform/PlatformZoneClassDetailPage.tsx @@ -86,7 +86,13 @@ export function PlatformZoneClassDetailPage() { ); } - const identityName = zoneClassIdentityName(selectedZoneClass); + const providerVersion = data.providers.items + .find(item => nameOf(item) === selectedZoneClass.spec.provider.name) + ?.spec.versions.find(item => item.name === selectedZoneClass.spec.provider.version); + const requiresIdentity = providerVersion + ? Boolean(providerVersion.identity) + : Boolean(selectedZoneClass.spec.identityRef); + const identityName = requiresIdentity ? zoneClassIdentityName(selectedZoneClass) : ''; const identity = identityName ? findIdentity(data.identities.items, namespaceOf(selectedZoneClass), identityName) : undefined; @@ -133,6 +139,7 @@ export function PlatformZoneClassDetailPage() { health={health} identity={identity} identityName={identityName} + requiresIdentity={requiresIdentity} provider={provider} usedBy={referencingZones.length} zoneClass={selectedZoneClass} @@ -162,6 +169,7 @@ export function PlatformZoneClassDetailPage() { zoneClass={selectedZoneClass} identity={identity} identityName={identityName} + requiresIdentity={requiresIdentity} events={events} eventsError={data.events.error} onOpenIdentity={ @@ -304,6 +312,7 @@ function ZoneClassVitalsStrip({ health, identity, identityName, + requiresIdentity, provider, usedBy, zoneClass, @@ -313,6 +322,7 @@ function ZoneClassVitalsStrip({ health: Health; identity?: ProviderIdentity; identityName?: string; + requiresIdentity: boolean; provider: string; usedBy: number; zoneClass: ZoneClass; @@ -364,6 +374,7 @@ function ZoneClassVitalsStrip({ {`${zoneClass.spec.provider.name}/${zoneClass.spec.provider.version}`} + {requiresIdentity ? ( {identity ? ( @@ -383,6 +394,7 @@ function ZoneClassVitalsStrip({ )} + ) : null} @@ -500,6 +512,7 @@ function StatusTab({ zoneClass, identity, identityName, + requiresIdentity, events, eventsError, onOpenIdentity, @@ -507,6 +520,7 @@ function StatusTab({ zoneClass: ZoneClass; identity?: ProviderIdentity; identityName?: string; + requiresIdentity: boolean; events: KubeEvent[]; eventsError: unknown; onOpenIdentity?: () => void; @@ -524,8 +538,9 @@ function StatusTab({ label="ZoneClass" health={zoneClassHealth} reason={conditionSummary(zoneClass.status?.conditions)} - divided + divided={requiresIdentity} /> + {requiresIdentity ? ( + ) : null} diff --git a/pkg/ts/ui/src/views/platform/PlatformZoneClassIdentitySelectPage.tsx b/pkg/ts/ui/src/views/platform/PlatformZoneClassIdentitySelectPage.tsx index f384345..4ba45e0 100644 --- a/pkg/ts/ui/src/views/platform/PlatformZoneClassIdentitySelectPage.tsx +++ b/pkg/ts/ui/src/views/platform/PlatformZoneClassIdentitySelectPage.tsx @@ -1,6 +1,8 @@ /** @jsxRuntime classic */ import React from 'react'; import { useDnsData } from '../../api/dns'; +import type { Provider } from '../../types/resources'; +import { platformNamespaces } from './resources'; import { PLATFORM_ZONE_CLASSES_PATH, PlatformRouteFrame, @@ -8,16 +10,33 @@ import { ZONE_CLASSES_BREADCRUMB, zoneClassNewPath, } from './routes'; -import { ZoneClassIdentitySelectPage } from './ZoneClassForm'; +import { ZoneClassFormPage, ZoneClassIdentitySelectPage } from './ZoneClassForm'; export function PlatformZoneClassIdentitySelectPage() { const navigation = usePlatformNavigation(); const data = useDnsData(); + const [provider, setProvider] = React.useState(); + if (provider) { + return ( + + setProvider(undefined)} + onSaved={() => navigation.push(PLATFORM_ZONE_CLASSES_PATH)} + /> + + ); + } return ( navigation.push(PLATFORM_ZONE_CLASSES_PATH)} onSelectIdentity={identity => navigation.push(zoneClassNewPath(identity))} /> diff --git a/pkg/ts/ui/src/views/platform/PlatformZoneClassesPage.tsx b/pkg/ts/ui/src/views/platform/PlatformZoneClassesPage.tsx index 949b383..a6cb060 100644 --- a/pkg/ts/ui/src/views/platform/PlatformZoneClassesPage.tsx +++ b/pkg/ts/ui/src/views/platform/PlatformZoneClassesPage.tsx @@ -31,7 +31,10 @@ export function PlatformZoneClassesPage() { const navigation = usePlatformNavigation(); const data = useDnsData(); const { canCreateZoneClass } = useZoneClassAccess(); - const hasIdentities = data.identities.items.length > 0; + const hasProviderSelection = data.identities.items.length > 0 || data.providers.items.some(provider => { + const version = provider.spec.versions.find(item => item.served); + return version && !version.identity; + }); return ( @@ -39,7 +42,7 @@ export function PlatformZoneClassesPage() { title="Zone Classes" description="ZoneClasses publish provider-backed DNS zone capability to selected namespaces." action={ - hasIdentities ? ( + hasProviderSelection ? ( - {!hasIdentities ? ( + {!hasProviderSelection ? ( [0] { + return { + namespace: 'platform', + name: 'customer-dns', + description: '', + provider: `fixture.dns.example/${version}`, + controllerName: 'fixture.dns.example/controller', + identityName: 'stale-selection', + allowedFrom: 'Same', + allowedLabelsText: '', + tags: '', + parametersJSON: '{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53"]},"expectedNameservers":["ns.example.net"]}', + zoneType: 'Public', + zoneCreationPolicy: 'Create', + zoneDeletionPolicy: 'Retain', + sameNameZonePolicy: 'Deny', + }; +} + +describe('ZoneClass provider identity contract', () => { + it('omits identity and writer parameters for the selected credentialless version', () => { + const manifest = buildZoneClassManifest(form('v1credentialless'), provider); + expect(manifest.spec.provider.version).toBe('v1credentialless'); + expect('identityRef' in manifest.spec).toBe(false); + expect(manifest.spec.parameters).toEqual({ + resolver: { mode: 'Recursive', servers: ['192.0.2.53:53'] }, + expectedNameservers: ['ns.example.net'], + }); + }); + + it('retains identity and writer parameters for an identity-backed version', () => { + const manifest = buildZoneClassManifest(form('v1identity'), provider); + expect(manifest.spec.identityRef).toEqual({ name: 'stale-selection' }); + expect(manifest.spec.parameters).toEqual({ zoneCreationPolicy: 'Create', zoneDeletionPolicy: 'Retain' }); + }); + + it('does not guess credentialless operation when the Provider version is unavailable', () => { + expect(() => buildZoneClassManifest(form('v1missing'), provider)).toThrow('Provider version is required'); + }); + + it('rejects non-object credentialless parameters', () => { + expect(() => buildZoneClassManifest({ ...form('v1credentialless'), parametersJSON: '[]' }, provider)).toThrow(); + }); +}); diff --git a/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx b/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx index 58d3d6d..f29b0ba 100644 --- a/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx +++ b/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx @@ -9,8 +9,8 @@ import { Stack } from '../../components/primitives'; import { Typography } from '../../components/primitives'; import React from 'react'; import { z } from 'zod'; -import { createZoneClass, updateZoneClass } from '../../api/dns'; -import { nameOf, namespaceOf, resourceKey, zoneClassIdentityName } from '../../resources'; +import { createZoneClass, updateZoneClass, useAccess } from '../../api/dns'; +import { nameOf, namespaceOf, resourceKey, zoneClassIdentityName, ZoneClassResource } from '../../resources'; import type { Provider, ProviderIdentity, ZoneClass } from '../../types/resources'; import { type BreadcrumbItem, @@ -23,6 +23,7 @@ import { IdentitySummaryCard, namespaceLabelsYaml, parseNamespaceLabelsYaml, + readJSON, ToolbarButton, ui, useDnsFormState, @@ -41,6 +42,7 @@ type ZoneClassForm = { allowedFrom: 'Same' | 'Selector' | 'All'; allowedLabelsText: string; tags: string; + parametersJSON: string; zoneType: 'Public' | 'Private'; zoneCreationPolicy: 'Create' | 'Deny'; zoneDeletionPolicy: 'Delete' | 'Retain'; @@ -62,6 +64,7 @@ const zoneClassFormSchema = z.object({ allowedFrom: z.enum(['Same', 'Selector', 'All']), allowedLabelsText: z.string(), tags: z.string(), + parametersJSON: z.string(), zoneType: z.enum(['Public', 'Private']), zoneCreationPolicy: z.enum(['Create', 'Deny']), zoneDeletionPolicy: z.enum(['Delete', 'Retain']), @@ -116,11 +119,13 @@ function zoneClassParametersFromForm( function buildZoneClassDesiredManifest({ form: rawForm, provider, + providerDefinition, baseParameters, validateSelector = true, }: { form: ZoneClassForm; provider: string; + providerDefinition?: Provider; baseParameters?: ZoneClass['spec']['parameters']; validateSelector?: boolean; }): ZoneClass { @@ -132,6 +137,12 @@ function buildZoneClassDesiredManifest({ throw new Error('Controller name is required'); } const providerObject = providerRefObject(provider); + const version = nameOf(providerDefinition) === providerObject.name + ? providerDefinition?.spec.versions.find(item => item.name === providerObject.version) + : undefined; + if (!version) { + throw new Error('Provider version is required'); + } return { apiVersion: 'dns.appthrust.io/v1alpha1', kind: 'ZoneClass', @@ -139,27 +150,31 @@ function buildZoneClassDesiredManifest({ spec: { provider: providerObject, controllerName: form.controllerName, - identityRef: { name: form.identityName }, + ...(version.identity ? { identityRef: { name: form.identityName } } : {}), allowedZones: allowedZonesFromForm(form, { validateSelector }), - parameters: zoneClassParametersFromForm(form, baseParameters), + parameters: version.identity + ? zoneClassParametersFromForm(form, baseParameters) + : z.record(z.string(), z.unknown()).parse(readJSON(form.parametersJSON, {}, 'Provider parameters')), }, }; } -function buildZoneClassManifest( +export function buildZoneClassManifest( form: ZoneClassForm, provider?: Provider ): ZoneClass { return buildZoneClassDesiredManifest({ form, - provider: providerRef(provider), + provider: form.provider, + providerDefinition: provider, }); } -function buildUpdatedZoneClassManifest(zoneClass: ZoneClass, form: ZoneClassForm): ZoneClass { +function buildUpdatedZoneClassManifest(zoneClass: ZoneClass, form: ZoneClassForm, provider?: Provider): ZoneClass { const desiredManifest = buildZoneClassDesiredManifest({ form, provider: providerRefString(zoneClass.spec.provider), + providerDefinition: provider, baseParameters: zoneClass.spec.parameters, }); const annotations = { ...(zoneClass.metadata?.annotations ?? {}) }; @@ -255,7 +270,8 @@ function zoneClassPreview( try { return buildZoneClassDesiredManifest({ form, - provider: providerRef(provider), + provider: form.provider, + providerDefinition: provider, validateSelector: false, }); } catch (error) { @@ -281,6 +297,7 @@ function zoneClassFormFromResource(zoneClass: ZoneClass): ZoneClassForm { allowedFrom, allowedLabelsText: namespaceLabelsYaml(selector?.matchLabels), tags: namespaceLabelsYaml(zoneClass.spec.parameters.tags), + parametersJSON: JSON.stringify(zoneClass.spec.parameters, null, 2), zoneType: zoneClass.spec.parameters.zoneType ?? 'Public', zoneCreationPolicy: zoneClass.spec.parameters.zoneCreationPolicy ?? 'Create', zoneDeletionPolicy: zoneClass.spec.parameters.zoneDeletionPolicy ?? 'Retain', @@ -290,12 +307,14 @@ function zoneClassFormFromResource(zoneClass: ZoneClass): ZoneClassForm { function updatedZoneClassPreview( zoneClass: ZoneClass, - form: ZoneClassForm + form: ZoneClassForm, + provider?: Provider ): ZoneClass | { error: string } { try { return buildZoneClassDesiredManifest({ form, provider: providerRefString(zoneClass.spec.provider), + providerDefinition: provider, baseParameters: zoneClass.spec.parameters, validateSelector: false, }); @@ -306,25 +325,43 @@ function updatedZoneClassPreview( export function ZoneClassIdentitySelectPage({ identities, + providers, + onSelectProvider, onCancel, onSelectIdentity, breadcrumb, }: { identities: ProviderIdentity[]; + providers: Provider[]; + onSelectProvider: (provider: Provider) => void; onCancel: () => void; onSelectIdentity: (identity: ProviderIdentity) => void; breadcrumb?: BreadcrumbItem[]; }) { + const credentiallessProviders = providers.filter(provider => { + const version = provider.spec.versions.find(item => item.served); + return version && !version.identity; + }); return ( } /> + {credentiallessProviders.map(provider => ( + + ))} {identities.length ? ( {identities.map(identity => ( @@ -346,7 +383,7 @@ export function ZoneClassIdentitySelectPage({ ))} - ) : ( + ) : credentiallessProviders.length ? null : ( void; onSaved: () => void; breadcrumb?: BreadcrumbItem[]; }) { const { showSuccess, showError, snackbar } = useNotice(); const isEdit = Boolean(zoneClass); - const selectedIdentityProvider = providerForIdentity(providers, identity); + const selectedIdentityProvider = provider ?? providerForIdentity(providers, identity); const initialForm = React.useMemo( () => zoneClass @@ -418,6 +459,7 @@ export function ZoneClassFormPage({ allowedFrom: 'Selector', allowedLabelsText: '', tags: '', + parametersJSON: '{}', zoneType: 'Public', zoneCreationPolicy: 'Create', zoneDeletionPolicy: 'Delete', @@ -428,14 +470,19 @@ export function ZoneClassFormPage({ const [form, setForm] = useDnsFormState(initialForm); const [errors, setErrors] = React.useState({}); - const selectedProvider = - providers.find( - provider => providerRef(provider) === form.provider - ) ?? selectedIdentityProvider; const providerObject = providerRefObject(form.provider); + const selectedProvider = providers.find(item => nameOf(item) === providerObject.name); + const selectedVersion = selectedProvider?.spec.versions.find(item => item.name === providerObject.version); + const requiresIdentity = Boolean(selectedVersion?.identity); + const canSave = useAccess(ZoneClassResource, isEdit ? 'update' : 'create', { + group: 'dns.appthrust.io', + version: 'v1alpha1', + resource: 'zoneclasses', + namespace: form.namespace, + }); const isPrivateZone = isRoute53ProviderRef(form.provider) && form.zoneType === 'Private'; const preview = zoneClass - ? updatedZoneClassPreview(zoneClass, form) + ? updatedZoneClassPreview(zoneClass, form, selectedProvider) : selectedProvider ? zoneClassPreview(form, selectedProvider) : { error: 'Provider is required' }; @@ -458,7 +505,7 @@ export function ZoneClassFormPage({ setErrors({}); try { if (zoneClass) { - await updateZoneClass(buildUpdatedZoneClassManifest(zoneClass, form)); + await updateZoneClass(buildUpdatedZoneClassManifest(zoneClass, form, selectedProvider)); showSuccess('ZoneClass updated'); } else { await createZoneClass(buildZoneClassManifest(form, selectedProvider)); @@ -485,16 +532,17 @@ export function ZoneClassFormPage({ } /> } > - {identity ? ( + {requiresIdentity ? identity ? ( ) : ( @@ -502,13 +550,28 @@ export function ZoneClassFormPage({ ? `Provider Identity ${form.namespace}/${form.identityName} is not visible.` : 'No Provider Identity selected.'} - )} + ) : null} Namespace - - - The ZoneClass is created in the same namespace as the selected Provider Identity. - + {!requiresIdentity && !isEdit ? ( + + + + ) : ( + + )} + {requiresIdentity ? ( + + The ZoneClass is created in the same namespace as the selected Provider Identity. + + ) : null} {isEdit ? ( @@ -546,6 +609,18 @@ export function ZoneClassFormPage({ Provider controller instance that reconciles ZoneUnits for this ZoneClass. + {!requiresIdentity ? ( + setForm({ ...form, parametersJSON: event.target.value })} + multiline + minRows={6} + fullWidth + /> + ) : ( + <> {isRoute53ProviderRef(form.provider) ? ( Zone type @@ -650,6 +725,8 @@ export function ZoneClassFormPage({ fullWidth /> ) : null} + + )}