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..2036cae 100644 --- a/docs/design/controllers-webhooks.md +++ b/docs/design/controllers-webhooks.md @@ -25,8 +25,31 @@ 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. It must not replace a provider diagnostic with its reason string. This applies to both Accepted and Programmed on Zone and RecordSet and does not weaken UID/generation checks, composition acceptance, or the use of core-generated messages for stale/missing provider results. Providers must supply bounded, non-secret messages suitable for public claim status. + 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 (required contract; not implemented at 87aa1dc) + +Provider enablement is a runtime and permission boundary, not merely a switch for rendering Provider manifests. The required chart contract passes explicit boolean manager flags. + +At the current revision only `--gateway-endpoint-controller-enabled` and `--service-endpoint-controller-enabled` exist as enable flags; `providers.*.enabled` gate manifests only, and identity controllers, identity webhooks, the Route 53 conversion handler, identity RBAC, and Secret read permission are always on (`app/operator/cmd/manager/main.go`, `deploy/charts/dns-api/templates/rbac.yaml`). The flags below are introduced with the external provider. + +| Chart value | Manager flag (new) | Default | Enabled scope | +| --- | --- | --- | --- | +| `providers.route53.enabled` | `--route53-controller-enabled` | true | Route53Identity, Route 53 ZoneClass/ZoneUnit controllers, identity webhook, provider identity RBAC, and the Route 53 Provider manifest. | +| `providers.cloudflare.enabled` | `--cloudflare-controller-enabled` | true | CloudflareIdentity, Cloudflare ZoneClass/ZoneUnit controllers, identity webhook, identity/Secret reads and watches, and the Cloudflare Provider manifest. | +| `providers.external.enabled` | `--external-controller-enabled` | false | External ZoneClass/ZoneUnit controllers, Provider, endpoint conversion handler/capability/APIService, and conversion-create RBAC. No identity controller, identity webhook, or credential reads. | +| `providers.route53.endpoint.conversionAPI.enabled` | `--route53-endpoint-conversion-enabled` | true | Route 53 conversion handler/capability/APIService and conversion-create RBAC, only when Route 53 is enabled. | + +The manager must not call disabled providers' controller or identity-webhook setup functions, register their conversion handlers, construct credential clients, or create their identity/Secret informers. Scheme/CRD types may remain available for reading existing objects. Provider-only RBAC and the corresponding ValidatingWebhookConfiguration entries are gated with registrations; leaving an unserved identity webhook in admission is not disabling a provider. Route 53 conversion requires both Route 53 flags. Cloudflare has no endpoint conversion handler in the current implementation; enablement must not invent an unserved capability. + +Namespace reads and core/endpoint RBAC remain independent. Secret get/list/watch is granted only for an enabled credential consumer (Cloudflare in the built-in chart), not to the external provider or an all-disabled deployment. Mounted serving TLS certificates do not require Kubernetes Secret API read permission. Helm and Kustomize wiring must provide equivalent admission coverage, including ZoneClass status and enabled provider identity spec/status handlers. Existing `gatewayEndpoint.enabled` / `--gateway-endpoint-controller-enabled` and `serviceEndpoint.enabled` / `--service-endpoint-controller-enabled` continue to gate their own registrations and source-only permissions. + +Core composition, endpoint translation, core CRDs, and core schema/status admission are retained when providers are disabled. External enablement requires its conversion API to be reachable through the existing webhook server with verified serving TLS and CA wiring; reject incompatible chart settings instead of publishing an unusable capability or using `insecureSkipTLSVerify`. Provider/controller-name/version flags select runtime bindings and cannot override a disabled flag. Changing flags does not complete cleanup of existing claims; operators must not disable a provider while relying on its finalizers or continuing verification. + +These gates are the required design contract; manifest-only enable switches do not satisfy it. + ## 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 +109,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 +162,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..43aaa2d 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`; the CRD does not structurally require it, and core admission enforces the Provider-dependent requirement. Both generated CRD distributions, Go callers (`internal/go/providers/{route53,cloudflare}/controllers/{zoneclass,zoneunit}`), and the UI ZoneClass form are updated: the form omits `identityRef` and the identity picker step when the selected Provider version declares no `identity`. Route 53 and Cloudflare still require an identity. `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,19 +53,19 @@ 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. ## Zone -`Zone` is a namespace-scoped resource. It is the management unit corresponding to a hosted zone / zone on the DNS provider, connecting Kubernetes ownership boundaries with the provider-side object. +`Zone` is a namespace-scoped resource connecting Kubernetes ownership boundaries with a provider's zone scope. For DNS-writing providers it corresponds to a hosted zone / zone on the DNS provider. For the [external verification provider](external-provider.md#zone-semantics), it is a logical DNS suffix and need not be a separately hosted or delegated zone. `Zone.spec` fields: -- `domainName`: zone apex. Only canonical ASCII values are accepted. Immutable. +- `domainName`: zone apex or logical suffix, according to the Provider contract. Only canonical ASCII values are accepted. Immutable. - `zoneClassRef`: selected `ZoneClass`. If namespace is omitted, it defaults to the `Zone` namespace. Immutable. - `provider.name`: `Provider.metadata.name`. Required and immutable. - `provider.version`: `Provider.spec.versions[].name`. Required and immutable. -- `adoption`: specified when adopting an existing external zone. Omitted for creation. Mutable, but provider controllers stop changes that point to a different external zone after management starts. +- `adoption`: specified when adopting an existing external zone under a writing provider; omitted for creation. The external verification provider does not adopt DNS objects. Mutable, but provider controllers stop changes that point to a different external zone after management starts. - `adoption`: provider-specific opaque object validated by the referenced Provider version `zone.schemas.adoption`. - `allowedRecordSets`: conditions under which `RecordSet` resources from other namespaces may attach. If omitted, only same-namespace `RecordSet` resources are allowed. Each rule combines a namespace selector and record policy. @@ -269,9 +269,9 @@ status: - `IdentityNotResolved`: provider-specific identity object or its accepted state is not resolved enough to decide. - `ProviderConversionUnavailable`: Provider conversion webhook is temporarily unavailable, timed out, or returned a retryable failure. -`Programmed` means the desired state has been reflected to the provider. +`Programmed` means the provider-specific completion criteria for the accepted desired state are satisfied. -`Programmed=True` does not mean DNS is resolvable. It means the last provider-side state observed by the controller matches the accepted desired state and there is no pending provider change. For Route 53, `Programmed=True` means no `pendingHostedZoneChange` exists in `ZoneUnit.status.provider.state` and the observed hosted zone matches `ZoneUnit.spec.zone` and `ZoneClass.spec.parameters`. Parent-zone NS delegation, public DNS resolvability, and resolver reachability are not guaranteed. +`Zone Programmed=True` is not a generic guarantee that records resolve. For Route 53, it means no `pendingHostedZoneChange` exists in `ZoneUnit.status.provider.state` and the observed hosted zone matches `ZoneUnit.spec.zone` and `ZoneClass.spec.parameters`; parent-zone delegation and DNS reachability are not guaranteed. For the external provider, it means the verifier is configured and any requested nameserver constraint is verified, independently of child record success. External RecordSet success requires the separate resolution predicate in [External Provider](external-provider.md#recordset-verification). Core performs no DNS queries. `Programmed=False` reasons: @@ -826,7 +826,7 @@ If a pre-applied `ZoneUnit` contains record-set ownership state whose owner clai - `IdentityNotResolved`: provider-specific identity object or its accepted state is not resolved enough to decide. - `ProviderConversionUnavailable`: Provider conversion webhook is temporarily unavailable, timed out, or returned a retryable failure. -`Programmed` means the desired state has been reflected to the provider. Provider-specific completion criteria are defined in provider sections. For Route 53, `Programmed=True` means the last observed RRSet matches the `ZoneUnit.spec.recordSets[]` item and there is no `pendingRecordSetChange` affecting that record identity. +`Programmed` means the provider-specific completion criteria for the accepted desired state are satisfied. For Route 53, `Programmed=True` means the last observed RRSet matches the `ZoneUnit.spec.recordSets[]` item and there is no `pendingRecordSetChange` affecting that record identity. For the external provider, it means the latest completed verification matches the expected records in every required resolver view, without DNS writes; it does not imply global resolver convergence or verified withdrawal after deletion. `Programmed=False` reasons: diff --git a/docs/design/external-provider.md b/docs/design/external-provider.md new file mode 100644 index 0000000..a2a8339 --- /dev/null +++ b/docs/design/external-provider.md @@ -0,0 +1,356 @@ +# 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..a5d1126 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,7 +203,7 @@ 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. diff --git a/docs/design/test-policy.md b/docs/design/test-policy.md index 33d004c..81f2c4c 100644 --- a/docs/design/test-policy.md +++ b/docs/design/test-policy.md @@ -8,6 +8,8 @@ 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. + 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 +143,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 +167,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..266492c 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. The UI does not expose private hosted zone or VPC selection for writing providers. The external provider supports operator-selected public or private resolver views without creating 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. +For a Provider version that declares `identity`, 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. For a Provider version without `identity`, ZoneClass creation starts from the provider picker instead of the provider identity picker, omits `identityRef`, and the `Provider identity` vitals field is omitted. The user selects an authorized ZoneClass namespace in this credentialless flow. External ZoneClass creation is supported in stage 1. -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`. For identity-backed providers, `provider` and `identityRef.name` are derived from the selected provider identity and Provider version identity resource declaration; for credentialless providers, `provider` comes from the selected Provider version and `identityRef` is absent from both the manifest preview and submitted object. `provider` is shown as read-only provider name and version fields after selection. `controllerName` is selected from the provider controller instance for the selected provider and is required. When declared, `identityRef` is shown as the selected provider identity, not as a raw group / kind / namespace editor. Hosted zone type is not selectable. + +External parameters are `resolver.mode`, `resolver.servers`, and `expectedNameservers`, using the schema and validation in [External Provider](external-provider.md#zoneclass-parameters). External does not show or submit the writing-provider parameters `zoneCreationPolicy`, `zoneDeletionPolicy`, or `sameNameZonePolicy`, and does not offer identity creation. The synchronized form implementation is `pkg/ts/ui/src/views/platform/ZoneClassForm.tsx`. `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`. `Status` is derived from `ZoneClass` conditions. `Used by` is the count of `Zone` resources referencing the `ZoneClass`. When present, `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. `ZoneClass` detail tabs are ordered `Zones`, `Status`, `Policy`, and `Manifest`.