From 2ba1c2f939aabb5dbc1c007953e4eb17c3b85c98 Mon Sep 17 00:00:00 2001 From: suin Date: Tue, 23 Jun 2026 16:48:48 +0900 Subject: [PATCH 1/2] design: add endpoint gateway app architecture - Define Core, Core Provider, App, and App Provider vocabulary. - Add endpoint.dns.appthrust.io common EndpointRecordSet and EndpointProviderCapability design. - Add gateway.endpoint.dns.appthrust.io HTTPRouteDNSReport and Gateway Endpoint App design. - Keep endpoint app provider capability outside the Core Provider contract. --- docs/design/endpoint-api.md | 141 ++++ docs/design/gateway-api-integration.md | 924 +++++++++++++++++++++++++ docs/design/overview.md | 11 + docs/design/provider-extension.md | 2 + 4 files changed, 1078 insertions(+) create mode 100644 docs/design/endpoint-api.md create mode 100644 docs/design/gateway-api-integration.md diff --git a/docs/design/endpoint-api.md b/docs/design/endpoint-api.md new file mode 100644 index 0000000..bf237c0 --- /dev/null +++ b/docs/design/endpoint-api.md @@ -0,0 +1,141 @@ +# Endpoint API + +`endpoint.dns.appthrust.io` contains common DNS API App resources for publishing endpoint addresses into DNS. It is not Gateway-specific. Gateway, Ingress, Service, and future source Apps can reuse the same endpoint record set model and provider capability discovery. + +## Endpoint RecordSet + +An endpoint record set is a generated `A`, `AAAA`, or `CNAME` record candidate that publishes an endpoint address into DNS. + +The v1 endpoint record set shape uses only these `RecordSet.spec` fields: + +- `type` +- `name` +- `ttl` +- `a` +- `aaaa` +- `cname` +- `options` + +It does not include `zoneRef`, `provider`, or `adoption`. The source App adds `zoneRef` and `provider` when it creates the final `RecordSet`. Endpoint record adoption is not supported in v1. + +The v1 supported endpoint record set types are `A`, `AAAA`, and `CNAME`. + +## EndpointProviderCapability + +`EndpointProviderCapability` is a cluster-scoped discovery object that describes how one Core Provider version participates in endpoint record set Apps. + +Example: + +```yaml +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: route53-v1alpha1 +spec: + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + defaults: + ttl: 300 + refine: + group: endpoint.route53.dns.appthrust.io + resource: endpointrecordsetrefinereviews +``` + +`spec.provider` references a Core Provider version. `spec.provider.name` is `Provider.metadata.name`; `spec.provider.version` is `Provider.spec.versions[].name`. + +`spec.defaults.ttl` is an optional static TTL default for source Apps that create endpoint record set candidates. The value must be in the core `RecordSet.spec.ttl` range, `1..2147483647`. Source Apps apply this value before calling the refine API, unless the source workflow provides an explicit TTL override. + +`spec.refine` is optional. Simple providers that can accept provider-neutral endpoint record sets may omit it. Providers that need provider-specific record shaping, such as Route 53 ALIAS records or provider-specific TTL/options, declare `spec.refine`. + +When `spec.refine` is omitted, the source App uses the endpoint record set candidate after applying `spec.defaults`. This static path is valid only when the resulting endpoint record set can be converted directly to a dns-api `RecordSet.spec` and accepted by the Core Provider. If admission later rejects the generated `RecordSet`, the source App reports the admission failure and does not keep an invalid generated object. + +The effective refine GVR is: + +```text +group = spec.refine.group +version = spec.refine.version, or spec.provider.version when omitted +resource = spec.refine.resource +``` + +`spec.refine.group` and `spec.refine.resource` are required when `spec.refine` is present. `spec.refine.version` is optional and must be non-empty when present. `spec.refine.resource` is the plural lowercase API resource name used by the Kubernetes API, such as `endpointrecordsetrefinereviews`. + +Endpoint API admission validates `EndpointProviderCapability` independently of endpoint record sets. It validates TTL range and static field shape, but it does not check whether the referenced Core Provider exists or whether the corresponding `APIService` is currently available. Runtime availability is reported by the source App as `EndpointRecordSetRefineUnavailable`. + +For a given Core Provider `name` and `version`, at most one `EndpointProviderCapability` may be effective. Source Apps treat multiple matching capabilities as invalid configuration and report `EndpointProviderCapabilityConflict`. + +## Endpoint RecordSet Refine API + +The Endpoint RecordSet Refine API is a create-only review API served by an App Provider aggregated API server. It is not a CRD and review objects are not persisted. Kubernetes authentication, authorization, audit, API discovery, and APIService routing are handled by the Kubernetes API server. + +The Provider package owns the aggregated API server and the matching `APIService` registration for its refine API. A source App does not create `APIService` resources and does not know the backing Service name, serving certificate, or CA bundle. It reads `EndpointProviderCapability.spec.refine`, then uses a Kubernetes client to `create` that resource through the Kubernetes API server. + +The shared review shape is: + +```yaml +apiVersion: endpoint.route53.dns.appthrust.io/v1alpha1 +kind: EndpointRecordSetRefineReview +request: + uid: 5f0f0b5e-0000-0000-0000-000000000001 + recordSet: + type: CNAME + name: api + ttl: 300 + cname: + target: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com +response: + uid: 5f0f0b5e-0000-0000-0000-000000000001 + result: + status: Success + reason: Refined + message: refined endpoint record set + retryable: false + recordSet: + type: A + name: api + options: + alias: + dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +`request.recordSet` and `response.recordSet` have the same endpoint record set shape. + +The API accepts one endpoint record set candidate per request and returns one refined endpoint record set. If a provider needs multiple record sets for one endpoint, such as routing policy records, v1 reports unsupported behavior. Multi-record-set refine is future work. + +The refine API may change `type`, `ttl`, record body fields, and `options`. It must not change `name`, and it must not add `adoption`. The `name` is derived from the source hostname and selected Zone; changing it would make `allowedRecordSets` checks and report output ambiguous. + +`response.uid` must equal `request.uid`. `response.result.status` is `Success` or `Failure`. On `Success`, `response.recordSet` is required. On `Failure`, `response.recordSet` is ignored. + +Failure mapping: + +- `EndpointRecordSetRefineUnsupported`: no matching `EndpointProviderCapability`, no supported static path, and no refine API. +- `EndpointProviderCapabilityConflict`: multiple matching `EndpointProviderCapability` resources exist for the same Core Provider name and version. +- `EndpointRecordSetRefineUnavailable`: retryable failure, timeout, APIService unavailability, discovery failure, or 5xx response. +- `EndpointRecordSetRefineDenied`: RBAC `Forbidden` or authentication failure. +- `EndpointRecordSetRefineInvalid`: successful API call returned malformed content, UID mismatch, missing successful `recordSet`, changed `name`, unsupported response type, unexpected `adoption`, or schema-invalid provider payload. +- `EndpointRecordSetRefineFailed`: non-retryable failure response from the refine API. + +The App Provider refine API owns provider-specific record shaping. For example, Route 53 refines a hostname `CNAME` endpoint record set candidate into an `A` record with `options.alias` when the hostname matches a supported AWS alias target and the target hosted zone ID is known. Cloudflare may return a `CNAME` endpoint record set and set provider-owned options. Source Apps must not contain provider-name-specific branches for Route 53 ALIAS or Cloudflare options. + +## API Groups + +Endpoint common resources use: + +```text +endpoint.dns.appthrust.io +``` + +Endpoint App Provider implementations use provider-specific API groups: + +```text +endpoint.route53.dns.appthrust.io +endpoint.cloudflare.dns.appthrust.io +``` + +Gateway-specific endpoint automation uses: + +```text +gateway.endpoint.dns.appthrust.io +``` diff --git a/docs/design/gateway-api-integration.md b/docs/design/gateway-api-integration.md new file mode 100644 index 0000000..37d0de7 --- /dev/null +++ b/docs/design/gateway-api-integration.md @@ -0,0 +1,924 @@ +# Gateway API Integration + +dns-api provides a Gateway API integration that observes `HTTPRoute` and `Gateway` resources and reports how their DNS intent maps to dns-api `Zone` and `RecordSet` resources. + +The initial integration targets `HTTPRoute` and `Gateway` from `gateway.networking.k8s.io/v1`. Other Route kinds such as `TLSRoute`, `TCPRoute`, `UDPRoute`, and `GRPCRoute` are out of scope for the initial version. + +Gateway API integration is a DNS API App. It runs above Core, derives `RecordSet` resources from Gateway API state, and reports the result through `HTTPRouteDNSReport`. It must not expand Core controller responsibility. It uses the common Endpoint API in `endpoint.dns.appthrust.io`; provider-specific endpoint record set shaping is exposed through `EndpointProviderCapability` and the Endpoint RecordSet Refine API. + +## Problem Space + +Without Gateway API integration, an application team must create or update at least three resource areas to attach a hostname to a Gateway: + +1. A `Gateway` exists with listeners and provider-assigned addresses. +2. An `HTTPRoute` attaches application traffic to that Gateway. +3. A dns-api `RecordSet` points the hostname to the Gateway address. + +This creates several failure modes: + +- A route exists but DNS is never created. +- A route is deleted or changed but old DNS remains. +- DNS points at the wrong Gateway address. +- Operators must inspect `Gateway.status.addresses` manually and translate it to provider-specific DNS target data, such as a Route 53 `ALIAS` target for an AWS Load Balancer Controller Gateway. + +The affected users are both application teams and platform teams. Application teams want `HTTPRoute` to be the normal place where hostnames are declared. Platform teams want DNS to remain constrained by dns-api `Zone`, `ZoneClass`, provider identity, and `allowedRecordSets` policy. + +external-dns already handles a similar flow with its Gateway API source: it derives DNS endpoints from `HTTPRoute` and `Gateway`, then provider implementations choose the matching provider zone. dns-api follows the same broad source interpretation, but it must expose intermediate feedback because dns-api creates Kubernetes `RecordSet` resources before provider reconciliation. A missing `Zone`, an `allowedRecordSets` rejection, or an unsupported target shape should be visible as Kubernetes status, not only as controller logs. + +## Goals + +- Observe `HTTPRoute` and `Gateway` resources and derive DNS hostnames from their attachment relationship. +- Create dns-api `RecordSet` resources for supported `HTTPRoute` hostname targets. +- Report DNS translation, policy, and programming state in a readable Kubernetes resource. +- Make missing zones, parent acceptance problems, unsupported target shapes, and `allowedRecordSets` denials visible without reading controller logs. +- Keep the integration compatible with the existing dns-api `Zone`, `RecordSet`, and provider controller model. + +## Non-Goals + +- Do not change Gateway API resources or write `HTTPRoute.status`. +- Do not implement Gateway or HTTPRoute admission validation. +- Do not support `TLSRoute`, `TCPRoute`, `UDPRoute`, or `GRPCRoute` initially. +- Do not add Route 53 weighted, latency, failover, or other routing policy support as part of the initial Gateway integration. +- Do not expose a user-authored desired-state API for Gateway DNS records in the initial version. +- Do not add route or Gateway selector configuration in the initial version. + +## API + +The integration adds a report resource: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: web +``` + +`HTTPRouteDNSReport` is a controller-owned report for one `HTTPRoute`. It is created in the same namespace and with the same name as the `HTTPRoute`. The controller sets an owner reference to the `HTTPRoute`, so the report is deleted with the route. + +The resource is read-only for application users. It is not a user-authored API. The initial type has no meaningful `spec`; all useful data is written under `status`. + +Example: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: web +status: + routeRef: + namespace: app + name: web + hostnames: + - hostname: api.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + conditions: + - type: Selected + status: "True" + reason: LongestAllowedSuffixMatch + recordSets: + - type: A + name: api + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true + conditions: + - type: Resolved + status: "True" + reason: Resolved + - type: Allowed + status: "True" + reason: AllowedByZone + - type: Created + status: "True" + reason: RecordSetCreated + generated: + ref: + namespace: dns-api-system + name: gwrs-8f3a2c91d7-a + conditions: + - type: Accepted + status: "True" + reason: Accepted + - type: Programmed + status: "False" + reason: ProviderChangePending + - type: AAAA + name: api + options: {} + conditions: + - type: Resolved + status: "False" + reason: UnsupportedTarget + - hostname: denied.example.com + zone: + candidates: + - ref: + namespace: platform-dns + name: example-com + domainName: example.com + reason: RecordSetNotAllowed + conditions: + - type: Selected + status: "False" + reason: RecordSetNotAllowed + recordSets: + - type: A + name: denied + conditions: + - type: Resolved + status: "True" + reason: Resolved + - type: Allowed + status: "False" + reason: RecordSetNotAllowed + - type: Created + status: "False" + reason: RecordSetNotCreated + - hostname: api.unknown.example.net + zone: + conditions: + - type: Selected + status: "False" + reason: ZoneNotResolved + recordSets: [] + conditions: + - type: Resolved + status: "False" + reason: PartialFailure + - type: Programmed + status: "False" + reason: RecordSetNotProgrammed +``` + +The exact status field names may change during implementation, but the status model stays route-scoped and grouped by DNS hostname. The first question the report answers is: "for this `HTTPRoute`, which `RecordSet` objects is the controller trying to create for each hostname, and which Zone was selected?" Policy and generated `RecordSet` details are attached under each `recordSets[]` item. Parent, listener, and Gateway address details are included only when they explain a blocked or unsupported `RecordSet`. + +## Controller Scope + +The initial controller watches all `HTTPRoute` and `Gateway` resources in the cluster. It does not support route namespace filters, route label filters, route annotation filters, Gateway namespace filters, Gateway name filters, or Gateway label filters in the initial version. + +Filtering is future work. The initial design keeps the controller behavior simple and closer to a cluster-level integration. Operators can rely on dns-api `Zone` and `allowedRecordSets` policy to decide which hostnames can become `RecordSet` resources. + +The controller creates an `HTTPRouteDNSReport` when an `HTTPRoute` has at least one Gateway parent reference and a DNS hostname can be derived from route hostnames, Gateway listener hostnames, or their intersection. If the route has no Gateway parent references, the controller does not create a report. + +The initial implementation uses one Gateway DNS controller. It is responsible for both: + +- reading `HTTPRoute`, `Gateway`, `Zone`, and generated `RecordSet` resources and writing `HTTPRouteDNSReport.status`; +- creating, updating, and deleting generated `RecordSet` resources. + +`HTTPRouteDNSReport` is not a controller-to-controller desired-state boundary in the initial version. It is a user-facing report produced by the Gateway DNS controller. + +## Hostname Resolution + +The controller resolves DNS hostnames from the relationship between `HTTPRoute.spec.hostnames` and matching Gateway listeners. + +Rules: + +- If both the route and listener define hostnames, the controller uses their hostname intersection. +- If the route omits `spec.hostnames` and the listener defines `hostname`, the listener hostname is used. +- If the route defines `spec.hostnames` and the listener omits `hostname`, the route hostname is used. +- If both omit hostnames, no DNS hostname is produced. +- Wildcards follow Gateway API source behavior used by external-dns: a wildcard label such as `*.example.com` is a suffix match, and the more specific overlapping hostname is used. + +Examples: + +```yaml +# Gateway listener hostname +hostname: api.example.com + +# HTTPRoute hostnames omitted +``` + +produces `api.example.com`. + +```yaml +# Gateway listener hostname +hostname: api.example.com + +# HTTPRoute hostname +hostnames: + - "*.example.com" +``` + +produces `api.example.com`. + +```yaml +# Gateway listener hostname +hostname: "*.example.com" + +# HTTPRoute hostname +hostnames: + - api.example.com +``` + +produces `api.example.com`. + +## Hostname Examples + +### HTTPRoute Hostname Only + +When `HTTPRoute.spec.hostnames` is set and the Gateway listener does not set `hostname`, the route hostname becomes the DNS hostname. + +Input: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + gatewayClassName: alb + listeners: + - name: https + protocol: HTTPS + port: 443 + allowedRoutes: + namespaces: + from: All +status: + addresses: + - type: Hostname + value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + namespace: app + name: web +spec: + parentRefs: + - namespace: platform + name: public + sectionName: https + hostnames: + - api.example.com + rules: + - backendRefs: + - name: web + port: 8080 +status: + parents: + - parentRef: + namespace: platform + name: public + sectionName: https + controllerName: example.com/gateway-controller + conditions: + - type: Accepted + status: "True" + reason: Accepted +``` + +Expected report shape: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: web +status: + hostnames: + - hostname: api.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + recordSets: + - type: A + name: api + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +### Gateway Listener Hostname Only + +When `HTTPRoute.spec.hostnames` is omitted and the Gateway listener sets `hostname`, the listener hostname becomes the DNS hostname. + +Input: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + gatewayClassName: alb + listeners: + - name: https-api + protocol: HTTPS + port: 443 + hostname: api.example.com + allowedRoutes: + namespaces: + from: All +status: + addresses: + - type: Hostname + value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + namespace: app + name: web +spec: + parentRefs: + - namespace: platform + name: public + sectionName: https-api + rules: + - backendRefs: + - name: web + port: 8080 +status: + parents: + - parentRef: + namespace: platform + name: public + sectionName: https-api + controllerName: example.com/gateway-controller + conditions: + - type: Accepted + status: "True" + reason: Accepted +``` + +Expected report shape: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: web +status: + hostnames: + - hostname: api.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + recordSets: + - type: A + name: api + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +### HTTPRoute And Gateway Listener Hostnames + +When both the route and listener set hostnames, the DNS hostname is their intersection. If one side is a wildcard and the other is concrete, the concrete hostname is used. + +Input: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + gatewayClassName: alb + listeners: + - name: https-api + protocol: HTTPS + port: 443 + hostname: api.example.com + allowedRoutes: + namespaces: + from: All +status: + addresses: + - type: Hostname + value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + namespace: app + name: wildcard +spec: + parentRefs: + - namespace: platform + name: public + sectionName: https-api + hostnames: + - "*.example.com" + rules: + - backendRefs: + - name: web + port: 8080 +status: + parents: + - parentRef: + namespace: platform + name: public + sectionName: https-api + controllerName: example.com/gateway-controller + conditions: + - type: Accepted + status: "True" + reason: Accepted +``` + +Expected report shape: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: wildcard +status: + hostnames: + - hostname: api.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + recordSets: + - type: A + name: api + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +### Multiple HTTPRoute Hostnames + +When an `HTTPRoute` has multiple hostnames and the Gateway listener does not narrow them, the report contains one hostname entry for each route hostname. Each hostname can have its own Zone and `recordSets`. + +Input: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + gatewayClassName: alb + listeners: + - name: https + protocol: HTTPS + port: 443 + allowedRoutes: + namespaces: + from: All +status: + addresses: + - type: Hostname + value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + namespace: app + name: multi +spec: + parentRefs: + - namespace: platform + name: public + sectionName: https + hostnames: + - api.example.com + - admin.example.com + rules: + - backendRefs: + - name: web + port: 8080 +status: + parents: + - parentRef: + namespace: platform + name: public + sectionName: https + controllerName: example.com/gateway-controller + conditions: + - type: Accepted + status: "True" + reason: Accepted +``` + +Expected report shape: + +```yaml +apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 +kind: HTTPRouteDNSReport +metadata: + namespace: app + name: multi +status: + hostnames: + - hostname: api.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + recordSets: + - type: A + name: api + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true + - hostname: admin.example.com + zone: + ref: + namespace: platform-dns + name: example-com + domainName: example.com + recordSets: + - type: A + name: admin + options: + alias: + dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +## Parent Acceptance + +`HTTPRoute` can reference multiple parents. Gateway API reports acceptance per parent in `HTTPRoute.status.parents[]`; acceptance is not a single route-wide condition. + +The dns-api Gateway integration treats all Gateway parents referenced by the route as part of the route's DNS intent. The initial version requires all Gateway parents that contribute to a hostname to be accepted before creating `RecordSet` resources for that hostname. If one parent is accepted and another is rejected or unresolved, the report records a blocked condition and does not create partial DNS. + +This avoids silently routing DNS to only a subset of the route's declared parents. + +## Endpoint Capability + +The Gateway DNS controller uses the common Endpoint API in `endpoint.dns.appthrust.io`. A selected Zone's Core Provider version is compatible with the initial Gateway API integration when there is exactly one matching `EndpointProviderCapability`. + +Example: + +```yaml +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: route53-v1alpha1 +spec: + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + defaults: + ttl: 300 + refine: + group: endpoint.route53.dns.appthrust.io + resource: endpointrecordsetrefinereviews +``` + +`spec.defaults.ttl` is an optional static TTL default for endpoint record set candidates. `spec.refine` is optional. When present, it identifies the Endpoint RecordSet Refine API. The Provider package owns the aggregated API server and `APIService` registration for that API. The Gateway DNS controller reads `EndpointProviderCapability`, creates the configured review resource through the Kubernetes API server, and never calls the backing Service directly. + +The refine API version defaults to `EndpointProviderCapability.spec.provider.version` when `spec.refine.version` is omitted. For the example above, the Gateway DNS controller calls: + +```text +POST /apis/endpoint.route53.dns.appthrust.io/v1alpha1/endpointrecordsetrefinereviews +``` + +If no matching `EndpointProviderCapability` exists, the report records `EndpointRecordSetRefineUnsupported` and no generated `RecordSet` is created for that Provider. If multiple matching capabilities exist, the report records `EndpointProviderCapabilityConflict`. + +## Endpoint RecordSet Resolution + +Accepted Gateway parents contribute targets from `Gateway.status.addresses`. The initial target types are: + +- `IPAddress` +- `Hostname` + +The controller first converts Gateway addresses into provider-neutral endpoint record set candidates: + +| Gateway address | Endpoint record set candidate | +| --- | --- | +| IPv4 `IPAddress` | `A` with `a.addresses[]` | +| IPv6 `IPAddress` | `AAAA` with `aaaa.addresses[]` | +| `Hostname` | `CNAME` with `cname.target` | + +An endpoint record set is a generated `A`, `AAAA`, or `CNAME` record candidate that publishes an endpoint address into DNS. It uses a subset of `RecordSet.spec` fields: `type`, `name`, `ttl`, `a`, `aaaa`, `cname`, and `options`. It does not include `zoneRef`, `provider`, or `adoption`; `zoneRef` and `provider` are added by the Gateway DNS controller after Provider refine succeeds, and endpoint record set adoption is not supported in v1. + +The Gateway DNS controller constructs each endpoint record set candidate from: + +- the zone-relative record name; +- the Gateway address type and value; +- `EndpointProviderCapability.spec.defaults.ttl`, when present; +- a valid `dns.appthrust.io/ttl` annotation value, when present. + +The controller does not include `adoption` in endpoint record sets. Endpoint record set adoption is not supported in v1. + +When the matching `EndpointProviderCapability` declares `spec.refine`, the controller sends the candidate to the Endpoint RecordSet Refine API: + +```yaml +apiVersion: endpoint.route53.dns.appthrust.io/v1alpha1 +kind: EndpointRecordSetRefineReview +request: + uid: 5f0f0b5e-0000-0000-0000-000000000001 + recordSet: + type: CNAME + name: api + ttl: 300 + cname: + target: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com +``` + +The Provider returns one refined endpoint record set: + +```yaml +response: + uid: 5f0f0b5e-0000-0000-0000-000000000001 + result: + status: Success + reason: Refined + retryable: false + recordSet: + type: A + name: api + options: + alias: + dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: true +``` + +The Gateway DNS controller validates that a successful response has the same `uid`, contains one endpoint record set, preserves the input `name`, omits `adoption`, and uses a supported endpoint record set type. It then adds `zoneRef` and `provider` to create the final dns-api `RecordSet`. + +Provider-specific record shaping belongs to the Endpoint RecordSet Refine API, not to the Gateway DNS controller. For example, Route 53 may refine a hostname `CNAME` candidate into an `A` record with `options.alias`, and Cloudflare may keep it as `CNAME` while setting provider-owned options. Route 53 hosted zone ID lookup for ALB or NLB alias targets is part of the Route 53 refine API implementation. The Gateway DNS controller must not hard-code AWS alias suffix tables or Cloudflare option rules. + +The Gateway DNS controller stops only when it cannot create an endpoint record set candidate, refine it when required, or convert the resulting endpoint record set to a dns-api `RecordSet`. It does not duplicate every provider-specific acceptance rule. Once a resulting endpoint record set can be expressed as a dns-api `RecordSet` and is allowed by dns-api policy, the existing core and provider controllers decide whether the `RecordSet` is accepted and programmed. For example, the Gateway DNS controller does not reject a Cloudflare apex `CNAME` on its own; it creates the `RecordSet` and projects the resulting `RecordSet.status.conditions` into the report. + +If the same resolved DNS hostname has multiple Gateway targets, the initial controller reports `MultipleGatewayTargetsUnsupported` and does not create a `RecordSet`. This is necessary because a single Route 53 `ALIAS` record set cannot express multiple alias targets without Route 53 routing policy such as weighted records and `setIdentifier`. Routing policy support is future work. + +## TTL Resolution + +Gateway API does not define a TTL field for `HTTPRoute` or `Gateway`. The Gateway DNS controller therefore resolves TTL before creating generated `RecordSet` resources. + +`RecordSet.spec.ttl` is optional in the core CRD schema, but provider validation may require it. For example, dns-api provider schemas commonly use `require-ttl` for standard record bodies. Some provider-specific refined shapes may omit or replace core TTL: + +- Route 53 `options.alias` records omit `spec.ttl`. +- Cloudflare `options.ttl: Auto` records omit `spec.ttl`. + +The initial Gateway DNS controller supports an optional `HTTPRoute` annotation override: + +```yaml +metadata: + annotations: + dns.appthrust.io/ttl: "300" +``` + +The annotation value is parsed as seconds. The accepted range is the core `RecordSet.spec.ttl` range, `1..2147483647`. Invalid values are reported on the `HTTPRouteDNSReport` and no invalid `RecordSet` is created. + +TTL resolution rules: + +- If `dns.appthrust.io/ttl` is present and valid, the controller sets the endpoint record set candidate's `ttl` to that value before Provider refine. +- If the annotation is absent and the matching `EndpointProviderCapability` has `spec.defaults.ttl`, the controller sets the endpoint record set candidate's `ttl` to that value before Provider refine. +- If neither the annotation nor `spec.defaults.ttl` supplies TTL, the endpoint record set candidate omits `ttl`. +- The Endpoint RecordSet Refine API may return a refined record that omits `ttl` or uses provider-owned TTL options when that is the correct provider-specific shape. +- If the annotation is invalid, the report records `TTLNotResolved` and the controller does not call refine for that candidate. +- If the refined endpoint record set is rejected later by `RecordSet` admission because no accepted TTL shape is present, the report records the admission failure and no invalid generated `RecordSet` is kept. + +`EndpointProviderCapability.spec.defaults.ttl` is a narrow Endpoint App default. Core `Provider` does not carry Endpoint App defaults. Provider-specific automatic TTL behavior, such as Cloudflare automatic TTL, is not inferred by the Gateway DNS controller; it must be returned by the Endpoint RecordSet Refine API when needed. + +## Zone Selection + +The controller selects a dns-api `Zone` by matching each resolved DNS hostname against existing `Zone.spec.domainName` values. + +The basic rule follows external-dns provider behavior: + +- Candidate zones are zones whose domain name equals the hostname or is a suffix of the hostname. +- The most specific candidate is selected by longest matching domain name. + +dns-api adds one more selection constraint: the refined generated `RecordSet` must be allowed to attach to the selected `Zone`. + +Endpoint record set handling depends on the candidate Zone because the Zone selects the Core Provider version, and the Core Provider version selects a matching `EndpointProviderCapability`. For each suffix-matching candidate Zone, the controller builds the zone-relative endpoint record set candidate, applies `EndpointProviderCapability.spec.defaults`, calls the refine API when `spec.refine` is present, then checks whether the resulting generated `RecordSet` in the configured RecordSet namespace is allowed by `Zone.spec.allowedRecordSets`. This includes: + +- generated `RecordSet` namespace +- zone-relative record name +- refined record type + +If the longest matching zone does not allow the generated `RecordSet`, the controller may continue to the next less-specific matching zone. The selected zone is the most specific matching zone that also allows the generated `RecordSet`. If no matching zone allows the refined generated `RecordSet`, the report records a denial and no `RecordSet` is created. + +Generated `RecordSet` resources are written to the controller-configured `recordSetNamespace`, such as `dns-api-system`. This namespace is not derived from the `HTTPRoute` namespace and does not have to match the controller Pod namespace. Platform teams must grant `recordSetNamespace` through `Zone.spec.allowedRecordSets` for shared zones. + +Condition reasons: + +- `ZoneNotResolved`: no `Zone` matches the hostname. +- `RecordSetNotAllowed`: matching zones exist, but none allows the generated `RecordSet` namespace, name, and type. + +## RecordSet Management + +The controller creates dns-api `RecordSet` resources only after the report data is resolvable and supported. + +Creation requirements for a hostname: + +- A DNS hostname is resolved from the route/listener relationship. +- All contributing Gateway parents are `Accepted=True`. +- Gateway addresses are present. +- A `Zone` is selected. +- Exactly one `EndpointProviderCapability` matches the selected Zone's Core Provider name and version. +- The controller can build an endpoint record set candidate from the Gateway address. +- If `EndpointProviderCapability.spec.refine` is present, the Endpoint RecordSet Refine API returns one successful refined endpoint record set. +- The resulting endpoint record set can be converted to the current dns-api `RecordSet.spec`. +- `Zone.spec.allowedRecordSets` allows the refined generated `RecordSet`. +- The generated `RecordSet` passes Kubernetes and dns-api admission. + +Generated `RecordSet` resources are created in `recordSetNamespace`, not in the application namespace. The report status points to generated `RecordSet` refs. + +The generated `RecordSet` name is stable and short: + +```text +gwrs-- +``` + +`` is computed from the source route namespace, source route name, selected Zone namespace, selected Zone name, and hostname. The record type is not part of the hash input because it is present as the final suffix. This keeps records for the same route, Zone, and hostname visually grouped: + +```text +gwrs-8f3a2c91d7-a +gwrs-8f3a2c91d7-aaaa +``` + +The exact hash length is an implementation detail, but it must be long enough to avoid practical collisions and short enough to keep generated names readable. + +Generated `RecordSet` resources carry labels and annotations identifying the owning `HTTPRouteDNSReport` and source `HTTPRoute`. Short selector values and hashes go in labels. Potentially long values, such as route namespace, route name, hostname, and Zone reference, go in annotations because Kubernetes label values have a 63-character limit. + +Generated `RecordSet` resources are not user-owned records. If a report no longer needs a generated `RecordSet`, the controller deletes it. + +The controller projects generated `RecordSet.status.conditions` into `HTTPRouteDNSReport.status.hostnames[].recordSets[].generated.conditions[]` so users can see both source translation and provider programming state from the report. + +## Deletion and Cleanup + +`HTTPRouteDNSReport` is created in the same namespace and with the same name as the source `HTTPRoute`, so it can use an owner reference to the `HTTPRoute`. Generated `RecordSet` resources are created in `recordSetNamespace`, which may be a different namespace. Because Kubernetes owner references cannot safely express cross-namespace ownership, generated `RecordSet` resources are not garbage-collected by the report owner reference. + +The Gateway DNS controller adds a finalizer to each `HTTPRouteDNSReport`. When the source `HTTPRoute` is deleted, Kubernetes marks the report for deletion through the owner reference. The controller then: + +1. Finds generated `RecordSet` resources owned by the report using labels and annotations. +2. Deletes those generated `RecordSet` resources. +3. Waits until their deletion is accepted by the Kubernetes API. +4. Removes the report finalizer. + +This cleanup path prevents generated DNS records from remaining after the source `HTTPRoute` is deleted. + +The same ownership index is used for updates. When a report reconciliation no longer includes a previously generated `RecordSet` because a hostname, parent, Zone, target, or record type changed, the controller deletes the obsolete generated `RecordSet`. The controller must delete only records that carry its managed labels and report ownership annotations. + +## Conditions + +`HTTPRouteDNSReport.status.conditions` summarizes the route-level DNS state. DNS outcome details live in `status.hostnames[]`. + +Each `status.hostnames[]` item represents one DNS hostname after Gateway route/listener hostname resolution: + +- `hostname`: fully qualified DNS hostname. +- `zone`: selected Zone, candidate Zones, and conditions for this hostname's Zone decision. If no Zone is selected, this field records why selection failed. +- `recordSets`: desired DNS `RecordSet` objects for this hostname. + +`zone.conditions[]` contains Gateway DNS controller conditions for the hostname's Zone decision. It does not copy `Zone.status.conditions`. + +Each `recordSets[]` item represents one desired dns-api `RecordSet` candidate for the hostname: + +- `type`: DNS record type the controller intends to create, such as `A` or `AAAA`. +- `name`: zone-relative `RecordSet.spec.name`. +- `ttl`: `RecordSet.spec.ttl`, when the controller sets one. +- `a`, `aaaa`, `cname`, or `options`: the DNS record body fields the controller intends to create. +- `conditions`: Gateway DNS controller conditions for this desired `RecordSet`, such as target resolution, policy allowance, and creation. +- `generated`: generated `RecordSet` reference and copied `RecordSet.status.conditions`, when a `RecordSet` was created. + +The `recordSets[]` item mirrors the relevant `RecordSet.spec` fields for Gateway integration: `type`, `name`, `ttl`, `a`, `aaaa`, `cname`, and `options`. It does not repeat `zoneRef` or `provider`; the selected Zone is represented by `hostnames[].zone`, and the provider is derived from that Zone. + +Initial `zone.conditions[]` condition types: + +- `Selected`: whether the controller selected a dns-api `Zone` for the hostname. + +Initial `recordSets[].conditions[]` condition types: + +- `Resolved`: whether the controller resolved DNS hostnames, Gateway parents, targets, zones, and policy enough to create the desired `RecordSet` resources. +- `Allowed`: whether the generated `RecordSet` is allowed by dns-api `Zone.spec.allowedRecordSets` policy. +- `Created`: whether the generated `RecordSet` object was created. + +Initial `Resolved=False` reasons include: + +- `HostnameNotResolved` +- `ParentNotAccepted` +- `GatewayNotResolved` +- `GatewayTargetNotResolved` +- `MultipleGatewayTargetsUnsupported` +- `EndpointRecordSetRefineUnsupported` +- `EndpointRecordSetRefineUnavailable` +- `EndpointRecordSetRefineDenied` +- `EndpointRecordSetRefineInvalid` +- `EndpointRecordSetRefineFailed` +- `TTLNotResolved` +- `ZoneNotResolved` +- `RecordSetNotAllowed` +- `UnsupportedTarget` + +Initial `Created=False` reasons include: + +- `RecordSetNotCreated` + +Generated `RecordSet.status.conditions` are copied under `status.hostnames[].recordSets[].generated.conditions[]`. They are not mixed into `status.hostnames[].recordSets[].conditions[]`, so future `RecordSet` condition types cannot collide with Gateway DNS controller condition types. + +The report must not hide partial failures. If one desired `RecordSet` is ready and another desired `RecordSet` is blocked, the route-level condition summarizes the blocked state and `status.hostnames[].recordSets[]` identifies the exact hostname, selected or attempted zone, record type, intended record body fields, and reason. + +## User Journey + +Application user creates an `HTTPRoute`: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + namespace: app + name: web +spec: + parentRefs: + - namespace: platform + name: public + sectionName: https-api + rules: + - backendRefs: + - name: web + port: 8080 +``` + +The referenced Gateway listener declares the hostname: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + listeners: + - name: https-api + protocol: HTTPS + port: 443 + hostname: api.example.com +``` + +The controller creates: + +```text +HTTPRouteDNSReport app/web +``` + +The report shows that the controller is trying to create an `A` record for `api.example.com`, which DNS record body it intends to write, which dns-api `Zone` matched, whether the generated `RecordSet` was allowed, and whether it is programmed. + +## Alternatives Considered + +### Create RecordSet Directly + +The controller could create `RecordSet` resources directly from `HTTPRoute` without a report resource. This hides important failure modes. If no zone matches or `allowedRecordSets` rejects the generated record, there is no `RecordSet` object on which to place status. Users would need controller logs, similar to external-dns skip behavior. + +### Controller-Owned Desired Resource With Spec + +The controller could create an `HTTPRouteRecord` or `GatewayRecord` with `spec` containing desired hostnames and targets. This became awkward because some DNS hostnames come from `HTTPRoute.spec.hostnames`, some come from Gateway listener hostname, and targets come from `Gateway.status.addresses`. The boundary between desired state and observed translation was unclear. + +`HTTPRouteDNSReport` avoids that ambiguity by presenting the integration output as a report. + +### Report in Management Namespace + +The report could live in `dns-api-system` together with generated `RecordSet` resources. That avoids granting report create/update permissions across application namespaces, but it makes the report harder for application teams to find and prevents a same-namespace owner reference to the source `HTTPRoute`. + +The initial design places the report in the same namespace and name as the `HTTPRoute`. + +### Provider Logic in Gateway Controller + +The Gateway DNS controller could translate Gateway addresses directly into provider-specific `RecordSet` shapes. For example, it could recognize AWS load balancer hostnames, resolve Route 53 alias hosted zone IDs, and omit `ttl` for Route 53 `options.alias`. + +This is rejected. It would make the Gateway DNS controller depend on provider-specific DNS rules and data tables. New providers would require Gateway controller changes, and existing providers would spread their record-shaping rules across multiple controllers. Provider-specific record shaping belongs to the Provider package and is exposed through the Endpoint RecordSet Refine API. + +### Provider Webhook for Refine + +The Provider package could expose Endpoint RecordSet Refine through a direct webhook. This is simpler to implement than an aggregated API server, but a controller-origin webhook call would need dns-api-specific authentication, authorization, TLS, retry, timeout, and audit conventions. + +The initial design uses an aggregated API served through `APIService`. The Gateway DNS controller calls the Kubernetes API server with a normal create request, and Kubernetes handles API routing, authentication, authorization, and audit. The Provider package owns the APIService and backing aggregated API server. + +## Future Work + +- Add route namespace, route label, route annotation, Gateway namespace, Gateway name, and Gateway label filters. +- Support `TLSRoute`, `TCPRoute`, `UDPRoute`, and `GRPCRoute`. +- Add multi-record-set Endpoint RecordSet Refine for providers that need several `RecordSet` resources for one endpoint candidate. +- Add Route 53 routing policy support so multiple Gateway alias targets can be represented. +- Add UI views for `HTTPRouteDNSReport` in the Headlamp plugin. +- Add report aggregation in `Zone` or `RecordSet` views. +- Support user-authored source policies if report-only resources are not enough for future workflows. diff --git a/docs/design/overview.md b/docs/design/overview.md index 3e2c852..0cbe3b8 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -6,6 +6,17 @@ The API follows the same broad model as Gateway API: provider differences are ex ExternalDNS is a controller that creates DNS records from existing Kubernetes resources. dns-api manages DNS zones and DNS record sets themselves as Kubernetes resources. +## Architecture Vocabulary + +dns-api uses these layer names to keep responsibilities clear: + +- **Core**: the first-floor DNS API primitives and contracts. Core owns `ZoneClass`, `Provider`, `Zone`, `RecordSet`, `ZoneUnit`, core admission, and the controllers that compose accepted `Zone` and `RecordSet` claims into `ZoneUnit`. Core does not know Gateway API, Ingress, Service, UI preview workflows, or provider-specific endpoint record set shaping. +- **Core Provider**: a provider implementation for Core resources. A Core Provider reconciles `ZoneUnit` desired state to a DNS provider and implements provider-specific identity, options, validation, status, and external API calls for `Zone` and `RecordSet`. Route 53 and Cloudflare Core Provider controllers are examples. +- **App**: a DNS API application built on top of Core. An App derives or manages Core resources from another workflow or API. Gateway API DNS support is an App. Future Ingress DNS support, Service DNS support, and UI-generated manifest flows are also Apps. +- **App Provider**: a provider-specific extension used by an App. An App Provider capability adapts an App's provider-neutral intent to provider-specific Core resource shape without expanding Core controller responsibility. `endpoint.dns.appthrust.io/EndpointProviderCapability` and the Endpoint RecordSet Refine API are App Provider capabilities. + +The `Provider` resource is the Core Provider discovery contract. Core reads Core Provider capabilities such as schemas, validation rules, conversions, supported record types, and condition reasons. App Provider capabilities live in App-specific API groups, such as `endpoint.dns.appthrust.io`, so Core controllers do not depend on Gateway, Ingress, Service, UI, or endpoint record set workflows. + ## 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. diff --git a/docs/design/provider-extension.md b/docs/design/provider-extension.md index ff7a14b..976eb63 100644 --- a/docs/design/provider-extension.md +++ b/docs/design/provider-extension.md @@ -4,6 +4,8 @@ `Provider` is a cluster-scoped resource. A provider package creates one `Provider` per provider. It defines provider-specific inline objects, support level, validation, and display metadata for `ZoneClass`, `Zone`, and `RecordSet`. Multiple compatible schema versions are stored in `spec.versions`. +`Provider` belongs to the Core layer described in `docs/design/overview.md`. It is the Core Provider discovery contract. App Provider capabilities, such as endpoint record set support for Gateway, Ingress, or Service Apps, live in App-specific API groups instead of on `Provider`. + `ZoneClass.spec.provider`, `Zone.spec.provider`, `RecordSet.spec.provider`, and `ZoneUnit.spec.provider` use an object with `name` and `version`. `provider.name` is `Provider.metadata.name`; `provider.version` is `Provider.spec.versions[].name`. For example, `name: route53.dns.appthrust.io` and `version: v1alpha1` references version `v1alpha1` in `Provider/route53.dns.appthrust.io`. The core API resolves Provider and version from those two fields. It does not interpret the DNS-name-like structure of the provider name or the semantics of the version name. Incompatible schema changes add a new version under the same `Provider` instead of changing an existing version destructively. From 4f1e124b7b2da5b7548dba3611d2099c7c882b9f Mon Sep 17 00:00:00 2001 From: suin Date: Tue, 30 Jun 2026 10:11:28 +0900 Subject: [PATCH 2/2] Add Gateway endpoint DNS integration --- Taskfile.yml | 11 +- app/operator/cmd/manager/main.go | 41 +- .../config/apiservice/kustomization.yaml | 2 + ...oute53_endpoint_conversion_apiservice.yaml | 15 + ...hrust.io_endpointprovidercapabilities.yaml | 94 ++ ...t.dns.appthrust.io_endpointrecordsets.yaml | 454 +++++++++ app/operator/config/crd/kustomization.yaml | 2 + .../config/default/kustomization.yaml | 1 + app/operator/config/manager/manager.yaml | 3 +- app/operator/config/namespace/namespace.yaml | 2 + .../provider/endpoint_capabilities.yaml | 12 + .../config/provider/kustomization.yaml | 1 + app/operator/config/rbac/role.yaml | 77 +- ...oute53-gateway-api-alias-lifecycle.test.ts | 639 ++++++++++++ ...hrust.io_endpointprovidercapabilities.yaml | 94 ++ ...t.dns.appthrust.io_endpointrecordsets.yaml | 454 +++++++++ ...piservice-route53-endpoint-conversion.yaml | 24 + .../charts/dns-api/templates/deployment.yaml | 2 +- .../endpoint-capability-route53.yaml | 14 + deploy/charts/dns-api/templates/rbac.yaml | 71 +- deploy/charts/dns-api/values.yaml | 8 +- docs/design/endpoint-api.md | 157 +-- docs/design/gateway-api-integration.md | 935 ++---------------- docs/design/overview.md | 2 +- go.mod | 13 +- go.sum | 31 +- .../endpointrecordset/controller.go | 477 +++++++++ .../controllers/gateway/controller.go | 238 +++++ .../controllers/gateway/controller_test.go | 128 +++ .../controllers/gateway/httproute.go | 181 ++++ .../route53/controllers/zoneunit/recordset.go | 3 - .../route53/controllers/zoneunit/zone.go | 34 +- .../route53/controllers/zoneunit/zone_test.go | 15 +- .../go/providers/route53/conversion/alias.go | 101 ++ .../providers/route53/conversion/handler.go | 212 ++++ .../route53/conversion/handler_test.go | 112 +++ .../api/endpoint/conversion/v1alpha1/doc.go | 5 + .../endpointrecordsetconversion_types.go | 71 ++ .../conversion/v1alpha1/groupversion_info.go | 14 + .../endpoint/conversion/v1alpha1/register.go | 16 + .../conversion/v1alpha1/scheme_test.go | 22 + .../v1alpha1/zz_generated.deepcopy.go | 129 +++ pkg/go/api/endpoint/v1alpha1/doc.go | 4 + .../endpointprovider_capability_types.go | 50 + .../v1alpha1/endpointrecordset_types.go | 221 +++++ .../endpoint/v1alpha1/groupversion_info.go | 14 + pkg/go/api/endpoint/v1alpha1/register.go | 18 + pkg/go/api/endpoint/v1alpha1/scheme_test.go | 31 + .../v1alpha1/zz_generated.deepcopy.go | 402 ++++++++ 49 files changed, 4713 insertions(+), 944 deletions(-) create mode 100644 app/operator/config/apiservice/kustomization.yaml create mode 100644 app/operator/config/apiservice/route53_endpoint_conversion_apiservice.yaml create mode 100644 app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml create mode 100644 app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml create mode 100644 app/operator/config/provider/endpoint_capabilities.yaml create mode 100644 app/operator/tests/e2e/recordset-api/route53-gateway-api-alias-lifecycle.test.ts create mode 100644 deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml create mode 100644 deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml create mode 100644 deploy/charts/dns-api/templates/apiservice-route53-endpoint-conversion.yaml create mode 100644 deploy/charts/dns-api/templates/endpoint-capability-route53.yaml create mode 100644 internal/go/apps/endpoint/controllers/endpointrecordset/controller.go create mode 100644 internal/go/apps/gatewayendpoint/controllers/gateway/controller.go create mode 100644 internal/go/apps/gatewayendpoint/controllers/gateway/controller_test.go create mode 100644 internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go create mode 100644 internal/go/providers/route53/conversion/alias.go create mode 100644 internal/go/providers/route53/conversion/handler.go create mode 100644 internal/go/providers/route53/conversion/handler_test.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/doc.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/endpointrecordsetconversion_types.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/groupversion_info.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/register.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/scheme_test.go create mode 100644 pkg/go/api/endpoint/conversion/v1alpha1/zz_generated.deepcopy.go create mode 100644 pkg/go/api/endpoint/v1alpha1/doc.go create mode 100644 pkg/go/api/endpoint/v1alpha1/endpointprovider_capability_types.go create mode 100644 pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go create mode 100644 pkg/go/api/endpoint/v1alpha1/groupversion_info.go create mode 100644 pkg/go/api/endpoint/v1alpha1/register.go create mode 100644 pkg/go/api/endpoint/v1alpha1/scheme_test.go create mode 100644 pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go diff --git a/Taskfile.yml b/Taskfile.yml index afb1d39..d18b491 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -221,6 +221,7 @@ tasks: - task: controller-image:build - task: controller-image:load-kind - task: cert-manager:install + - task: gateway-api:install - task: manifests:apply - task: controller:rollout-status @@ -249,12 +250,18 @@ tasks: --set crds.enabled=true \ --wait + gateway-api:install: + desc: Install Gateway API CRDs into the current Kubernetes context. + cmds: + - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml + - kubectl wait --for=condition=Established crd/gatewayclasses.gateway.networking.k8s.io crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io --timeout=120s + manifests:apply: desc: Generate and apply dns-api manifests to the current Kubernetes context. cmds: - task: manifests - kustomize build app/operator/config/crd | kubectl apply -f - - - kubectl wait --for=condition=Established crd/providers.dns.appthrust.io crd/zoneclasses.dns.appthrust.io crd/zones.dns.appthrust.io crd/recordsets.dns.appthrust.io crd/zoneunits.dns.appthrust.io crd/cloudflareidentities.cloudflare.dns.appthrust.io crd/route53identities.route53.dns.appthrust.io --timeout=120s + - kubectl wait --for=condition=Established crd/providers.dns.appthrust.io crd/zoneclasses.dns.appthrust.io crd/zones.dns.appthrust.io crd/recordsets.dns.appthrust.io crd/zoneunits.dns.appthrust.io crd/endpointprovidercapabilities.endpoint.dns.appthrust.io crd/endpointrecordsets.endpoint.dns.appthrust.io crd/cloudflareidentities.cloudflare.dns.appthrust.io crd/route53identities.route53.dns.appthrust.io --timeout=120s - kustomize build app/operator/config/default | kubectl apply -f - controller-image:build: @@ -574,6 +581,7 @@ tasks: vars: KIND_CLUSTER: '{{.KIND_CLUSTER}}' - task: cert-manager:install + - task: gateway-api:install - task: local-irsa:install vars: LOCAL_IRSA_NAME: '{{.LOCAL_IRSA_NAME}}' @@ -614,6 +622,7 @@ tasks: vars: KIND_CLUSTER: '{{.KIND_CLUSTER}}' - task: cert-manager:install + - task: gateway-api:install - task: manifests:apply - task: controller:rollout-status - task: controller:webhook-ready diff --git a/app/operator/cmd/manager/main.go b/app/operator/cmd/manager/main.go index 66198ac..1f433f8 100644 --- a/app/operator/cmd/manager/main.go +++ b/app/operator/cmd/manager/main.go @@ -4,6 +4,8 @@ import ( "flag" "os" + endpointrecordsetcontroller "github.com/appthrust/dns-api/internal/go/apps/endpoint/controllers/endpointrecordset" + gatewayendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/gatewayendpoint/controllers/gateway" zoneunitcontroller "github.com/appthrust/dns-api/internal/go/core/controllers/zoneunit" corewebhook "github.com/appthrust/dns-api/internal/go/core/webhook" cloudflareidentity "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/identity" @@ -13,9 +15,12 @@ import ( 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" + route53conversion "github.com/appthrust/dns-api/internal/go/providers/route53/conversion" route53webhook "github.com/appthrust/dns-api/internal/go/providers/route53/webhook" cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1" 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" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -24,11 +29,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=create;get;list;update;watch // +kubebuilder:rbac:groups=cloudflare.dns.appthrust.io,resources=cloudflareidentities,verbs=get;list;patch;update;watch // +kubebuilder:rbac:groups=cloudflare.dns.appthrust.io,resources=cloudflareidentities/status,verbs=get;patch;update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=providers,verbs=get;list;watch @@ -37,12 +44,19 @@ import ( // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zones,verbs=get;list;watch;patch;update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zones/finalizers,verbs=update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zones/status,verbs=get;patch;update -// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets,verbs=get;list;watch;patch;update +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets,verbs=create;delete;get;list;watch;patch;update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets/finalizers,verbs=update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets/status,verbs=get;patch;update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits,verbs=create;delete;get;list;watch;patch;update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits/finalizers,verbs=update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits/status,verbs=get;watch;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointprovidercapabilities,verbs=get;list;watch +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/status,verbs=get;patch;update +// +kubebuilder:rbac:groups=endpoint.route53.dns.appthrust.io,resources=endpointrecordsetconversions,verbs=create +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;update +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/finalizers,verbs=update // +kubebuilder:rbac:groups=route53.dns.appthrust.io,resources=route53identities,verbs=get;list;patch;update;watch // +kubebuilder:rbac:groups=route53.dns.appthrust.io,resources=route53identities/status,verbs=get;patch;update @@ -52,6 +66,9 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(cloudflarev1alpha1.AddToScheme(scheme)) utilruntime.Must(dnsv1alpha1.AddToScheme(scheme)) + utilruntime.Must(endpointv1alpha1.AddToScheme(scheme)) + utilruntime.Must(endpointconversionv1alpha1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.Install(scheme)) utilruntime.Must(route53v1alpha1.AddToScheme(scheme)) } @@ -59,6 +76,7 @@ func main() { var metricsAddr string var probeAddr string var leaderElection bool + endpointRecordSetNamespace := envOrDefault("ENDPOINT_RECORDSET_NAMESPACE", "") route53ControllerName := envOrDefault("ROUTE53_CONTROLLER_NAME", route53zoneunit.DefaultControllerName) route53ProviderName := envOrDefault("ROUTE53_PROVIDER_NAME", route53v1alpha1.ProviderName) route53ProviderVersion := envOrDefault("ROUTE53_PROVIDER_VERSION", route53v1alpha1.ProviderVersion) @@ -69,6 +87,7 @@ func main() { 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.") flag.BoolVar(&leaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.StringVar(&endpointRecordSetNamespace, "endpoint-recordset-namespace", endpointRecordSetNamespace, "Namespace where generated EndpointRecordSet and RecordSet resources are stored. Defaults to the source namespace.") 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.") @@ -167,6 +186,25 @@ func main() { os.Exit(1) } + if err := (&endpointrecordsetcontroller.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + RESTConfig: mgr.GetConfig(), + RecordSetNamespace: endpointRecordSetNamespace, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up EndpointRecordSet controller") + os.Exit(1) + } + + if err := (&gatewayendpointcontroller.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EndpointRecordSetNamespace: endpointRecordSetNamespace, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up Gateway Endpoint controller") + os.Exit(1) + } + if err := corewebhook.SetupCoreValidationWebhookWithManager(mgr); err != nil { ctrl.Log.Error(err, "unable to set up core validation webhook") os.Exit(1) @@ -179,6 +217,7 @@ func main() { ctrl.Log.Error(err, "unable to set up Cloudflare validation webhook") os.Exit(1) } + route53conversion.NewHandler().Register(mgr.GetWebhookServer()) if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { ctrl.Log.Error(err, "unable to set up health check") diff --git a/app/operator/config/apiservice/kustomization.yaml b/app/operator/config/apiservice/kustomization.yaml new file mode 100644 index 0000000..159efac --- /dev/null +++ b/app/operator/config/apiservice/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - route53_endpoint_conversion_apiservice.yaml diff --git a/app/operator/config/apiservice/route53_endpoint_conversion_apiservice.yaml b/app/operator/config/apiservice/route53_endpoint_conversion_apiservice.yaml new file mode 100644 index 0000000..65a21cc --- /dev/null +++ b/app/operator/config/apiservice/route53_endpoint_conversion_apiservice.yaml @@ -0,0 +1,15 @@ +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.endpoint.route53.dns.appthrust.io + annotations: + cert-manager.io/inject-ca-from: dns-api-system/dns-api-webhook-serving-cert +spec: + group: endpoint.route53.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/crd/bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml new file mode 100644 index 0000000..13c263d --- /dev/null +++ b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: endpointprovidercapabilities.endpoint.dns.appthrust.io +spec: + group: endpoint.dns.appthrust.io + names: + kind: EndpointProviderCapability + listKind: EndpointProviderCapabilityList + plural: endpointprovidercapabilities + singular: endpointprovidercapability + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.provider.name + name: Provider + type: string + - jsonPath: .spec.provider.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EndpointProviderCapability declares endpoint app support for + one Core Provider version. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + conversion: + description: |- + Conversion declares the aggregated API resource for provider-specific + endpoint RecordSet conversion. + properties: + group: + description: Group is the API group that serves EndpointRecordSetConversion. + minLength: 1 + type: string + resource: + description: Resource is the plural resource name, normally endpointrecordsetconversions. + minLength: 1 + type: string + version: + description: Version defaults to spec.provider.version when omitted. + type: string + required: + - group + - resource + type: object + provider: + description: Provider references a Core Provider version. + properties: + name: + description: Name is the cluster-scoped Provider resource name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + required: + - conversion + - provider + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml new file mode 100644 index 0000000..cb95758 --- /dev/null +++ b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml @@ -0,0 +1,454 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: endpointrecordsets.endpoint.dns.appthrust.io +spec: + group: endpoint.dns.appthrust.io + names: + kind: EndpointRecordSet + listKind: EndpointRecordSetList + plural: endpointrecordsets + singular: endpointrecordset + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.hostnameCount + name: Hostnames + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EndpointRecordSet is a provider-neutral endpoint publishing intent. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + hostnames: + description: Hostnames are fully qualified DNS hostnames to publish. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + targets: + description: Targets are endpoint addresses that the hostnames should + publish. + items: + description: EndpointTarget is one provider-neutral endpoint address. + properties: + type: + description: Type is the target address type. + enum: + - Hostname + - IPAddress + type: string + value: + description: Value is the target address value. + minLength: 1 + type: string + required: + - type + - value + type: object + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - hostnames + - targets + type: object + status: + properties: + conditions: + description: Conditions summarizes endpoint record set reconciliation. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameCount: + description: HostnameCount is the number of hostname status entries. + format: int32 + type: integer + hostnames: + description: Hostnames contains per-hostname resolution and generated + RecordSet status. + items: + properties: + conditions: + description: Conditions describes per-hostname reconciliation. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostname: + description: Hostname is the DNS hostname from spec.hostnames. + minLength: 1 + type: string + recordSets: + description: RecordSets contains generated RecordSet outputs + for this hostname. + items: + properties: + conditions: + description: Conditions mirrors generated RecordSet conditions. + items: + description: Condition contains details for one aspect + of the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, + False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in + foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + fragment: + description: Fragment is the Provider-converted RecordSet.spec + fragment. + properties: + a: + description: A defines a standard A record body. + properties: + addresses: + description: Addresses contains IPv4 addresses. + items: + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$ + type: string + minItems: 1 + type: array + required: + - addresses + type: object + aaaa: + description: AAAA defines a standard AAAA record body. + properties: + addresses: + description: Addresses contains IPv6 addresses. + items: + type: string + minItems: 1 + type: array + required: + - addresses + type: object + cname: + description: CNAME defines a standard CNAME record + body. + properties: + target: + description: Target contains the canonical target + DNS name without a trailing root dot. + minLength: 1 + type: string + required: + - target + type: object + name: + description: Name is the relative DNS owner name in + the Zone. + minLength: 1 + type: string + options: + description: Options contains provider-specific record + options. + type: object + x-kubernetes-preserve-unknown-fields: true + ttl: + description: TTL is the record TTL in seconds. + format: int32 + maximum: 2147483647 + minimum: 1 + type: integer + type: + description: Type is the DNS record type. + enum: + - A + - AAAA + - CNAME + type: string + required: + - name + - type + type: object + x-kubernetes-validations: + - message: a or options is required when type is A + rule: self.type != 'A' || has(self.a) || has(self.options) + - message: aaaa or options is required when type is AAAA + rule: self.type != 'AAAA' || has(self.aaaa) || has(self.options) + - message: cname is required when type is CNAME + rule: self.type != 'CNAME' || has(self.cname) + name: + description: Name is the zone-relative DNS owner name. + type: string + ref: + description: Ref points to the generated Core RecordSet. + properties: + name: + description: Name is the object name. + minLength: 1 + type: string + namespace: + description: Namespace is the object namespace. + minLength: 1 + type: string + required: + - name + - namespace + type: object + type: + description: Type is the DNS record type. + enum: + - A + - AAAA + - CNAME + type: string + required: + - name + - ref + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + - type + x-kubernetes-list-type: map + zone: + description: Zone is the selected Zone for this hostname. + properties: + domainName: + description: DomainName is the selected Zone domain name. + type: string + provider: + description: Provider is the selected Zone provider. + properties: + name: + description: Name is the cluster-scoped Provider resource + name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + ref: + description: Ref points to the selected Zone. + properties: + name: + description: Name is the object name. + minLength: 1 + type: string + namespace: + description: Namespace is the object namespace. + minLength: 1 + type: string + required: + - name + - namespace + type: object + required: + - domainName + - provider + - ref + type: object + required: + - hostname + type: object + type: array + x-kubernetes-list-map-keys: + - hostname + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration is the generation observed by the + endpoint controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/app/operator/config/crd/kustomization.yaml b/app/operator/config/crd/kustomization.yaml index 977038e..8b8bcde 100644 --- a/app/operator/config/crd/kustomization.yaml +++ b/app/operator/config/crd/kustomization.yaml @@ -4,5 +4,7 @@ resources: - bases/dns.appthrust.io_zones.yaml - bases/dns.appthrust.io_recordsets.yaml - bases/dns.appthrust.io_zoneunits.yaml + - bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml + - bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml - bases/cloudflare.dns.appthrust.io_cloudflareidentities.yaml - bases/route53.dns.appthrust.io_route53identities.yaml diff --git a/app/operator/config/default/kustomization.yaml b/app/operator/config/default/kustomization.yaml index fcc6286..b399c6f 100644 --- a/app/operator/config/default/kustomization.yaml +++ b/app/operator/config/default/kustomization.yaml @@ -6,3 +6,4 @@ resources: - ../manager - ../webhook - ../certmanager + - ../apiservice diff --git a/app/operator/config/manager/manager.yaml b/app/operator/config/manager/manager.yaml index b89500a..8a9ea46 100644 --- a/app/operator/config/manager/manager.yaml +++ b/app/operator/config/manager/manager.yaml @@ -24,7 +24,8 @@ spec: image: dns-api-controller imagePullPolicy: IfNotPresent args: - - --leader-elect=false + - --leader-elect=true + - --endpoint-recordset-namespace=dns-api-system ports: - name: metrics containerPort: 8080 diff --git a/app/operator/config/namespace/namespace.yaml b/app/operator/config/namespace/namespace.yaml index 73426f2..fa4ee6c 100644 --- a/app/operator/config/namespace/namespace.yaml +++ b/app/operator/config/namespace/namespace.yaml @@ -2,3 +2,5 @@ apiVersion: v1 kind: Namespace metadata: name: dns-api-system + labels: + dns-api: system diff --git a/app/operator/config/provider/endpoint_capabilities.yaml b/app/operator/config/provider/endpoint_capabilities.yaml new file mode 100644 index 0000000..2d53086 --- /dev/null +++ b/app/operator/config/provider/endpoint_capabilities.yaml @@ -0,0 +1,12 @@ +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: route53.dns.appthrust.io-v1alpha1 +spec: + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.route53.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions diff --git a/app/operator/config/provider/kustomization.yaml b/app/operator/config/provider/kustomization.yaml index 7d5e5e8..e68ce23 100644 --- a/app/operator/config/provider/kustomization.yaml +++ b/app/operator/config/provider/kustomization.yaml @@ -1,3 +1,4 @@ resources: - cloudflare_extensions.yaml + - endpoint_capabilities.yaml - route53_extensions.yaml diff --git a/app/operator/config/rbac/role.yaml b/app/operator/config/rbac/role.yaml index 763c8bd..6b2ff41 100644 --- a/app/operator/config/rbac/role.yaml +++ b/app/operator/config/rbac/role.yaml @@ -38,6 +38,16 @@ rules: - get - patch - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - get + - list + - update + - watch - apiGroups: - dns.appthrust.io resources: @@ -50,9 +60,10 @@ rules: - dns.appthrust.io resources: - recordsets - - zoneclasses - - zones + - zoneunits verbs: + - create + - delete - get - list - patch @@ -79,10 +90,9 @@ rules: - apiGroups: - dns.appthrust.io resources: - - zoneunits + - zoneclasses + - zones verbs: - - create - - delete - get - list - patch @@ -97,6 +107,63 @@ rules: - patch - update - watch +- apiGroups: + - endpoint.dns.appthrust.io + resources: + - endpointprovidercapabilities + verbs: + - get + - list + - watch +- apiGroups: + - endpoint.dns.appthrust.io + resources: + - endpointrecordsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - endpoint.dns.appthrust.io + resources: + - endpointrecordsets/status + verbs: + - get + - patch + - update +- apiGroups: + - endpoint.route53.dns.appthrust.io + resources: + - endpointrecordsetconversions + verbs: + - create +- apiGroups: + - gateway.networking.k8s.io + resources: + - gateways + verbs: + - get + - list + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes + verbs: + - get + - list + - update + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes/finalizers + verbs: + - update - apiGroups: - route53.dns.appthrust.io resources: diff --git a/app/operator/tests/e2e/recordset-api/route53-gateway-api-alias-lifecycle.test.ts b/app/operator/tests/e2e/recordset-api/route53-gateway-api-alias-lifecycle.test.ts new file mode 100644 index 0000000..4fc2cbf --- /dev/null +++ b/app/operator/tests/e2e/recordset-api/route53-gateway-api-alias-lifecycle.test.ts @@ -0,0 +1,639 @@ +import { + GetHostedZoneCommand, + ListResourceRecordSetsCommand, + Route53Client, +} from "@aws-sdk/client-route-53"; +import { type K8sResource, test } from "@appthrust/kest"; +import { expect } from "bun:test"; + +if (!process.env.AWS_PROFILE && process.env.PROFILE) { + process.env.AWS_PROFILE = process.env.PROFILE; +} +process.env.AWS_REGION ??= "ap-northeast-1"; + +const awsRegion = process.env.AWS_REGION ?? "ap-northeast-1"; +const route53 = new Route53Client({ region: awsRegion }); +const route53AccountID = process.env.ROUTE53_ACCOUNT_ID; +if (!route53AccountID) { + throw new Error("ROUTE53_ACCOUNT_ID is required"); +} + +const waitForRoute53 = { + timeout: "5m", + interval: "5s", + stallTimeout: "0s", +}; + +interface Condition { + type: string; + status: "True" | "False" | "Unknown"; + reason?: string; +} + +interface Route53Identity extends K8sResource { + apiVersion: "route53.dns.appthrust.io/v1alpha1"; + kind: "Route53Identity"; + spec: { + accountID: string; + region: string; + credentials: { + runtime: Record; + }; + }; +} + +interface ZoneClass extends K8sResource { + apiVersion: "dns.appthrust.io/v1alpha1"; + kind: "ZoneClass"; + spec: { + allowedZones: { + namespaces: { + from: "Selector"; + selector: { + matchLabels: Record; + }; + }; + }; + provider: { name: string; version: string }; + controllerName: string; + identityRef: { + name: string; + }; + parameters: { + zoneCreationPolicy: "Create"; + zoneDeletionPolicy: "Delete"; + sameNameZonePolicy: "Deny"; + tags: Record; + }; + }; +} + +interface Zone extends K8sResource { + apiVersion: "dns.appthrust.io/v1alpha1"; + kind: "Zone"; + spec: { + domainName: string; + provider: { name: string; version: string }; + zoneClassRef: { + namespace: string; + name: string; + }; + allowedRecordSets?: Array<{ + namespaces: { + selector: { + matchLabels: Record; + }; + }; + records: Array<{ + name: { + pattern: string; + }; + types: Array<"A" | "AAAA" | "CNAME">; + }>; + }>; + }; + status?: { + provider?: { + data?: { + hostedZoneID?: string; + }; + }; + conditions?: Array; + }; +} + +interface Gateway extends K8sResource { + apiVersion: "gateway.networking.k8s.io/v1"; + kind: "Gateway"; + spec: { + gatewayClassName: string; + listeners: Array<{ + name: string; + protocol: "HTTPS"; + port: number; + hostname?: string; + allowedRoutes?: { + namespaces: { + from: "All"; + }; + }; + }>; + }; +} + +interface HTTPRoute extends K8sResource { + apiVersion: "gateway.networking.k8s.io/v1"; + kind: "HTTPRoute"; + spec: { + parentRefs: Array<{ + namespace: string; + name: string; + sectionName: string; + }>; + hostnames?: Array; + rules: Array<{ + backendRefs: Array<{ + name: string; + port: number; + }>; + }>; + }; +} + +interface EndpointRecordSet extends K8sResource { + apiVersion: "endpoint.dns.appthrust.io/v1alpha1"; + kind: "EndpointRecordSet"; + status?: { + hostnameCount?: number; + hostnames?: Array<{ + hostname: string; + zone?: { + ref: { + namespace: string; + name: string; + }; + domainName: string; + }; + recordSets?: Array<{ + ref: { + namespace: string; + name: string; + }; + type: "A" | "AAAA" | "CNAME"; + name: string; + fragment?: { + type: "A" | "AAAA" | "CNAME"; + name: string; + options?: { + alias?: { + dnsName: string; + hostedZoneID: string; + evaluateTargetHealth: boolean; + }; + }; + }; + }>; + conditions?: Array; + }>; + conditions?: Array; + }; +} + +test( + "Gateway API HTTPRoute hostnames become Route 53 ALIAS A and AAAA RecordSets", + async (s) => { + s.given("Gateway API CRDs and the dns-api controller are installed"); + await s.exec({ + do: async ({ $ }) => { + await $`kubectl get crd gateways.gateway.networking.k8s.io httproutes.gateway.networking.k8s.io endpointrecordsets.endpoint.dns.appthrust.io`; + await $`kubectl -n dns-api-system get deploy dns-api-controller-manager`; + }, + }); + + s.given("platform and application namespaces exist"); + const platform = await s.newNamespace({ + generateName: "dns-api-platform-", + }); + const app = await s.newNamespace({ generateName: "dns-api-app-" }); + const testID = s.generateName("gw-"); + const domainName = `${testID}.dns-api.test`; + const gatewayAddress = "k8s-public-123456.ap-northeast-1.elb.amazonaws.com"; + const aliasDNSName = `${gatewayAddress}.`; + const aliasHostedZoneID = "Z14GRHDCWA56QT"; + + await s.label( + { + apiVersion: "v1", + kind: "Namespace", + name: app.name, + labels: { + "appthrust.io/tenant": testID, + }, + overwrite: true, + }, + { timeout: "30s" }, + ); + + s.given("a Route53Identity and ZoneClass allow the application namespace"); + await platform.apply({ + apiVersion: "route53.dns.appthrust.io/v1alpha1", + kind: "Route53Identity", + metadata: { name: "route53-dev" }, + spec: { + accountID: route53AccountID, + region: awsRegion, + credentials: { + runtime: {}, + }, + }, + }); + await platform.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "ZoneClass", + metadata: { name: "route53-public" }, + spec: { + allowedZones: { + namespaces: { + from: "Selector", + selector: { + matchLabels: { + "appthrust.io/tenant": testID, + }, + }, + }, + }, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + controllerName: "route53.dns.appthrust.io/controller", + identityRef: { + name: "route53-dev", + }, + parameters: { + zoneCreationPolicy: "Create", + zoneDeletionPolicy: "Delete", + sameNameZonePolicy: "Deny", + tags: { + "appthrust.io/test-scope": "kest", + "appthrust.io/test-id": testID, + }, + }, + }, + }); + + s.when("an application Zone is created"); + await app.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + metadata: { name: "apps-example-com" }, + spec: { + domainName, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + zoneClassRef: { + namespace: platform.name, + name: "route53-public", + }, + allowedRecordSets: [ + { + namespaces: { + selector: { + matchLabels: { + "dns-api": "system", + }, + }, + }, + records: [ + { + name: { + pattern: "api", + }, + types: ["A", "AAAA"], + }, + ], + }, + ], + }, + }); + + s.then("the Zone reports Accepted=True and Programmed=True"); + const zone = await app.assert( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + test() { + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Accepted", + status: "True", + }), + ); + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Programmed", + status: "True", + }), + ); + expect(this.status?.provider?.data?.hostedZoneID).toMatch(/^Z[A-Z0-9]+$/); + }, + }, + waitForRoute53, + ); + const hostedZoneID = requireString(zone.status?.provider?.data?.hostedZoneID); + + s.then("the Route 53 hosted zone exists before Gateway DNS is generated"); + await s.exec( + { + do: async () => { + await assertHostedZoneExists(hostedZoneID, domainName); + }, + }, + waitForRoute53, + ); + + s.when("a Gateway listener has a concrete hostname and a Route wildcard matches it"); + await platform.apply({ + apiVersion: "gateway.networking.k8s.io/v1", + kind: "Gateway", + metadata: { name: "public" }, + spec: { + gatewayClassName: "example", + listeners: [ + { + name: "https-api", + protocol: "HTTPS", + port: 443, + hostname: `api.${domainName}`, + allowedRoutes: { + namespaces: { + from: "All", + }, + }, + }, + ], + }, + }); + await app.apply({ + apiVersion: "gateway.networking.k8s.io/v1", + kind: "HTTPRoute", + metadata: { name: "web" }, + spec: { + parentRefs: [ + { + namespace: platform.name, + name: "public", + sectionName: "https-api", + }, + ], + hostnames: [`*.${domainName}`], + rules: [ + { + backendRefs: [ + { + name: "web", + port: 8080, + }, + ], + }, + ], + }, + }); + await s.exec({ + do: async ({ $ }) => { + await $`kubectl -n ${platform.name} patch gateway public --subresource=status --type=merge -p ${JSON.stringify({ + status: { + addresses: [ + { + type: "Hostname", + value: gatewayAddress, + }, + ], + }, + })}`; + await $`kubectl -n ${app.name} patch httproute web --subresource=status --type=merge -p ${JSON.stringify({ + status: { + parents: [ + { + parentRef: { + namespace: platform.name, + name: "public", + sectionName: "https-api", + }, + controllerName: "example.com/gateway-controller", + conditions: [ + { + type: "Accepted", + status: "True", + reason: "Accepted", + message: "Accepted by test Gateway controller status.", + lastTransitionTime: new Date().toISOString(), + }, + ], + }, + ], + }, + })}`; + }, + }); + + s.then("EndpointRecordSet explains that the concrete hostname was selected"); + await s.exec( + { + do: async ({ $ }) => { + const selector = [ + "app.kubernetes.io/managed-by=dns-api-gateway-endpoint", + `gateway.endpoint.dns.appthrust.io/route-namespace=${app.name}`, + "gateway.endpoint.dns.appthrust.io/route-name=web", + ].join(","); + const output = + await $`kubectl -n dns-api-system get endpointrecordsets.endpoint.dns.appthrust.io -l ${selector} -o json`.text(); + const list = JSON.parse(output) as { items?: Array }; + expect(list.items).toHaveLength(1); + const endpointRecordSet = requireValue(list.items?.[0]); + expect(endpointRecordSet.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Resolved", + status: "True", + }), + ); + expect(endpointRecordSet.status?.hostnameCount).toBe(1); + const hostname = endpointRecordSet.status?.hostnames?.find( + (item) => item.hostname === `api.${domainName}`, + ); + expect(hostname).toBeDefined(); + expect(hostname?.conditions).toContainEqual( + expect.objectContaining({ + type: "Resolved", + status: "True", + }), + ); + expect(hostname?.zone).toMatchObject({ + ref: { + namespace: app.name, + name: "apps-example-com", + }, + domainName, + }); + expect(recordSetTypes(hostname?.recordSets)).toEqual(["A", "AAAA"]); + for (const recordSet of hostname?.recordSets ?? []) { + expect(recordSet.ref.namespace).toBe("dns-api-system"); + expect(recordSet.name).toBe("api"); + expect(recordSet.fragment?.options?.alias).toMatchObject({ + dnsName: aliasDNSName, + hostedZoneID: aliasHostedZoneID, + evaluateTargetHealth: false, + }); + } + }, + }, + waitForRoute53, + ); + + s.then("Route 53 has generated ALIAS A and AAAA records for the Gateway hostname"); + await s.exec( + { + do: async () => { + await assertAliasRecordSetExists({ + hostedZoneID, + recordName: `api.${domainName}.`, + type: "A", + aliasDNSName, + aliasHostedZoneID, + }); + await assertAliasRecordSetExists({ + hostedZoneID, + recordName: `api.${domainName}.`, + type: "AAAA", + aliasDNSName, + aliasHostedZoneID, + }); + }, + }, + waitForRoute53, + ); + + s.when("the HTTPRoute is deleted"); + await app.delete( + { + apiVersion: "gateway.networking.k8s.io/v1", + kind: "HTTPRoute", + name: "web", + }, + waitForRoute53, + ); + await app.assertAbsence( + { + apiVersion: "gateway.networking.k8s.io/v1", + kind: "HTTPRoute", + name: "web", + }, + waitForRoute53, + ); + + s.then("the generated Route 53 ALIAS records are gone"); + await s.exec( + { + do: async () => { + await assertRecordSetAbsent(hostedZoneID, `api.${domainName}.`, "A"); + await assertRecordSetAbsent(hostedZoneID, `api.${domainName}.`, "AAAA"); + }, + }, + waitForRoute53, + ); + + s.when("the Zone is deleted"); + await app.delete( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + }, + waitForRoute53, + ); + await app.assertAbsence( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + }, + waitForRoute53, + ); + + s.then("the Route 53 hosted zone is gone"); + await s.exec( + { + do: async () => { + await assertHostedZoneAbsent(hostedZoneID); + }, + }, + waitForRoute53, + ); + }, + { timeout: "25m" }, +); + +type EndpointRecordSetHostnameStatus = NonNullable< + NonNullable["hostnames"] +>[number]; + +function recordSetTypes(recordSets: EndpointRecordSetHostnameStatus["recordSets"]) { + return (recordSets ?? []).map((recordSet) => recordSet.type).sort(); +} + +async function assertHostedZoneExists(hostedZoneID: string, domainName: string) { + const output = await route53.send(new GetHostedZoneCommand({ Id: hostedZoneID })); + expect(output.HostedZone?.Name).toBe(`${domainName}.`); +} + +async function assertHostedZoneAbsent(hostedZoneID: string) { + try { + await route53.send(new GetHostedZoneCommand({ Id: hostedZoneID })); + } catch (error) { + expect(awsErrorName(error)).toBe("NoSuchHostedZone"); + return; + } + throw new Error(`Route 53 hosted zone ${hostedZoneID} still exists`); +} + +async function assertAliasRecordSetExists(input: { + hostedZoneID: string; + recordName: string; + type: "A" | "AAAA"; + aliasDNSName: string; + aliasHostedZoneID: string; +}) { + const record = await getRecordSet(input.hostedZoneID, input.recordName, input.type); + expect(record?.Name).toBe(input.recordName); + expect(record?.Type).toBe(input.type); + expect(record?.TTL).toBeUndefined(); + expect(record?.ResourceRecords).toBeUndefined(); + expect(record?.AliasTarget).toMatchObject({ + DNSName: input.aliasDNSName, + HostedZoneId: input.aliasHostedZoneID, + EvaluateTargetHealth: false, + }); +} + +async function assertRecordSetAbsent( + hostedZoneID: string, + recordName: string, + type: "A" | "AAAA", +) { + const record = await getRecordSet(hostedZoneID, recordName, type); + expect(record).toBeUndefined(); +} + +async function getRecordSet( + hostedZoneID: string, + recordName: string, + type: "A" | "AAAA", +) { + const output = await route53.send( + new ListResourceRecordSetsCommand({ + HostedZoneId: hostedZoneID, + StartRecordName: recordName, + StartRecordType: type, + MaxItems: 1, + }), + ); + const record = output.ResourceRecordSets?.[0]; + if (record?.Name !== recordName || record.Type !== type) { + return undefined; + } + return record; +} + +function awsErrorName(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("name" in error)) { + return undefined; + } + return String(error.name); +} + +function requireString(value: unknown): string { + expect(value).toBeString(); + return value as string; +} + +function requireValue(value: T | undefined | null): T { + expect(value).toBeDefined(); + return value as T; +} diff --git a/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml new file mode 100644 index 0000000..13c263d --- /dev/null +++ b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: endpointprovidercapabilities.endpoint.dns.appthrust.io +spec: + group: endpoint.dns.appthrust.io + names: + kind: EndpointProviderCapability + listKind: EndpointProviderCapabilityList + plural: endpointprovidercapabilities + singular: endpointprovidercapability + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.provider.name + name: Provider + type: string + - jsonPath: .spec.provider.version + name: Version + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EndpointProviderCapability declares endpoint app support for + one Core Provider version. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + conversion: + description: |- + Conversion declares the aggregated API resource for provider-specific + endpoint RecordSet conversion. + properties: + group: + description: Group is the API group that serves EndpointRecordSetConversion. + minLength: 1 + type: string + resource: + description: Resource is the plural resource name, normally endpointrecordsetconversions. + minLength: 1 + type: string + version: + description: Version defaults to spec.provider.version when omitted. + type: string + required: + - group + - resource + type: object + provider: + description: Provider references a Core Provider version. + properties: + name: + description: Name is the cluster-scoped Provider resource name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + required: + - conversion + - provider + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml new file mode 100644 index 0000000..cb95758 --- /dev/null +++ b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml @@ -0,0 +1,454 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: endpointrecordsets.endpoint.dns.appthrust.io +spec: + group: endpoint.dns.appthrust.io + names: + kind: EndpointRecordSet + listKind: EndpointRecordSetList + plural: endpointrecordsets + singular: endpointrecordset + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.hostnameCount + name: Hostnames + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EndpointRecordSet is a provider-neutral endpoint publishing intent. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + hostnames: + description: Hostnames are fully qualified DNS hostnames to publish. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + targets: + description: Targets are endpoint addresses that the hostnames should + publish. + items: + description: EndpointTarget is one provider-neutral endpoint address. + properties: + type: + description: Type is the target address type. + enum: + - Hostname + - IPAddress + type: string + value: + description: Value is the target address value. + minLength: 1 + type: string + required: + - type + - value + type: object + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - hostnames + - targets + type: object + status: + properties: + conditions: + description: Conditions summarizes endpoint record set reconciliation. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostnameCount: + description: HostnameCount is the number of hostname status entries. + format: int32 + type: integer + hostnames: + description: Hostnames contains per-hostname resolution and generated + RecordSet status. + items: + properties: + conditions: + description: Conditions describes per-hostname reconciliation. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + hostname: + description: Hostname is the DNS hostname from spec.hostnames. + minLength: 1 + type: string + recordSets: + description: RecordSets contains generated RecordSet outputs + for this hostname. + items: + properties: + conditions: + description: Conditions mirrors generated RecordSet conditions. + items: + description: Condition contains details for one aspect + of the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, + False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in + foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + fragment: + description: Fragment is the Provider-converted RecordSet.spec + fragment. + properties: + a: + description: A defines a standard A record body. + properties: + addresses: + description: Addresses contains IPv4 addresses. + items: + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$ + type: string + minItems: 1 + type: array + required: + - addresses + type: object + aaaa: + description: AAAA defines a standard AAAA record body. + properties: + addresses: + description: Addresses contains IPv6 addresses. + items: + type: string + minItems: 1 + type: array + required: + - addresses + type: object + cname: + description: CNAME defines a standard CNAME record + body. + properties: + target: + description: Target contains the canonical target + DNS name without a trailing root dot. + minLength: 1 + type: string + required: + - target + type: object + name: + description: Name is the relative DNS owner name in + the Zone. + minLength: 1 + type: string + options: + description: Options contains provider-specific record + options. + type: object + x-kubernetes-preserve-unknown-fields: true + ttl: + description: TTL is the record TTL in seconds. + format: int32 + maximum: 2147483647 + minimum: 1 + type: integer + type: + description: Type is the DNS record type. + enum: + - A + - AAAA + - CNAME + type: string + required: + - name + - type + type: object + x-kubernetes-validations: + - message: a or options is required when type is A + rule: self.type != 'A' || has(self.a) || has(self.options) + - message: aaaa or options is required when type is AAAA + rule: self.type != 'AAAA' || has(self.aaaa) || has(self.options) + - message: cname is required when type is CNAME + rule: self.type != 'CNAME' || has(self.cname) + name: + description: Name is the zone-relative DNS owner name. + type: string + ref: + description: Ref points to the generated Core RecordSet. + properties: + name: + description: Name is the object name. + minLength: 1 + type: string + namespace: + description: Namespace is the object namespace. + minLength: 1 + type: string + required: + - name + - namespace + type: object + type: + description: Type is the DNS record type. + enum: + - A + - AAAA + - CNAME + type: string + required: + - name + - ref + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + - type + x-kubernetes-list-type: map + zone: + description: Zone is the selected Zone for this hostname. + properties: + domainName: + description: DomainName is the selected Zone domain name. + type: string + provider: + description: Provider is the selected Zone provider. + properties: + name: + description: Name is the cluster-scoped Provider resource + name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + ref: + description: Ref points to the selected Zone. + properties: + name: + description: Name is the object name. + minLength: 1 + type: string + namespace: + description: Namespace is the object namespace. + minLength: 1 + type: string + required: + - name + - namespace + type: object + required: + - domainName + - provider + - ref + type: object + required: + - hostname + type: object + type: array + x-kubernetes-list-map-keys: + - hostname + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration is the generation observed by the + endpoint controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/dns-api/templates/apiservice-route53-endpoint-conversion.yaml b/deploy/charts/dns-api/templates/apiservice-route53-endpoint-conversion.yaml new file mode 100644 index 0000000..ce249f5 --- /dev/null +++ b/deploy/charts/dns-api/templates/apiservice-route53-endpoint-conversion.yaml @@ -0,0 +1,24 @@ +{{- if and .Values.webhook.enabled .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled }} +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.endpoint.route53.dns.appthrust.io + labels: + {{- include "dns-api.labels" . | nindent 4 }} + {{- if .Values.certManager.enabled }} + annotations: + cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "dns-api.webhookCertificateName" . }} + {{- end }} +spec: + group: endpoint.route53.dns.appthrust.io + version: v1alpha1 + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: {{ include "dns-api.webhookServiceName" . }} + namespace: {{ .Release.Namespace }} + port: {{ .Values.webhook.service.port }} + {{- if not .Values.certManager.enabled }} + insecureSkipTLSVerify: true + {{- end }} +{{- end }} diff --git a/deploy/charts/dns-api/templates/deployment.yaml b/deploy/charts/dns-api/templates/deployment.yaml index a016ef6..d210af8 100644 --- a/deploy/charts/dns-api/templates/deployment.yaml +++ b/deploy/charts/dns-api/templates/deployment.yaml @@ -38,6 +38,7 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} args: - --leader-elect={{ .Values.leaderElection.enabled }} + - --endpoint-recordset-namespace={{ .Values.endpoint.recordSetNamespace }} {{- range .Values.extraArgs }} - {{ . | quote }} {{- end }} @@ -94,4 +95,3 @@ spec: secret: secretName: {{ include "dns-api.webhookSecretName" . }} {{- end }} - diff --git a/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml b/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml new file mode 100644 index 0000000..fa6460b --- /dev/null +++ b/deploy/charts/dns-api/templates/endpoint-capability-route53.yaml @@ -0,0 +1,14 @@ +{{- if and .Values.providers.route53.enabled .Values.providers.route53.endpoint.conversionAPI.enabled }} +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: route53.dns.appthrust.io-v1alpha1 +spec: + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.route53.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions +{{- end }} diff --git a/deploy/charts/dns-api/templates/rbac.yaml b/deploy/charts/dns-api/templates/rbac.yaml index d53c1a3..a60049a 100644 --- a/deploy/charts/dns-api/templates/rbac.yaml +++ b/deploy/charts/dns-api/templates/rbac.yaml @@ -40,6 +40,16 @@ rules: - get - patch - update + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - get + - list + - update + - watch - apiGroups: - dns.appthrust.io resources: @@ -52,9 +62,10 @@ rules: - dns.appthrust.io resources: - recordsets - - zoneclasses - - zones + - zoneunits verbs: + - create + - delete - get - list - patch @@ -81,7 +92,28 @@ rules: - apiGroups: - dns.appthrust.io resources: - - zoneunits + - zoneunits/status + verbs: + - get + - patch + - update + - watch + - apiGroups: + - dns.appthrust.io + resources: + - zoneclasses + - zones + verbs: + - get + - list + - patch + - update + - watch + - apiGroups: + - endpoint.dns.appthrust.io + resources: + - endpointprovidercapabilities + - endpointrecordsets verbs: - create - delete @@ -91,14 +123,42 @@ rules: - update - watch - apiGroups: - - dns.appthrust.io + - endpoint.dns.appthrust.io resources: - - zoneunits/status + - endpointrecordsets/status verbs: - get - patch - update + - apiGroups: + - endpoint.route53.dns.appthrust.io + resources: + - endpointrecordsetconversions + verbs: + - create + - apiGroups: + - gateway.networking.k8s.io + resources: + - gateways + verbs: + - get + - list - watch + - apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes + verbs: + - get + - list + - update + - watch + - apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes/finalizers + verbs: + - update - apiGroups: - route53.dns.appthrust.io resources: @@ -133,4 +193,3 @@ subjects: name: {{ include "dns-api.serviceAccountName" . }} namespace: {{ .Release.Namespace }} {{- end }} - diff --git a/deploy/charts/dns-api/values.yaml b/deploy/charts/dns-api/values.yaml index 27a7bc2..431f083 100644 --- a/deploy/charts/dns-api/values.yaml +++ b/deploy/charts/dns-api/values.yaml @@ -27,7 +27,10 @@ tolerations: [] affinity: {} leaderElection: - enabled: false + enabled: true + +endpoint: + recordSetNamespace: dns-api-system metrics: port: 8080 @@ -48,6 +51,9 @@ certManager: providers: route53: enabled: true + endpoint: + conversionAPI: + enabled: true cloudflare: enabled: true diff --git a/docs/design/endpoint-api.md b/docs/design/endpoint-api.md index bf237c0..1965a49 100644 --- a/docs/design/endpoint-api.md +++ b/docs/design/endpoint-api.md @@ -1,28 +1,62 @@ # Endpoint API -`endpoint.dns.appthrust.io` contains common DNS API App resources for publishing endpoint addresses into DNS. It is not Gateway-specific. Gateway, Ingress, Service, and future source Apps can reuse the same endpoint record set model and provider capability discovery. +`endpoint.dns.appthrust.io` contains common DNS API App resources for publishing endpoint addresses into DNS. It is not Gateway-specific. Gateway, Ingress, Service, and future source Apps create `EndpointRecordSet` resources. The endpoint controller resolves those resources into Core `RecordSet` resources. ## Endpoint RecordSet -An endpoint record set is a generated `A`, `AAAA`, or `CNAME` record candidate that publishes an endpoint address into DNS. +`EndpointRecordSet` is a persistent endpoint publishing intent. It says which DNS hostnames should point at which endpoint targets. It does not choose a DNS Provider, Zone, record type, TTL, or provider-specific options. -The v1 endpoint record set shape uses only these `RecordSet.spec` fields: +Example: + +```yaml +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointRecordSet +metadata: + namespace: app + name: web-public-https +spec: + hostnames: + - api.example.com + targets: + - type: Hostname + value: k8s-public-123.ap-northeast-1.elb.amazonaws.com +``` + +The source App owns source-specific interpretation. For example, the Gateway App reads `HTTPRoute` and `Gateway`, then creates one `EndpointRecordSet` for each accepted route/listener binding. The source App should use labels or owner references for source tracking. `EndpointRecordSet.spec` does not include `sourceRef` because the endpoint controller does not use it to reconcile DNS. + +`EndpointRecordSet.spec` contains: +- `hostnames`: fully qualified DNS hostnames to publish. +- `targets`: endpoint addresses. The initial target types are `Hostname` and `IPAddress`. + +It does not contain: + +- `provider` +- `zoneRef` - `type` -- `name` - `ttl` -- `a` -- `aaaa` -- `cname` +- `a`, `aaaa`, or `cname` - `options` +- `adoption` + +Those fields are selected or produced later by the endpoint controller and Provider conversion API. -It does not include `zoneRef`, `provider`, or `adoption`. The source App adds `zoneRef` and `provider` when it creates the final `RecordSet`. Endpoint record adoption is not supported in v1. +## Endpoint Controller -The v1 supported endpoint record set types are `A`, `AAAA`, and `CNAME`. +The endpoint controller reconciles `EndpointRecordSet` resources: + +1. Resolve each hostname to candidate Core `Zone` resources by longest suffix match. +2. Use the selected Zone's Core `Provider` reference. +3. Find the matching `EndpointProviderCapability`. +4. Create a provider-specific `EndpointRecordSetConversion` through the Kubernetes API server. +5. Receive `RecordSetSpecFragment` output from the Provider. +6. Attach `zoneRef` and `provider`, then apply Core `RecordSet` resources. + +The endpoint controller does not decide provider-specific record shape. It does not hard-code Route 53 alias hosted zone IDs or Cloudflare TTL behavior. ## EndpointProviderCapability -`EndpointProviderCapability` is a cluster-scoped discovery object that describes how one Core Provider version participates in endpoint record set Apps. +`EndpointProviderCapability` is a cluster-scoped discovery object that describes how one Core Provider version participates in endpoint Apps. Example: @@ -35,89 +69,86 @@ spec: provider: name: route53.dns.appthrust.io version: v1alpha1 - defaults: - ttl: 300 - refine: + conversion: group: endpoint.route53.dns.appthrust.io - resource: endpointrecordsetrefinereviews + resource: endpointrecordsetconversions ``` `spec.provider` references a Core Provider version. `spec.provider.name` is `Provider.metadata.name`; `spec.provider.version` is `Provider.spec.versions[].name`. -`spec.defaults.ttl` is an optional static TTL default for source Apps that create endpoint record set candidates. The value must be in the core `RecordSet.spec.ttl` range, `1..2147483647`. Source Apps apply this value before calling the refine API, unless the source workflow provides an explicit TTL override. - -`spec.refine` is optional. Simple providers that can accept provider-neutral endpoint record sets may omit it. Providers that need provider-specific record shaping, such as Route 53 ALIAS records or provider-specific TTL/options, declare `spec.refine`. - -When `spec.refine` is omitted, the source App uses the endpoint record set candidate after applying `spec.defaults`. This static path is valid only when the resulting endpoint record set can be converted directly to a dns-api `RecordSet.spec` and accepted by the Core Provider. If admission later rejects the generated `RecordSet`, the source App reports the admission failure and does not keep an invalid generated object. - -The effective refine GVR is: +`spec.conversion` identifies the provider-specific create-only conversion API. The effective conversion GVR is: ```text -group = spec.refine.group -version = spec.refine.version, or spec.provider.version when omitted -resource = spec.refine.resource +group = spec.conversion.group +version = spec.conversion.version, or spec.provider.version when omitted +resource = spec.conversion.resource ``` -`spec.refine.group` and `spec.refine.resource` are required when `spec.refine` is present. `spec.refine.version` is optional and must be non-empty when present. `spec.refine.resource` is the plural lowercase API resource name used by the Kubernetes API, such as `endpointrecordsetrefinereviews`. - -Endpoint API admission validates `EndpointProviderCapability` independently of endpoint record sets. It validates TTL range and static field shape, but it does not check whether the referenced Core Provider exists or whether the corresponding `APIService` is currently available. Runtime availability is reported by the source App as `EndpointRecordSetRefineUnavailable`. +For a given Core Provider `name` and `version`, at most one `EndpointProviderCapability` may be effective. The endpoint controller treats multiple matching capabilities as invalid configuration and reports `EndpointProviderCapabilityConflict`. -For a given Core Provider `name` and `version`, at most one `EndpointProviderCapability` may be effective. Source Apps treat multiple matching capabilities as invalid configuration and report `EndpointProviderCapabilityConflict`. +## Endpoint RecordSet Conversion API -## Endpoint RecordSet Refine API +`EndpointRecordSetConversion` is a create-only API served by an App Provider aggregated API server. It is not persisted. Kubernetes authentication, authorization, audit, API discovery, and APIService routing are handled by the Kubernetes API server. -The Endpoint RecordSet Refine API is a create-only review API served by an App Provider aggregated API server. It is not a CRD and review objects are not persisted. Kubernetes authentication, authorization, audit, API discovery, and APIService routing are handled by the Kubernetes API server. +The Provider package owns the aggregated API server and matching `APIService` registration. The endpoint controller reads `EndpointProviderCapability.spec.conversion`, then creates that resource through the Kubernetes API server. -The Provider package owns the aggregated API server and the matching `APIService` registration for its refine API. A source App does not create `APIService` resources and does not know the backing Service name, serving certificate, or CA bundle. It reads `EndpointProviderCapability.spec.refine`, then uses a Kubernetes client to `create` that resource through the Kubernetes API server. - -The shared review shape is: +Example: ```yaml apiVersion: endpoint.route53.dns.appthrust.io/v1alpha1 -kind: EndpointRecordSetRefineReview -request: +kind: EndpointRecordSetConversion +spec: uid: 5f0f0b5e-0000-0000-0000-000000000001 - recordSet: - type: CNAME + input: + hostname: api.example.com name: api - ttl: 300 - cname: - target: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com -response: + zone: + domainName: example.com + targets: + - type: Hostname + value: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com +status: uid: 5f0f0b5e-0000-0000-0000-000000000001 result: status: Success - reason: Refined - message: refined endpoint record set - retryable: false - recordSet: - type: A - name: api - options: - alias: - dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true + reason: Converted + message: converted endpoint record set + output: + fragments: + - type: A + name: api + options: + alias: + dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: false + - type: AAAA + name: api + options: + alias: + dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. + hostedZoneID: Z14GRHDCWA56QT + evaluateTargetHealth: false ``` -`request.recordSet` and `response.recordSet` have the same endpoint record set shape. - -The API accepts one endpoint record set candidate per request and returns one refined endpoint record set. If a provider needs multiple record sets for one endpoint, such as routing policy records, v1 reports unsupported behavior. Multi-record-set refine is future work. +`spec.input` is an `EndpointRecordSetConversionInput`: one hostname from an `EndpointRecordSet`, the selected zone-relative record name, the selected Zone domain name, and endpoint targets. `status.output.fragments[]` is a non-empty list of `RecordSetSpecFragment`: Provider-converted fragments of `RecordSet.spec` before `zoneRef` and `provider` are attached. -The refine API may change `type`, `ttl`, record body fields, and `options`. It must not change `name`, and it must not add `adoption`. The `name` is derived from the source hostname and selected Zone; changing it would make `allowedRecordSets` checks and report output ambiguous. +`RecordSetSpecFragment` contains `type`, `name`, `ttl`, `a`, `aaaa`, `cname`, and `options`. It does not contain `zoneRef`, `provider`, or `adoption`; the endpoint controller adds `zoneRef` and `provider` after selecting the Zone. The conversion API may choose record type, TTL, record body fields, and options. It must not change `name`. -`response.uid` must equal `request.uid`. `response.result.status` is `Success` or `Failure`. On `Success`, `response.recordSet` is required. On `Failure`, `response.recordSet` is ignored. +`status.uid` must equal `spec.uid`. `status.result.status` is `Success` or `Failure`. On `Success`, `status.output.fragments` is required and must have at least one item. On `Failure`, `status.output` is ignored. Failure mapping: -- `EndpointRecordSetRefineUnsupported`: no matching `EndpointProviderCapability`, no supported static path, and no refine API. +- `EndpointRecordSetConversionUnsupported`: no matching `EndpointProviderCapability` or no provider-supported output. - `EndpointProviderCapabilityConflict`: multiple matching `EndpointProviderCapability` resources exist for the same Core Provider name and version. -- `EndpointRecordSetRefineUnavailable`: retryable failure, timeout, APIService unavailability, discovery failure, or 5xx response. -- `EndpointRecordSetRefineDenied`: RBAC `Forbidden` or authentication failure. -- `EndpointRecordSetRefineInvalid`: successful API call returned malformed content, UID mismatch, missing successful `recordSet`, changed `name`, unsupported response type, unexpected `adoption`, or schema-invalid provider payload. -- `EndpointRecordSetRefineFailed`: non-retryable failure response from the refine API. +- `EndpointRecordSetConversionUnavailable`: retryable failure, timeout, APIService unavailability, discovery failure, or 5xx response. +- `EndpointRecordSetConversionDenied`: RBAC `Forbidden` or authentication failure. +- `EndpointRecordSetConversionInvalid`: successful API call returned malformed content, UID mismatch, missing or empty successful fragments, changed `name`, unsupported response type, or schema-invalid provider payload. +- `EndpointRecordSetConversionFailed`: non-retryable failure response from the conversion API. + +## ZoneUnit and Adoption -The App Provider refine API owns provider-specific record shaping. For example, Route 53 refines a hostname `CNAME` endpoint record set candidate into an `A` record with `options.alias` when the hostname matches a supported AWS alias target and the target hosted zone ID is known. Cloudflare may return a `CNAME` endpoint record set and set provider-owned options. Source Apps must not contain provider-name-specific branches for Route 53 ALIAS or Cloudflare options. +`ZoneUnit` is the Core ownership registry. The endpoint APIs do not define adoption behavior in this design. `EndpointRecordSet.spec` and `RecordSetSpecFragment` do not contain adoption, and the endpoint controller does not write provider-specific adoption input into generated `RecordSet` resources. ## API Groups diff --git a/docs/design/gateway-api-integration.md b/docs/design/gateway-api-integration.md index 37d0de7..c85d406 100644 --- a/docs/design/gateway-api-integration.md +++ b/docs/design/gateway-api-integration.md @@ -1,254 +1,65 @@ -# Gateway API Integration +# Gateway API Endpoint Integration -dns-api provides a Gateway API integration that observes `HTTPRoute` and `Gateway` resources and reports how their DNS intent maps to dns-api `Zone` and `RecordSet` resources. +Gateway API integration is a DNS API App above Core. It watches Gateway API resources and creates common `EndpointRecordSet` resources. It does not create Core `RecordSet` resources directly. -The initial integration targets `HTTPRoute` and `Gateway` from `gateway.networking.k8s.io/v1`. Other Route kinds such as `TLSRoute`, `TCPRoute`, `UDPRoute`, and `GRPCRoute` are out of scope for the initial version. +The initial integration targets `HTTPRoute` and `Gateway` from `gateway.networking.k8s.io/v1`. Other route kinds are future work. -Gateway API integration is a DNS API App. It runs above Core, derives `RecordSet` resources from Gateway API state, and reports the result through `HTTPRouteDNSReport`. It must not expand Core controller responsibility. It uses the common Endpoint API in `endpoint.dns.appthrust.io`; provider-specific endpoint record set shaping is exposed through `EndpointProviderCapability` and the Endpoint RecordSet Refine API. +## Architecture -## Problem Space - -Without Gateway API integration, an application team must create or update at least three resource areas to attach a hostname to a Gateway: - -1. A `Gateway` exists with listeners and provider-assigned addresses. -2. An `HTTPRoute` attaches application traffic to that Gateway. -3. A dns-api `RecordSet` points the hostname to the Gateway address. - -This creates several failure modes: - -- A route exists but DNS is never created. -- A route is deleted or changed but old DNS remains. -- DNS points at the wrong Gateway address. -- Operators must inspect `Gateway.status.addresses` manually and translate it to provider-specific DNS target data, such as a Route 53 `ALIAS` target for an AWS Load Balancer Controller Gateway. - -The affected users are both application teams and platform teams. Application teams want `HTTPRoute` to be the normal place where hostnames are declared. Platform teams want DNS to remain constrained by dns-api `Zone`, `ZoneClass`, provider identity, and `allowedRecordSets` policy. - -external-dns already handles a similar flow with its Gateway API source: it derives DNS endpoints from `HTTPRoute` and `Gateway`, then provider implementations choose the matching provider zone. dns-api follows the same broad source interpretation, but it must expose intermediate feedback because dns-api creates Kubernetes `RecordSet` resources before provider reconciliation. A missing `Zone`, an `allowedRecordSets` rejection, or an unsupported target shape should be visible as Kubernetes status, not only as controller logs. - -## Goals - -- Observe `HTTPRoute` and `Gateway` resources and derive DNS hostnames from their attachment relationship. -- Create dns-api `RecordSet` resources for supported `HTTPRoute` hostname targets. -- Report DNS translation, policy, and programming state in a readable Kubernetes resource. -- Make missing zones, parent acceptance problems, unsupported target shapes, and `allowedRecordSets` denials visible without reading controller logs. -- Keep the integration compatible with the existing dns-api `Zone`, `RecordSet`, and provider controller model. - -## Non-Goals - -- Do not change Gateway API resources or write `HTTPRoute.status`. -- Do not implement Gateway or HTTPRoute admission validation. -- Do not support `TLSRoute`, `TCPRoute`, `UDPRoute`, or `GRPCRoute` initially. -- Do not add Route 53 weighted, latency, failover, or other routing policy support as part of the initial Gateway integration. -- Do not expose a user-authored desired-state API for Gateway DNS records in the initial version. -- Do not add route or Gateway selector configuration in the initial version. - -## API - -The integration adds a report resource: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: web -``` - -`HTTPRouteDNSReport` is a controller-owned report for one `HTTPRoute`. It is created in the same namespace and with the same name as the `HTTPRoute`. The controller sets an owner reference to the `HTTPRoute`, so the report is deleted with the route. - -The resource is read-only for application users. It is not a user-authored API. The initial type has no meaningful `spec`; all useful data is written under `status`. - -Example: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: web -status: - routeRef: - namespace: app - name: web - hostnames: - - hostname: api.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - conditions: - - type: Selected - status: "True" - reason: LongestAllowedSuffixMatch - recordSets: - - type: A - name: api - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true - conditions: - - type: Resolved - status: "True" - reason: Resolved - - type: Allowed - status: "True" - reason: AllowedByZone - - type: Created - status: "True" - reason: RecordSetCreated - generated: - ref: - namespace: dns-api-system - name: gwrs-8f3a2c91d7-a - conditions: - - type: Accepted - status: "True" - reason: Accepted - - type: Programmed - status: "False" - reason: ProviderChangePending - - type: AAAA - name: api - options: {} - conditions: - - type: Resolved - status: "False" - reason: UnsupportedTarget - - hostname: denied.example.com - zone: - candidates: - - ref: - namespace: platform-dns - name: example-com - domainName: example.com - reason: RecordSetNotAllowed - conditions: - - type: Selected - status: "False" - reason: RecordSetNotAllowed - recordSets: - - type: A - name: denied - conditions: - - type: Resolved - status: "True" - reason: Resolved - - type: Allowed - status: "False" - reason: RecordSetNotAllowed - - type: Created - status: "False" - reason: RecordSetNotCreated - - hostname: api.unknown.example.net - zone: - conditions: - - type: Selected - status: "False" - reason: ZoneNotResolved - recordSets: [] - conditions: - - type: Resolved - status: "False" - reason: PartialFailure - - type: Programmed - status: "False" - reason: RecordSetNotProgrammed +```text +HTTPRoute / Gateway + -> gateway endpoint controller + -> endpoint.dns.appthrust.io/EndpointRecordSet + -> endpoint record set controller + -> Provider EndpointRecordSetConversion API + -> dns.appthrust.io/RecordSet + -> dns.appthrust.io/ZoneUnit + -> provider controller ``` -The exact status field names may change during implementation, but the status model stays route-scoped and grouped by DNS hostname. The first question the report answers is: "for this `HTTPRoute`, which `RecordSet` objects is the controller trying to create for each hostname, and which Zone was selected?" Policy and generated `RecordSet` details are attached under each `recordSets[]` item. Parent, listener, and Gateway address details are included only when they explain a blocked or unsupported `RecordSet`. - -## Controller Scope +The Gateway controller is a source controller. It reads Gateway API attachment state and emits endpoint publishing intent. The endpoint controller owns DNS-specific resolution and Core resource creation. -The initial controller watches all `HTTPRoute` and `Gateway` resources in the cluster. It does not support route namespace filters, route label filters, route annotation filters, Gateway namespace filters, Gateway name filters, or Gateway label filters in the initial version. +## Responsibilities -Filtering is future work. The initial design keeps the controller behavior simple and closer to a cluster-level integration. Operators can rely on dns-api `Zone` and `allowedRecordSets` policy to decide which hostnames can become `RecordSet` resources. +The Gateway controller: -The controller creates an `HTTPRouteDNSReport` when an `HTTPRoute` has at least one Gateway parent reference and a DNS hostname can be derived from route hostnames, Gateway listener hostnames, or their intersection. If the route has no Gateway parent references, the controller does not create a report. +- watches `HTTPRoute` and `Gateway`; +- waits until all `HTTPRoute.spec.parentRefs` are accepted; +- resolves hostnames from the route/listener relationship; +- reads targets from `Gateway.status.addresses`; +- creates one `EndpointRecordSet` for each accepted route/listener binding with usable hostnames and targets; +- deletes generated `EndpointRecordSet` resources when the route no longer produces them. -The initial implementation uses one Gateway DNS controller. It is responsible for both: +The Gateway controller does not: -- reading `HTTPRoute`, `Gateway`, `Zone`, and generated `RecordSet` resources and writing `HTTPRouteDNSReport.status`; -- creating, updating, and deleting generated `RecordSet` resources. +- select `Zone`; +- select Core `Provider`; +- decide `A`, `AAAA`, or `CNAME`; +- decide TTL; +- call provider conversion APIs; +- create `RecordSet`; +- write provider-specific `adoption`. -`HTTPRouteDNSReport` is not a controller-to-controller desired-state boundary in the initial version. It is a user-facing report produced by the Gateway DNS controller. - -## Hostname Resolution +The endpoint controller: -The controller resolves DNS hostnames from the relationship between `HTTPRoute.spec.hostnames` and matching Gateway listeners. +- watches `EndpointRecordSet`; +- selects matching Zones by longest suffix match; +- resolves `EndpointProviderCapability` from the selected Zone provider; +- calls the Provider `EndpointRecordSetConversion` API; +- validates returned fragments; +- checks `Zone.spec.allowedRecordSets`; +- creates and updates generated Core `RecordSet` resources. -Rules: +Provider controllers still make final provider-side acceptance and programming decisions. -- If both the route and listener define hostnames, the controller uses their hostname intersection. -- If the route omits `spec.hostnames` and the listener defines `hostname`, the listener hostname is used. -- If the route defines `spec.hostnames` and the listener omits `hostname`, the route hostname is used. -- If both omit hostnames, no DNS hostname is produced. -- Wildcards follow Gateway API source behavior used by external-dns: a wildcard label such as `*.example.com` is a suffix match, and the more specific overlapping hostname is used. +## EndpointRecordSet Creation -Examples: - -```yaml -# Gateway listener hostname -hostname: api.example.com - -# HTTPRoute hostnames omitted -``` +The Gateway controller creates one `EndpointRecordSet` per `HTTPRoute` parent/listener binding. -produces `api.example.com`. - -```yaml -# Gateway listener hostname -hostname: api.example.com - -# HTTPRoute hostname -hostnames: - - "*.example.com" -``` - -produces `api.example.com`. - -```yaml -# Gateway listener hostname -hostname: "*.example.com" - -# HTTPRoute hostname -hostnames: - - api.example.com -``` - -produces `api.example.com`. - -## Hostname Examples - -### HTTPRoute Hostname Only - -When `HTTPRoute.spec.hostnames` is set and the Gateway listener does not set `hostname`, the route hostname becomes the DNS hostname. - -Input: +For a route: ```yaml apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - namespace: platform - name: public -spec: - gatewayClassName: alb - listeners: - - name: https - protocol: HTTPS - port: 443 - allowedRoutes: - namespaces: - from: All -status: - addresses: - - type: Hostname - value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com ---- -apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: namespace: app @@ -260,54 +71,9 @@ spec: sectionName: https hostnames: - api.example.com - rules: - - backendRefs: - - name: web - port: 8080 -status: - parents: - - parentRef: - namespace: platform - name: public - sectionName: https - controllerName: example.com/gateway-controller - conditions: - - type: Accepted - status: "True" - reason: Accepted -``` - -Expected report shape: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: web -status: - hostnames: - - hostname: api.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - recordSets: - - type: A - name: api - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true ``` -### Gateway Listener Hostname Only - -When `HTTPRoute.spec.hostnames` is omitted and the Gateway listener sets `hostname`, the listener hostname becomes the DNS hostname. - -Input: +and a Gateway: ```yaml apiVersion: gateway.networking.k8s.io/v1 @@ -316,609 +82,126 @@ metadata: namespace: platform name: public spec: - gatewayClassName: alb - listeners: - - name: https-api - protocol: HTTPS - port: 443 - hostname: api.example.com - allowedRoutes: - namespaces: - from: All -status: - addresses: - - type: Hostname - value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - namespace: app - name: web -spec: - parentRefs: - - namespace: platform - name: public - sectionName: https-api - rules: - - backendRefs: - - name: web - port: 8080 -status: - parents: - - parentRef: - namespace: platform - name: public - sectionName: https-api - controllerName: example.com/gateway-controller - conditions: - - type: Accepted - status: "True" - reason: Accepted -``` - -Expected report shape: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: web -status: - hostnames: - - hostname: api.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - recordSets: - - type: A - name: api - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true -``` - -### HTTPRoute And Gateway Listener Hostnames - -When both the route and listener set hostnames, the DNS hostname is their intersection. If one side is a wildcard and the other is concrete, the concrete hostname is used. - -Input: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - namespace: platform - name: public -spec: - gatewayClassName: alb - listeners: - - name: https-api - protocol: HTTPS - port: 443 - hostname: api.example.com - allowedRoutes: - namespaces: - from: All -status: - addresses: - - type: Hostname - value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - namespace: app - name: wildcard -spec: - parentRefs: - - namespace: platform - name: public - sectionName: https-api - hostnames: - - "*.example.com" - rules: - - backendRefs: - - name: web - port: 8080 -status: - parents: - - parentRef: - namespace: platform - name: public - sectionName: https-api - controllerName: example.com/gateway-controller - conditions: - - type: Accepted - status: "True" - reason: Accepted -``` - -Expected report shape: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: wildcard -status: - hostnames: - - hostname: api.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - recordSets: - - type: A - name: api - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true -``` - -### Multiple HTTPRoute Hostnames - -When an `HTTPRoute` has multiple hostnames and the Gateway listener does not narrow them, the report contains one hostname entry for each route hostname. Each hostname can have its own Zone and `recordSets`. - -Input: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - namespace: platform - name: public -spec: - gatewayClassName: alb listeners: - name: https protocol: HTTPS port: 443 - allowedRoutes: - namespaces: - from: All status: addresses: - type: Hostname - value: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - namespace: app - name: multi -spec: - parentRefs: - - namespace: platform - name: public - sectionName: https - hostnames: - - api.example.com - - admin.example.com - rules: - - backendRefs: - - name: web - port: 8080 -status: - parents: - - parentRef: - namespace: platform - name: public - sectionName: https - controllerName: example.com/gateway-controller - conditions: - - type: Accepted - status: "True" - reason: Accepted + value: k8s-public-123.ap-northeast-1.elb.amazonaws.com ``` -Expected report shape: - -```yaml -apiVersion: gateway.endpoint.dns.appthrust.io/v1alpha1 -kind: HTTPRouteDNSReport -metadata: - namespace: app - name: multi -status: - hostnames: - - hostname: api.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - recordSets: - - type: A - name: api - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true - - hostname: admin.example.com - zone: - ref: - namespace: platform-dns - name: example-com - domainName: example.com - recordSets: - - type: A - name: admin - options: - alias: - dnsName: dualstack.example-alb.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true -``` - -## Parent Acceptance - -`HTTPRoute` can reference multiple parents. Gateway API reports acceptance per parent in `HTTPRoute.status.parents[]`; acceptance is not a single route-wide condition. - -The dns-api Gateway integration treats all Gateway parents referenced by the route as part of the route's DNS intent. The initial version requires all Gateway parents that contribute to a hostname to be accepted before creating `RecordSet` resources for that hostname. If one parent is accepted and another is rejected or unresolved, the report records a blocked condition and does not create partial DNS. - -This avoids silently routing DNS to only a subset of the route's declared parents. - -## Endpoint Capability - -The Gateway DNS controller uses the common Endpoint API in `endpoint.dns.appthrust.io`. A selected Zone's Core Provider version is compatible with the initial Gateway API integration when there is exactly one matching `EndpointProviderCapability`. - -Example: +the controller creates: ```yaml apiVersion: endpoint.dns.appthrust.io/v1alpha1 -kind: EndpointProviderCapability -metadata: - name: route53-v1alpha1 +kind: EndpointRecordSet +metadata: + namespace: dns-api-system + name: web-public-https-6b96e4d5f0 + labels: + app.kubernetes.io/managed-by: dns-api-gateway-endpoint + gateway.endpoint.dns.appthrust.io/route-namespace: app + gateway.endpoint.dns.appthrust.io/route-name: web + gateway.endpoint.dns.appthrust.io/gateway-namespace: platform + gateway.endpoint.dns.appthrust.io/gateway-name: public + gateway.endpoint.dns.appthrust.io/listener-name: https spec: - provider: - name: route53.dns.appthrust.io - version: v1alpha1 - defaults: - ttl: 300 - refine: - group: endpoint.route53.dns.appthrust.io - resource: endpointrecordsetrefinereviews + hostnames: + - api.example.com + targets: + - type: Hostname + value: k8s-public-123.ap-northeast-1.elb.amazonaws.com ``` -`spec.defaults.ttl` is an optional static TTL default for endpoint record set candidates. `spec.refine` is optional. When present, it identifies the Endpoint RecordSet Refine API. The Provider package owns the aggregated API server and `APIService` registration for that API. The Gateway DNS controller reads `EndpointProviderCapability`, creates the configured review resource through the Kubernetes API server, and never calls the backing Service directly. - -The refine API version defaults to `EndpointProviderCapability.spec.provider.version` when `spec.refine.version` is omitted. For the example above, the Gateway DNS controller calls: +The generated name is deterministic from: ```text -POST /apis/endpoint.route53.dns.appthrust.io/v1alpha1/endpointrecordsetrefinereviews -``` - -If no matching `EndpointProviderCapability` exists, the report records `EndpointRecordSetRefineUnsupported` and no generated `RecordSet` is created for that Provider. If multiple matching capabilities exist, the report records `EndpointProviderCapabilityConflict`. - -## Endpoint RecordSet Resolution - -Accepted Gateway parents contribute targets from `Gateway.status.addresses`. The initial target types are: - -- `IPAddress` -- `Hostname` - -The controller first converts Gateway addresses into provider-neutral endpoint record set candidates: - -| Gateway address | Endpoint record set candidate | -| --- | --- | -| IPv4 `IPAddress` | `A` with `a.addresses[]` | -| IPv6 `IPAddress` | `AAAA` with `aaaa.addresses[]` | -| `Hostname` | `CNAME` with `cname.target` | - -An endpoint record set is a generated `A`, `AAAA`, or `CNAME` record candidate that publishes an endpoint address into DNS. It uses a subset of `RecordSet.spec` fields: `type`, `name`, `ttl`, `a`, `aaaa`, `cname`, and `options`. It does not include `zoneRef`, `provider`, or `adoption`; `zoneRef` and `provider` are added by the Gateway DNS controller after Provider refine succeeds, and endpoint record set adoption is not supported in v1. - -The Gateway DNS controller constructs each endpoint record set candidate from: - -- the zone-relative record name; -- the Gateway address type and value; -- `EndpointProviderCapability.spec.defaults.ttl`, when present; -- a valid `dns.appthrust.io/ttl` annotation value, when present. - -The controller does not include `adoption` in endpoint record sets. Endpoint record set adoption is not supported in v1. - -When the matching `EndpointProviderCapability` declares `spec.refine`, the controller sends the candidate to the Endpoint RecordSet Refine API: - -```yaml -apiVersion: endpoint.route53.dns.appthrust.io/v1alpha1 -kind: EndpointRecordSetRefineReview -request: - uid: 5f0f0b5e-0000-0000-0000-000000000001 - recordSet: - type: CNAME - name: api - ttl: 300 - cname: - target: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com +HTTPRoute namespace/name +Gateway namespace/name +listener name ``` -The Provider returns one refined endpoint record set: +Hostnames are not part of the object name, because changing route hostnames should update the same endpoint intent object rather than break ownership continuity. -```yaml -response: - uid: 5f0f0b5e-0000-0000-0000-000000000001 - result: - status: Success - reason: Refined - retryable: false - recordSet: - type: A - name: api - options: - alias: - dnsName: dualstack.k8s-public-123.ap-northeast-1.elb.amazonaws.com. - hostedZoneID: Z14GRHDCWA56QT - evaluateTargetHealth: true -``` - -The Gateway DNS controller validates that a successful response has the same `uid`, contains one endpoint record set, preserves the input `name`, omits `adoption`, and uses a supported endpoint record set type. It then adds `zoneRef` and `provider` to create the final dns-api `RecordSet`. - -Provider-specific record shaping belongs to the Endpoint RecordSet Refine API, not to the Gateway DNS controller. For example, Route 53 may refine a hostname `CNAME` candidate into an `A` record with `options.alias`, and Cloudflare may keep it as `CNAME` while setting provider-owned options. Route 53 hosted zone ID lookup for ALB or NLB alias targets is part of the Route 53 refine API implementation. The Gateway DNS controller must not hard-code AWS alias suffix tables or Cloudflare option rules. - -The Gateway DNS controller stops only when it cannot create an endpoint record set candidate, refine it when required, or convert the resulting endpoint record set to a dns-api `RecordSet`. It does not duplicate every provider-specific acceptance rule. Once a resulting endpoint record set can be expressed as a dns-api `RecordSet` and is allowed by dns-api policy, the existing core and provider controllers decide whether the `RecordSet` is accepted and programmed. For example, the Gateway DNS controller does not reject a Cloudflare apex `CNAME` on its own; it creates the `RecordSet` and projects the resulting `RecordSet.status.conditions` into the report. +## Hostname Resolution -If the same resolved DNS hostname has multiple Gateway targets, the initial controller reports `MultipleGatewayTargetsUnsupported` and does not create a `RecordSet`. This is necessary because a single Route 53 `ALIAS` record set cannot express multiple alias targets without Route 53 routing policy such as weighted records and `setIdentifier`. Routing policy support is future work. +For each accepted route/listener binding: -## TTL Resolution +- If both `HTTPRoute.spec.hostnames` and listener `hostname` are set, use their intersection. +- If the route omits `spec.hostnames` and listener `hostname` is set, use the listener hostname. +- If the route sets `spec.hostnames` and listener `hostname` is omitted, use the route hostnames. +- If both omit hostnames, no `EndpointRecordSet` is created for that binding. -Gateway API does not define a TTL field for `HTTPRoute` or `Gateway`. The Gateway DNS controller therefore resolves TTL before creating generated `RecordSet` resources. +Wildcard intersection chooses the concrete hostname when one side is concrete and the other side is wildcard. -`RecordSet.spec.ttl` is optional in the core CRD schema, but provider validation may require it. For example, dns-api provider schemas commonly use `require-ttl` for standard record bodies. Some provider-specific refined shapes may omit or replace core TTL: +## Target Resolution -- Route 53 `options.alias` records omit `spec.ttl`. -- Cloudflare `options.ttl: Auto` records omit `spec.ttl`. +Targets come from `Gateway.status.addresses`. -The initial Gateway DNS controller supports an optional `HTTPRoute` annotation override: +Supported target types: -```yaml -metadata: - annotations: - dns.appthrust.io/ttl: "300" -``` - -The annotation value is parsed as seconds. The accepted range is the core `RecordSet.spec.ttl` range, `1..2147483647`. Invalid values are reported on the `HTTPRouteDNSReport` and no invalid `RecordSet` is created. - -TTL resolution rules: +- `Hostname` +- `IPAddress` -- If `dns.appthrust.io/ttl` is present and valid, the controller sets the endpoint record set candidate's `ttl` to that value before Provider refine. -- If the annotation is absent and the matching `EndpointProviderCapability` has `spec.defaults.ttl`, the controller sets the endpoint record set candidate's `ttl` to that value before Provider refine. -- If neither the annotation nor `spec.defaults.ttl` supplies TTL, the endpoint record set candidate omits `ttl`. -- The Endpoint RecordSet Refine API may return a refined record that omits `ttl` or uses provider-owned TTL options when that is the correct provider-specific shape. -- If the annotation is invalid, the report records `TTLNotResolved` and the controller does not call refine for that candidate. -- If the refined endpoint record set is rejected later by `RecordSet` admission because no accepted TTL shape is present, the report records the admission failure and no invalid generated `RecordSet` is kept. +The Gateway controller preserves target type and value. It does not turn hostname targets into `CNAME`, and it does not split IP targets into `A` and `AAAA`. That is provider conversion responsibility. -`EndpointProviderCapability.spec.defaults.ttl` is a narrow Endpoint App default. Core `Provider` does not carry Endpoint App defaults. Provider-specific automatic TTL behavior, such as Cloudflare automatic TTL, is not inferred by the Gateway DNS controller; it must be returned by the Endpoint RecordSet Refine API when needed. +## Parent Acceptance -## Zone Selection +The initial behavior is conservative: DNS intent is generated only after every `HTTPRoute.spec.parentRefs[]` entry has an `Accepted=True` parent status. -The controller selects a dns-api `Zone` by matching each resolved DNS hostname against existing `Zone.spec.domainName` values. +This avoids partial DNS publication for a route whose complete Gateway attachment is not settled. -The basic rule follows external-dns provider behavior: +## Observability -- Candidate zones are zones whose domain name equals the hostname or is a suffix of the hostname. -- The most specific candidate is selected by longest matching domain name. +`HTTPRouteDNSReport` is not part of the design. Gateway-specific reporting would be a projection and is not needed for the initial implementation. -dns-api adds one more selection constraint: the refined generated `RecordSet` must be allowed to attach to the selected `Zone`. +Users inspect: -Endpoint record set handling depends on the candidate Zone because the Zone selects the Core Provider version, and the Core Provider version selects a matching `EndpointProviderCapability`. For each suffix-matching candidate Zone, the controller builds the zone-relative endpoint record set candidate, applies `EndpointProviderCapability.spec.defaults`, calls the refine API when `spec.refine` is present, then checks whether the resulting generated `RecordSet` in the configured RecordSet namespace is allowed by `Zone.spec.allowedRecordSets`. This includes: +- generated `EndpointRecordSet` resources via Gateway labels; +- `EndpointRecordSet.status` for Zone selection, generated RecordSets, and conversion errors; +- generated `RecordSet.status` for Core and provider acceptance; +- `ZoneUnit.status` for zone-scoped provider state. -- generated `RecordSet` namespace -- zone-relative record name -- refined record type +`EndpointRecordSet.status` is the common observability surface for Gateway, Ingress, Service, and future source Apps. -If the longest matching zone does not allow the generated `RecordSet`, the controller may continue to the next less-specific matching zone. The selected zone is the most specific matching zone that also allows the generated `RecordSet`. If no matching zone allows the refined generated `RecordSet`, the report records a denial and no `RecordSet` is created. +## Adoption and GitOps Restore -Generated `RecordSet` resources are written to the controller-configured `recordSetNamespace`, such as `dns-api-system`. This namespace is not derived from the `HTTPRoute` namespace and does not have to match the controller Pod namespace. Platform teams must grant `recordSetNamespace` through `Zone.spec.allowedRecordSets` for shared zones. +The Gateway controller does not write adoption. `EndpointRecordSet.spec` does not contain adoption. -Condition reasons: +Endpoint-based adoption and GitOps restore are not part of this design yet. Direct Core `RecordSet.spec.adoption` remains the supported adoption path. -- `ZoneNotResolved`: no `Zone` matches the hostname. -- `RecordSetNotAllowed`: matching zones exist, but none allows the generated `RecordSet` namespace, name, and type. +## Provider Conversion -## RecordSet Management +Gateway is provider-neutral. Provider-specific endpoint record shape belongs to the Endpoint Provider conversion API. -The controller creates dns-api `RecordSet` resources only after the report data is resolvable and supported. +For Route 53: -Creation requirements for a hostname: +- hostname targets that match known AWS load balancer suffixes become `A` and `AAAA` ALIAS fragments; +- IP targets become standard `A` and/or `AAAA` fragments; +- default TTL for standard IP records is chosen by the Route 53 conversion API; +- ALIAS records omit Core `ttl`; +- unsupported hostname targets fail conversion. -- A DNS hostname is resolved from the route/listener relationship. -- All contributing Gateway parents are `Accepted=True`. -- Gateway addresses are present. -- A `Zone` is selected. -- Exactly one `EndpointProviderCapability` matches the selected Zone's Core Provider name and version. -- The controller can build an endpoint record set candidate from the Gateway address. -- If `EndpointProviderCapability.spec.refine` is present, the Endpoint RecordSet Refine API returns one successful refined endpoint record set. -- The resulting endpoint record set can be converted to the current dns-api `RecordSet.spec`. -- `Zone.spec.allowedRecordSets` allows the refined generated `RecordSet`. -- The generated `RecordSet` passes Kubernetes and dns-api admission. +The endpoint controller receives `RecordSetSpecFragment[]`, attaches `zoneRef` and `provider`, and applies Core `RecordSet` resources. -Generated `RecordSet` resources are created in `recordSetNamespace`, not in the application namespace. The report status points to generated `RecordSet` refs. +## API Groups -The generated `RecordSet` name is stable and short: +Common endpoint resources: ```text -gwrs-- +endpoint.dns.appthrust.io ``` -`` is computed from the source route namespace, source route name, selected Zone namespace, selected Zone name, and hostname. The record type is not part of the hash input because it is present as the final suffix. This keeps records for the same route, Zone, and hostname visually grouped: +Provider conversion implementation: ```text -gwrs-8f3a2c91d7-a -gwrs-8f3a2c91d7-aaaa +endpoint.route53.dns.appthrust.io ``` -The exact hash length is an implementation detail, but it must be long enough to avoid practical collisions and short enough to keep generated names readable. - -Generated `RecordSet` resources carry labels and annotations identifying the owning `HTTPRouteDNSReport` and source `HTTPRoute`. Short selector values and hashes go in labels. Potentially long values, such as route namespace, route name, hostname, and Zone reference, go in annotations because Kubernetes label values have a 63-character limit. - -Generated `RecordSet` resources are not user-owned records. If a report no longer needs a generated `RecordSet`, the controller deletes it. - -The controller projects generated `RecordSet.status.conditions` into `HTTPRouteDNSReport.status.hostnames[].recordSets[].generated.conditions[]` so users can see both source translation and provider programming state from the report. - -## Deletion and Cleanup - -`HTTPRouteDNSReport` is created in the same namespace and with the same name as the source `HTTPRoute`, so it can use an owner reference to the `HTTPRoute`. Generated `RecordSet` resources are created in `recordSetNamespace`, which may be a different namespace. Because Kubernetes owner references cannot safely express cross-namespace ownership, generated `RecordSet` resources are not garbage-collected by the report owner reference. - -The Gateway DNS controller adds a finalizer to each `HTTPRouteDNSReport`. When the source `HTTPRoute` is deleted, Kubernetes marks the report for deletion through the owner reference. The controller then: - -1. Finds generated `RecordSet` resources owned by the report using labels and annotations. -2. Deletes those generated `RecordSet` resources. -3. Waits until their deletion is accepted by the Kubernetes API. -4. Removes the report finalizer. - -This cleanup path prevents generated DNS records from remaining after the source `HTTPRoute` is deleted. - -The same ownership index is used for updates. When a report reconciliation no longer includes a previously generated `RecordSet` because a hostname, parent, Zone, target, or record type changed, the controller deletes the obsolete generated `RecordSet`. The controller must delete only records that carry its managed labels and report ownership annotations. - -## Conditions - -`HTTPRouteDNSReport.status.conditions` summarizes the route-level DNS state. DNS outcome details live in `status.hostnames[]`. - -Each `status.hostnames[]` item represents one DNS hostname after Gateway route/listener hostname resolution: - -- `hostname`: fully qualified DNS hostname. -- `zone`: selected Zone, candidate Zones, and conditions for this hostname's Zone decision. If no Zone is selected, this field records why selection failed. -- `recordSets`: desired DNS `RecordSet` objects for this hostname. - -`zone.conditions[]` contains Gateway DNS controller conditions for the hostname's Zone decision. It does not copy `Zone.status.conditions`. - -Each `recordSets[]` item represents one desired dns-api `RecordSet` candidate for the hostname: - -- `type`: DNS record type the controller intends to create, such as `A` or `AAAA`. -- `name`: zone-relative `RecordSet.spec.name`. -- `ttl`: `RecordSet.spec.ttl`, when the controller sets one. -- `a`, `aaaa`, `cname`, or `options`: the DNS record body fields the controller intends to create. -- `conditions`: Gateway DNS controller conditions for this desired `RecordSet`, such as target resolution, policy allowance, and creation. -- `generated`: generated `RecordSet` reference and copied `RecordSet.status.conditions`, when a `RecordSet` was created. - -The `recordSets[]` item mirrors the relevant `RecordSet.spec` fields for Gateway integration: `type`, `name`, `ttl`, `a`, `aaaa`, `cname`, and `options`. It does not repeat `zoneRef` or `provider`; the selected Zone is represented by `hostnames[].zone`, and the provider is derived from that Zone. - -Initial `zone.conditions[]` condition types: - -- `Selected`: whether the controller selected a dns-api `Zone` for the hostname. - -Initial `recordSets[].conditions[]` condition types: - -- `Resolved`: whether the controller resolved DNS hostnames, Gateway parents, targets, zones, and policy enough to create the desired `RecordSet` resources. -- `Allowed`: whether the generated `RecordSet` is allowed by dns-api `Zone.spec.allowedRecordSets` policy. -- `Created`: whether the generated `RecordSet` object was created. - -Initial `Resolved=False` reasons include: - -- `HostnameNotResolved` -- `ParentNotAccepted` -- `GatewayNotResolved` -- `GatewayTargetNotResolved` -- `MultipleGatewayTargetsUnsupported` -- `EndpointRecordSetRefineUnsupported` -- `EndpointRecordSetRefineUnavailable` -- `EndpointRecordSetRefineDenied` -- `EndpointRecordSetRefineInvalid` -- `EndpointRecordSetRefineFailed` -- `TTLNotResolved` -- `ZoneNotResolved` -- `RecordSetNotAllowed` -- `UnsupportedTarget` - -Initial `Created=False` reasons include: - -- `RecordSetNotCreated` - -Generated `RecordSet.status.conditions` are copied under `status.hostnames[].recordSets[].generated.conditions[]`. They are not mixed into `status.hostnames[].recordSets[].conditions[]`, so future `RecordSet` condition types cannot collide with Gateway DNS controller condition types. - -The report must not hide partial failures. If one desired `RecordSet` is ready and another desired `RecordSet` is blocked, the route-level condition summarizes the blocked state and `status.hostnames[].recordSets[]` identifies the exact hostname, selected or attempted zone, record type, intended record body fields, and reason. - -## User Journey - -Application user creates an `HTTPRoute`: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - namespace: app - name: web -spec: - parentRefs: - - namespace: platform - name: public - sectionName: https-api - rules: - - backendRefs: - - name: web - port: 8080 -``` - -The referenced Gateway listener declares the hostname: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - namespace: platform - name: public -spec: - listeners: - - name: https-api - protocol: HTTPS - port: 443 - hostname: api.example.com -``` - -The controller creates: +Gateway source controller labels: ```text -HTTPRouteDNSReport app/web +gateway.endpoint.dns.appthrust.io/* ``` - -The report shows that the controller is trying to create an `A` record for `api.example.com`, which DNS record body it intends to write, which dns-api `Zone` matched, whether the generated `RecordSet` was allowed, and whether it is programmed. - -## Alternatives Considered - -### Create RecordSet Directly - -The controller could create `RecordSet` resources directly from `HTTPRoute` without a report resource. This hides important failure modes. If no zone matches or `allowedRecordSets` rejects the generated record, there is no `RecordSet` object on which to place status. Users would need controller logs, similar to external-dns skip behavior. - -### Controller-Owned Desired Resource With Spec - -The controller could create an `HTTPRouteRecord` or `GatewayRecord` with `spec` containing desired hostnames and targets. This became awkward because some DNS hostnames come from `HTTPRoute.spec.hostnames`, some come from Gateway listener hostname, and targets come from `Gateway.status.addresses`. The boundary between desired state and observed translation was unclear. - -`HTTPRouteDNSReport` avoids that ambiguity by presenting the integration output as a report. - -### Report in Management Namespace - -The report could live in `dns-api-system` together with generated `RecordSet` resources. That avoids granting report create/update permissions across application namespaces, but it makes the report harder for application teams to find and prevents a same-namespace owner reference to the source `HTTPRoute`. - -The initial design places the report in the same namespace and name as the `HTTPRoute`. - -### Provider Logic in Gateway Controller - -The Gateway DNS controller could translate Gateway addresses directly into provider-specific `RecordSet` shapes. For example, it could recognize AWS load balancer hostnames, resolve Route 53 alias hosted zone IDs, and omit `ttl` for Route 53 `options.alias`. - -This is rejected. It would make the Gateway DNS controller depend on provider-specific DNS rules and data tables. New providers would require Gateway controller changes, and existing providers would spread their record-shaping rules across multiple controllers. Provider-specific record shaping belongs to the Provider package and is exposed through the Endpoint RecordSet Refine API. - -### Provider Webhook for Refine - -The Provider package could expose Endpoint RecordSet Refine through a direct webhook. This is simpler to implement than an aggregated API server, but a controller-origin webhook call would need dns-api-specific authentication, authorization, TLS, retry, timeout, and audit conventions. - -The initial design uses an aggregated API served through `APIService`. The Gateway DNS controller calls the Kubernetes API server with a normal create request, and Kubernetes handles API routing, authentication, authorization, and audit. The Provider package owns the APIService and backing aggregated API server. - -## Future Work - -- Add route namespace, route label, route annotation, Gateway namespace, Gateway name, and Gateway label filters. -- Support `TLSRoute`, `TCPRoute`, `UDPRoute`, and `GRPCRoute`. -- Add multi-record-set Endpoint RecordSet Refine for providers that need several `RecordSet` resources for one endpoint candidate. -- Add Route 53 routing policy support so multiple Gateway alias targets can be represented. -- Add UI views for `HTTPRouteDNSReport` in the Headlamp plugin. -- Add report aggregation in `Zone` or `RecordSet` views. -- Support user-authored source policies if report-only resources are not enough for future workflows. diff --git a/docs/design/overview.md b/docs/design/overview.md index 0cbe3b8..867575d 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -13,7 +13,7 @@ dns-api uses these layer names to keep responsibilities clear: - **Core**: the first-floor DNS API primitives and contracts. Core owns `ZoneClass`, `Provider`, `Zone`, `RecordSet`, `ZoneUnit`, core admission, and the controllers that compose accepted `Zone` and `RecordSet` claims into `ZoneUnit`. Core does not know Gateway API, Ingress, Service, UI preview workflows, or provider-specific endpoint record set shaping. - **Core Provider**: a provider implementation for Core resources. A Core Provider reconciles `ZoneUnit` desired state to a DNS provider and implements provider-specific identity, options, validation, status, and external API calls for `Zone` and `RecordSet`. Route 53 and Cloudflare Core Provider controllers are examples. - **App**: a DNS API application built on top of Core. An App derives or manages Core resources from another workflow or API. Gateway API DNS support is an App. Future Ingress DNS support, Service DNS support, and UI-generated manifest flows are also Apps. -- **App Provider**: a provider-specific extension used by an App. An App Provider capability adapts an App's provider-neutral intent to provider-specific Core resource shape without expanding Core controller responsibility. `endpoint.dns.appthrust.io/EndpointProviderCapability` and the Endpoint RecordSet Refine API are App Provider capabilities. +- **App Provider**: a provider-specific extension used by an App. An App Provider capability adapts an App's provider-neutral intent to provider-specific Core resource shape without expanding Core controller responsibility. `endpoint.dns.appthrust.io/EndpointProviderCapability` and the Endpoint RecordSet Conversion API are App Provider capabilities. The `Provider` resource is the Core Provider discovery contract. Core reads Core Provider capabilities such as schemas, validation rules, conversions, supported record types, and condition reasons. App Provider capabilities live in App-specific API groups, such as `endpoint.dns.appthrust.io`, so Core controllers do not depend on Gateway, Ingress, Service, UI, or endpoint record set workflows. diff --git a/go.mod b/go.mod index 5f5924d..cf873d2 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/gateway-api v1.4.0 sigs.k8s.io/yaml v1.6.0 ) @@ -42,15 +43,15 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonpointer v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -58,7 +59,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -66,7 +67,7 @@ require ( 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.16.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -86,7 +87,7 @@ require ( golang.org/x/time v0.14.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-20250528174236-200df99c418a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index ed25af4..5945ad8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -69,8 +70,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -87,12 +88,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= +github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -136,8 +137,8 @@ github.com/kubernetes-sigs/kro v0.9.2 h1:+oDxno2+7T4FWcAS4sb9r1GADpkjyv80guB1LKs github.com/kubernetes-sigs/kro v0.9.2/go.mod h1:pHMYqGg913SbCK1rqD1wOYu85pfQWkhNkzzRBNkord0= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/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= @@ -161,8 +162,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -238,10 +239,10 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -275,6 +276,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUo sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= +sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go b/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go new file mode 100644 index 0000000..debe5b7 --- /dev/null +++ b/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go @@ -0,0 +1,477 @@ +package endpointrecordset + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + 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" + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/uuid" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + fieldOwner = "dns-api-endpoint-controller" + managedByLabel = "app.kubernetes.io/managed-by" + managedByValue = "dns-api-endpoint" + generatedLabelEndpointRecordSetNamespace = "endpoint.dns.appthrust.io/endpointrecordset-namespace" + generatedLabelEndpointRecordSetName = "endpoint.dns.appthrust.io/endpointrecordset-name" +) + +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + RESTConfig *rest.Config + RecordSetNamespace string +} + +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=get;list;watch;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/status,verbs=get;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointprovidercapabilities,verbs=get;list;watch +// +kubebuilder:rbac:groups=endpoint.route53.dns.appthrust.io,resources=endpointrecordsetconversions,verbs=create +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=zones,verbs=get;list;watch +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits,verbs=get;list;watch +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + recordSetNamespace := r.RecordSetNamespace + if recordSetNamespace == "" { + recordSetNamespace = req.Namespace + } + var endpointRecordSet endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, req.NamespacedName, &endpointRecordSet); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, r.cleanupForEndpointRecordSet(ctx, recordSetNamespace, req.Namespace, req.Name) + } + return ctrl.Result{}, err + } + if !endpointRecordSet.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.cleanupForEndpointRecordSet(ctx, recordSetNamespace, endpointRecordSet.Namespace, endpointRecordSet.Name) + } + + status, desired, err := r.buildStatusAndRecordSets(ctx, &endpointRecordSet, recordSetNamespace) + if err != nil { + return ctrl.Result{}, err + } + if err := r.applyRecordSets(ctx, &endpointRecordSet, recordSetNamespace, desired); err != nil { + return ctrl.Result{}, err + } + if err := r.patchStatus(ctx, &endpointRecordSet, status); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Scheme == nil { + r.Scheme = mgr.GetScheme() + } + return ctrl.NewControllerManagedBy(mgr). + Named("endpoint-recordset"). + For(&endpointv1alpha1.EndpointRecordSet{}). + Watches(&dnsv1alpha1.Zone{}, handler.EnqueueRequestsFromMapFunc(r.mapAllEndpointRecordSets)). + Watches(&endpointv1alpha1.EndpointProviderCapability{}, handler.EnqueueRequestsFromMapFunc(r.mapAllEndpointRecordSets)). + Watches(&dnsv1alpha1.RecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapRecordSetToEndpointRecordSet)). + Complete(r) +} + +func (r *Reconciler) mapAllEndpointRecordSets(ctx context.Context, _ client.Object) []reconcile.Request { + var list endpointv1alpha1.EndpointRecordSetList + if err := r.List(ctx, &list); err != nil { + return nil + } + requests := make([]reconcile.Request, 0, len(list.Items)) + for _, item := range list.Items { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&item)}) + } + return requests +} + +func (r *Reconciler) mapRecordSetToEndpointRecordSet(_ context.Context, obj client.Object) []reconcile.Request { + namespace := obj.GetLabels()[generatedLabelEndpointRecordSetNamespace] + name := obj.GetLabels()[generatedLabelEndpointRecordSetName] + if namespace == "" || name == "" { + return nil + } + return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: namespace, Name: name}}} +} + +func (r *Reconciler) buildStatusAndRecordSets(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, recordSetNamespace string) (endpointv1alpha1.EndpointRecordSetStatus, []dnsv1alpha1.RecordSet, error) { + status := endpointv1alpha1.EndpointRecordSetStatus{ObservedGeneration: endpointRecordSet.Generation} + desiredByName := map[string]dnsv1alpha1.RecordSet{} + for _, hostname := range sortedStrings(endpointRecordSet.Spec.Hostnames) { + hostnameStatus, recordSets, err := r.recordSetsForHostname(ctx, endpointRecordSet, strings.TrimSuffix(hostname, "."), recordSetNamespace) + if err != nil { + return status, nil, err + } + status.Hostnames = append(status.Hostnames, hostnameStatus) + for _, recordSet := range recordSets { + desiredByName[recordSet.Name] = recordSet + } + } + status.HostnameCount = int32(len(status.Hostnames)) + if len(status.Hostnames) == 0 { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionFalse, ObservedGeneration: endpointRecordSet.Generation, Reason: "NoHostname", Message: "EndpointRecordSet has no hostnames."}) + return status, nil, nil + } + allResolved := true + for _, hostnameStatus := range status.Hostnames { + condition := meta.FindStatusCondition(hostnameStatus.Conditions, "Resolved") + if condition == nil || condition.Status != metav1.ConditionTrue { + allResolved = false + break + } + } + if allResolved { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionTrue, ObservedGeneration: endpointRecordSet.Generation, Reason: "Resolved", Message: "All hostnames resolved to generated RecordSets."}) + } else { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionFalse, ObservedGeneration: endpointRecordSet.Generation, Reason: "HostnameNotResolved", Message: "At least one hostname could not be resolved."}) + } + desired := make([]dnsv1alpha1.RecordSet, 0, len(desiredByName)) + for _, recordSet := range desiredByName { + desired = append(desired, recordSet) + } + sort.Slice(desired, func(i, j int) bool { + return desired[i].Name < desired[j].Name + }) + return status, desired, nil +} + +func (r *Reconciler) recordSetsForHostname(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, hostname, recordSetNamespace string) (endpointv1alpha1.EndpointRecordSetHostnameStatus, []dnsv1alpha1.RecordSet, error) { + status := endpointv1alpha1.EndpointRecordSetHostnameStatus{Hostname: hostname} + zones, err := r.sortedZones(ctx, hostname) + if err != nil { + return status, nil, err + } + if len(zones) == 0 { + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionFalse, Reason: "ZoneNotResolved", Message: "No Zone suffix matched this hostname."}) + return status, nil, nil + } + rejections := make([]string, 0, len(zones)) + for _, zone := range zones { + capability, capabilityCount, err := r.capabilityForZone(ctx, &zone) + if err != nil { + return status, nil, err + } + if capabilityCount == 0 { + rejections = append(rejections, fmt.Sprintf("%s/%s: no EndpointProviderCapability for provider %s/%s", zone.Namespace, zone.Name, zone.Spec.Provider.Name, zone.Spec.Provider.Version)) + continue + } + if capabilityCount > 1 { + rejections = append(rejections, fmt.Sprintf("%s/%s: multiple EndpointProviderCapability resources for provider %s/%s", zone.Namespace, zone.Name, zone.Spec.Provider.Name, zone.Spec.Provider.Version)) + continue + } + input := endpointv1alpha1.EndpointRecordSetConversionInput{ + Hostname: hostname, + Name: relativeRecordName(hostname, zone.Spec.DomainName), + Zone: endpointv1alpha1.EndpointRecordSetConversionZone{DomainName: zone.Spec.DomainName}, + Targets: endpointRecordSet.Spec.Targets, + } + fragments, message, err := r.convertEndpointRecordSet(ctx, capability, input) + if err != nil { + return status, nil, err + } + if message != "" { + rejections = append(rejections, fmt.Sprintf("%s/%s: endpoint conversion failed: %s", zone.Namespace, zone.Name, message)) + continue + } + recordSets := make([]dnsv1alpha1.RecordSet, 0, len(fragments)) + allowed := true + rejectionMessage := "" + for _, fragment := range fragments { + recordSet := r.recordSetFromFragment(ctx, endpointRecordSet, recordSetNamespace, &zone, fragment) + ok, message := declaration.RecordSetAllowedByZone(ctx, r.Client, &recordSet, &zone) + if !ok { + allowed = false + rejectionMessage = fmt.Sprintf("%s/%s: generated RecordSet %s/%s is not allowed by Zone: %s", zone.Namespace, zone.Name, recordSet.Namespace, recordSet.Name, message) + break + } + recordSets = append(recordSets, recordSet) + } + if !allowed { + rejections = append(rejections, rejectionMessage) + continue + } + status.Zone = &endpointv1alpha1.EndpointRecordSetZoneStatus{ + Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, + DomainName: zone.Spec.DomainName, + Provider: zone.Spec.Provider, + } + for _, recordSet := range recordSets { + item := endpointv1alpha1.EndpointRecordSetGeneratedRecordSetStatus{ + Ref: dnsv1alpha1.ObjectReference{Namespace: recordSet.Namespace, Name: recordSet.Name}, + Name: recordSet.Spec.Name, + Type: endpointv1alpha1.EndpointRecordSetType(recordSet.Spec.Type), + Fragment: &endpointv1alpha1.RecordSetSpecFragment{ + Type: endpointv1alpha1.EndpointRecordSetType(recordSet.Spec.Type), + Name: recordSet.Spec.Name, + TTL: recordSet.Spec.TTL, + A: recordSet.Spec.A, + AAAA: recordSet.Spec.AAAA, + CNAME: recordSet.Spec.CNAME, + Options: recordSet.Spec.Options, + }, + } + var existing dnsv1alpha1.RecordSet + if err := r.Get(ctx, client.ObjectKey{Namespace: recordSet.Namespace, Name: recordSet.Name}, &existing); err == nil { + item.Conditions = existing.Status.Conditions + } + status.RecordSets = append(status.RecordSets, item) + } + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionTrue, Reason: "Resolved", Message: "Hostname resolved to generated RecordSets."}) + return status, recordSets, nil + } + meta.SetStatusCondition(&status.Conditions, metav1.Condition{Type: "Resolved", Status: metav1.ConditionFalse, Reason: "ZoneNotResolved", Message: "No Zone and EndpointProviderCapability combination accepted this hostname: " + strings.Join(rejections, "; ")}) + return status, nil, nil +} + +func (r *Reconciler) convertEndpointRecordSet(ctx context.Context, capability *endpointv1alpha1.EndpointProviderCapability, input endpointv1alpha1.EndpointRecordSetConversionInput) ([]endpointv1alpha1.RecordSetSpecFragment, string, error) { + if r.RESTConfig == nil { + return nil, "EndpointRecordSet conversion API is configured but no REST config is available", nil + } + version := capability.Spec.Conversion.Version + if version == "" { + version = capability.Spec.Provider.Version + } + gvr := schema.GroupVersionResource{ + Group: capability.Spec.Conversion.Group, + Version: version, + Resource: capability.Spec.Conversion.Resource, + } + uid := string(uuid.NewUUID()) + request := endpointconversionv1alpha1.EndpointRecordSetConversion{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gvr.Group + "/" + gvr.Version, + Kind: "EndpointRecordSetConversion", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "endpoint-record-set-conversion-" + uid, + }, + Spec: endpointconversionv1alpha1.EndpointRecordSetConversionSpec{ + UID: uid, + Input: input, + }, + } + object, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&request) + if err != nil { + return nil, "", err + } + object["apiVersion"] = gvr.Group + "/" + gvr.Version + object["kind"] = "EndpointRecordSetConversion" + dynamicClient, err := dynamic.NewForConfig(r.RESTConfig) + if err != nil { + return nil, "", err + } + responseObject, err := dynamicClient.Resource(gvr).Create(ctx, &unstructured.Unstructured{Object: object}, metav1.CreateOptions{}) + if apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) { + return nil, "EndpointRecordSet conversion API denied the request: " + err.Error(), nil + } + if err != nil { + return nil, "EndpointRecordSet conversion API is unavailable: " + err.Error(), nil + } + var response endpointconversionv1alpha1.EndpointRecordSetConversion + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(responseObject.Object, &response); err != nil { + return nil, "EndpointRecordSet conversion API returned malformed content: " + err.Error(), nil + } + if response.Status.UID != uid { + return nil, "EndpointRecordSet conversion API returned mismatched uid", nil + } + if response.Status.Result.Status == "Failure" { + message := response.Status.Result.Message + if message == "" { + message = response.Status.Result.Reason + } + return nil, message, nil + } + if response.Status.Result.Status != "Success" { + return nil, "EndpointRecordSet conversion API returned unsupported result status " + response.Status.Result.Status, nil + } + if response.Status.Output == nil || len(response.Status.Output.Fragments) == 0 { + return nil, "EndpointRecordSet conversion API returned no fragments", nil + } + for _, fragment := range response.Status.Output.Fragments { + if fragment.Name != input.Name { + return nil, "EndpointRecordSet conversion API changed record name", nil + } + if fragment.Type != endpointv1alpha1.EndpointRecordSetTypeA && fragment.Type != endpointv1alpha1.EndpointRecordSetTypeAAAA && fragment.Type != endpointv1alpha1.EndpointRecordSetTypeCNAME { + return nil, "EndpointRecordSet conversion API returned unsupported record type " + string(fragment.Type), nil + } + } + return response.Status.Output.Fragments, "", nil +} + +func (r *Reconciler) sortedZones(ctx context.Context, hostname string) ([]dnsv1alpha1.Zone, error) { + var list dnsv1alpha1.ZoneList + if err := r.List(ctx, &list); err != nil { + return nil, err + } + zones := make([]dnsv1alpha1.Zone, 0, len(list.Items)) + for _, zone := range list.Items { + if hostname == zone.Spec.DomainName || strings.HasSuffix(hostname, "."+zone.Spec.DomainName) { + zones = append(zones, zone) + } + } + sort.SliceStable(zones, func(i, j int) bool { + return len(zones[i].Spec.DomainName) > len(zones[j].Spec.DomainName) + }) + return zones, nil +} + +func (r *Reconciler) capabilityForZone(ctx context.Context, zone *dnsv1alpha1.Zone) (*endpointv1alpha1.EndpointProviderCapability, int, error) { + var list endpointv1alpha1.EndpointProviderCapabilityList + if err := r.List(ctx, &list); err != nil { + return nil, 0, err + } + var matched []endpointv1alpha1.EndpointProviderCapability + for _, item := range list.Items { + if item.Spec.Provider == zone.Spec.Provider { + matched = append(matched, item) + } + } + if len(matched) != 1 { + return nil, len(matched), nil + } + return &matched[0], 1, nil +} + +func (r *Reconciler) recordSetFromFragment(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, namespace string, zone *dnsv1alpha1.Zone, fragment endpointv1alpha1.RecordSetSpecFragment) dnsv1alpha1.RecordSet { + recordSet := dnsv1alpha1.RecordSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: generatedRecordSetName(endpointRecordSet.Namespace, endpointRecordSet.Name, zone.Namespace, zone.Name, fragment.Name, fragment.Type), + Labels: map[string]string{ + managedByLabel: managedByValue, + generatedLabelEndpointRecordSetNamespace: endpointRecordSet.Namespace, + generatedLabelEndpointRecordSetName: endpointRecordSet.Name, + }, + }, + Spec: dnsv1alpha1.RecordSetSpec{ + ZoneRef: dnsv1alpha1.ZoneReference{Namespace: &zone.Namespace, Name: zone.Name}, + Provider: zone.Spec.Provider, + Type: fragment.Type.DNSRecordType(), + Name: fragment.Name, + TTL: fragment.TTL, + A: fragment.A, + AAAA: fragment.AAAA, + CNAME: fragment.CNAME, + Options: fragment.Options, + }, + } + return recordSet +} + +func generatedRecordSetName(endpointRecordSetNamespace, endpointRecordSetName, zoneNamespace, zoneName, recordName string, recordType endpointv1alpha1.EndpointRecordSetType) string { + hashInput := endpointRecordSetNamespace + "/" + endpointRecordSetName + "/" + zoneNamespace + "/" + zoneName + "/" + recordName + sum := sha256.Sum256([]byte(hashInput)) + hash := hex.EncodeToString(sum[:])[:10] + cleanRecordName := strings.NewReplacer(".", "-", "*", "wildcard").Replace(recordName) + candidate := fmt.Sprintf("%s-%s-%s-%s", endpointRecordSetName, cleanRecordName, strings.ToLower(string(recordType)), hash) + if len(candidate) <= 63 { + return candidate + } + suffix := "-" + strings.ToLower(string(recordType)) + "-" + hash + prefixLength := 63 - len(suffix) + return strings.Trim(candidate[:prefixLength], "-") + suffix +} + +func (r *Reconciler) applyRecordSets(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, namespace string, desired []dnsv1alpha1.RecordSet) error { + desiredNames := map[string]struct{}{} + for _, item := range desired { + desired := item + desiredNames[desired.Name] = struct{}{} + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + var existing dnsv1alpha1.RecordSet + err := r.Get(ctx, client.ObjectKey{Namespace: desired.Namespace, Name: desired.Name}, &existing) + if apierrors.IsNotFound(err) { + return r.Create(ctx, &desired, client.FieldOwner(fieldOwner)) + } + if err != nil { + return err + } + existing.Labels = desired.Labels + existing.Spec = desired.Spec + return r.Update(ctx, &existing, client.FieldOwner(fieldOwner)) + }); err != nil { + return err + } + } + var existing dnsv1alpha1.RecordSetList + if err := r.List(ctx, &existing, client.InNamespace(namespace), client.MatchingLabels{ + managedByLabel: managedByValue, + generatedLabelEndpointRecordSetNamespace: endpointRecordSet.Namespace, + generatedLabelEndpointRecordSetName: endpointRecordSet.Name, + }); err != nil { + return err + } + for _, item := range existing.Items { + if _, ok := desiredNames[item.Name]; ok { + continue + } + item := item + if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func (r *Reconciler) cleanupForEndpointRecordSet(ctx context.Context, recordSetNamespace, endpointRecordSetNamespace, endpointRecordSetName string) error { + var existing dnsv1alpha1.RecordSetList + if err := r.List(ctx, &existing, client.InNamespace(recordSetNamespace), client.MatchingLabels{ + managedByLabel: managedByValue, + generatedLabelEndpointRecordSetNamespace: endpointRecordSetNamespace, + generatedLabelEndpointRecordSetName: endpointRecordSetName, + }); err != nil { + return err + } + for _, item := range existing.Items { + item := item + if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func (r *Reconciler) patchStatus(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, status endpointv1alpha1.EndpointRecordSetStatus) error { + var current endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, client.ObjectKeyFromObject(endpointRecordSet), ¤t); err != nil { + return err + } + base := current.DeepCopy() + current.Status = status + return client.IgnoreNotFound(r.Status().Patch(ctx, ¤t, client.MergeFrom(base))) +} + +func relativeRecordName(hostname, zoneDomainName string) string { + trimmed := strings.TrimSuffix(hostname, ".") + zone := strings.TrimSuffix(zoneDomainName, ".") + if trimmed == zone { + return "@" + } + return strings.TrimSuffix(trimmed, "."+zone) +} + +func sortedStrings(items []string) []string { + out := append([]string(nil), items...) + sort.Strings(out) + return out +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go new file mode 100644 index 0000000..55d9905 --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go @@ -0,0 +1,238 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const ( + fieldOwner = "dns-api-gateway-endpoint-controller" + httpRouteFinalizer = "gateway.endpoint.dns.appthrust.io/httproute-finalizer" + generatedLabelRouteNamespace = "gateway.endpoint.dns.appthrust.io/route-namespace" + generatedLabelRouteName = "gateway.endpoint.dns.appthrust.io/route-name" + generatedLabelGatewayNamespace = "gateway.endpoint.dns.appthrust.io/gateway-namespace" + generatedLabelGatewayName = "gateway.endpoint.dns.appthrust.io/gateway-name" + generatedLabelListenerName = "gateway.endpoint.dns.appthrust.io/listener-name" + managedByLabel = "app.kubernetes.io/managed-by" + managedByValue = "dns-api-gateway-endpoint" +) + +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + EndpointRecordSetNamespace string +} + +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;update +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/finalizers,verbs=update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=create;delete;get;list;watch;patch;update +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + namespace := r.EndpointRecordSetNamespace + if namespace == "" { + namespace = req.Namespace + } + + var route gatewayv1.HTTPRoute + if err := r.Get(ctx, req.NamespacedName, &route); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, r.cleanupForRoute(ctx, namespace, req.Namespace, req.Name) + } + return ctrl.Result{}, err + } + if !route.DeletionTimestamp.IsZero() { + if err := r.cleanupForRoute(ctx, namespace, route.Namespace, route.Name); err != nil { + return ctrl.Result{}, err + } + if controllerutil.ContainsFinalizer(&route, httpRouteFinalizer) { + controllerutil.RemoveFinalizer(&route, httpRouteFinalizer) + if err := r.Update(ctx, &route); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + if !controllerutil.ContainsFinalizer(&route, httpRouteFinalizer) { + controllerutil.AddFinalizer(&route, httpRouteFinalizer) + if err := r.Update(ctx, &route); err != nil { + return ctrl.Result{}, err + } + } + + desired, err := r.endpointRecordSetsForRoute(ctx, &route, namespace) + if err != nil { + return ctrl.Result{}, err + } + if err := r.applyEndpointRecordSets(ctx, &route, namespace, desired); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Scheme == nil { + r.Scheme = mgr.GetScheme() + } + return ctrl.NewControllerManagedBy(mgr). + Named("gateway-endpoint"). + For(&gatewayv1.HTTPRoute{}). + Watches(&gatewayv1.Gateway{}, handler.EnqueueRequestsFromMapFunc(r.mapGatewayToHTTPRoutes)). + Complete(r) +} + +func (r *Reconciler) mapGatewayToHTTPRoutes(ctx context.Context, obj client.Object) []reconcile.Request { + gateway, ok := obj.(*gatewayv1.Gateway) + if !ok { + return nil + } + var routes gatewayv1.HTTPRouteList + if err := r.List(ctx, &routes); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for _, route := range routes.Items { + for _, parentRef := range route.Spec.ParentRefs { + namespace := route.Namespace + if parentRef.Namespace != nil { + namespace = string(*parentRef.Namespace) + } + if namespace == gateway.Namespace && string(parentRef.Name) == gateway.Name { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&route)}) + break + } + } + } + return requests +} + +func (r *Reconciler) endpointRecordSetsForRoute(ctx context.Context, route *gatewayv1.HTTPRoute, namespace string) ([]endpointv1alpha1.EndpointRecordSet, error) { + parents, err := r.acceptedParents(ctx, route) + if err != nil { + return nil, err + } + if len(parents) == 0 || len(parents) != len(route.Spec.ParentRefs) { + return nil, nil + } + desired := make([]endpointv1alpha1.EndpointRecordSet, 0, len(parents)) + for _, parent := range parents { + hostnames := effectiveHostnamesForParent(route, parent) + targets := endpointTargets(parent.addresses) + if len(hostnames) == 0 || len(targets) == 0 { + continue + } + listenerName := string(parent.listener.Name) + gatewayNamespace := parent.gateway.Namespace + gatewayName := parent.gateway.Name + item := endpointv1alpha1.EndpointRecordSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: generatedEndpointRecordSetName(route.Namespace, route.Name, gatewayNamespace, gatewayName, listenerName), + Labels: map[string]string{ + managedByLabel: managedByValue, + generatedLabelRouteNamespace: route.Namespace, + generatedLabelRouteName: route.Name, + generatedLabelGatewayNamespace: gatewayNamespace, + generatedLabelGatewayName: gatewayName, + generatedLabelListenerName: listenerName, + }, + }, + Spec: endpointv1alpha1.EndpointRecordSetSpec{ + Hostnames: hostnames, + Targets: targets, + }, + } + desired = append(desired, item) + } + sort.Slice(desired, func(i, j int) bool { + return desired[i].Name < desired[j].Name + }) + return desired, nil +} + +func (r *Reconciler) applyEndpointRecordSets(ctx context.Context, route *gatewayv1.HTTPRoute, namespace string, desired []endpointv1alpha1.EndpointRecordSet) error { + desiredNames := map[string]struct{}{} + for _, item := range desired { + desired := item + desiredNames[desired.Name] = struct{}{} + var existing endpointv1alpha1.EndpointRecordSet + err := r.Get(ctx, client.ObjectKey{Namespace: desired.Namespace, Name: desired.Name}, &existing) + if apierrors.IsNotFound(err) { + if err := r.Create(ctx, &desired, client.FieldOwner(fieldOwner)); err != nil { + return err + } + continue + } + if err != nil { + return err + } + existing.Labels = desired.Labels + existing.Spec = desired.Spec + if err := r.Update(ctx, &existing, client.FieldOwner(fieldOwner)); err != nil { + return err + } + } + var existing endpointv1alpha1.EndpointRecordSetList + if err := r.List(ctx, &existing, client.InNamespace(namespace), client.MatchingLabels{ + managedByLabel: managedByValue, + generatedLabelRouteNamespace: route.Namespace, + generatedLabelRouteName: route.Name, + }); err != nil { + return err + } + for _, item := range existing.Items { + if _, ok := desiredNames[item.Name]; ok { + continue + } + item := item + if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func (r *Reconciler) cleanupForRoute(ctx context.Context, endpointRecordSetNamespace, routeNamespace, routeName string) error { + var existing endpointv1alpha1.EndpointRecordSetList + if err := r.List(ctx, &existing, client.InNamespace(endpointRecordSetNamespace), client.MatchingLabels{ + managedByLabel: managedByValue, + generatedLabelRouteNamespace: routeNamespace, + generatedLabelRouteName: routeName, + }); err != nil { + return err + } + for _, item := range existing.Items { + item := item + if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func generatedEndpointRecordSetName(routeNamespace, routeName, gatewayNamespace, gatewayName, listenerName string) string { + hashInput := routeNamespace + "/" + routeName + "/" + gatewayNamespace + "/" + gatewayName + "/" + listenerName + sum := sha256.Sum256([]byte(hashInput)) + hash := hex.EncodeToString(sum[:])[:10] + candidate := fmt.Sprintf("%s-%s-%s", routeName, gatewayName, listenerName) + candidate = strings.NewReplacer(".", "-", "_", "-", "*", "wildcard").Replace(candidate) + candidate = strings.ToLower(candidate) + if len(candidate) <= 52 { + return candidate + "-" + hash + } + return strings.Trim(candidate[:52], "-") + "-" + hash +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/controller_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/controller_test.go new file mode 100644 index 0000000..9bc9a76 --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/controller_test.go @@ -0,0 +1,128 @@ +package gateway + +import ( + "context" + "testing" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestReconcilerCreatesEndpointRecordSetForAcceptedHTTPRouteParent(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + section := gatewayv1.SectionName("https") + gatewayNamespace := gatewayv1.Namespace("platform") + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "web"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Namespace: &gatewayNamespace, + Name: "public", + SectionName: §ion, + }}, + }, + Hostnames: []gatewayv1.Hostname{"api.example.com"}, + }, + Status: gatewayv1.HTTPRouteStatus{ + RouteStatus: gatewayv1.RouteStatus{ + Parents: []gatewayv1.RouteParentStatus{{ + ParentRef: gatewayv1.ParentReference{ + Namespace: &gatewayNamespace, + Name: "public", + SectionName: §ion, + }, + Conditions: []metav1.Condition{{Type: string(gatewayv1.RouteConditionAccepted), Status: metav1.ConditionTrue}}, + }}, + }, + }, + } + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "public"}, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{{Name: section}}, + }, + Status: gatewayv1.GatewayStatus{ + Addresses: []gatewayv1.GatewayStatusAddress{{Value: "k8s-public-123.ap-northeast-1.elb.amazonaws.com"}}, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route, gateway). + Build() + + reconciler := &Reconciler{Client: k8sClient, Scheme: scheme, EndpointRecordSetNamespace: "dns-api-system"} + if _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(route)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var list endpointv1alpha1.EndpointRecordSetList + if err := k8sClient.List(ctx, &list, client.InNamespace("dns-api-system")); err != nil { + t.Fatalf("list EndpointRecordSets: %v", err) + } + if len(list.Items) != 1 { + t.Fatalf("EndpointRecordSets = %d, want 1", len(list.Items)) + } + got := list.Items[0] + if len(got.Spec.Hostnames) != 1 || got.Spec.Hostnames[0] != "api.example.com" { + t.Fatalf("hostnames = %#v", got.Spec.Hostnames) + } + if len(got.Spec.Targets) != 1 || got.Spec.Targets[0].Type != endpointv1alpha1.EndpointTargetTypeHostname || got.Spec.Targets[0].Value != "k8s-public-123.ap-northeast-1.elb.amazonaws.com" { + t.Fatalf("targets = %#v", got.Spec.Targets) + } +} + +func TestReconcilerWaitsUntilAllHTTPRouteParentsAreAccepted(t *testing.T) { + ctx := context.Background() + scheme := testScheme(t) + section := gatewayv1.SectionName("https") + gatewayNamespace := gatewayv1.Namespace("platform") + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "web"}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{{ + Namespace: &gatewayNamespace, + Name: "public", + SectionName: §ion, + }}, + }, + Hostnames: []gatewayv1.Hostname{"api.example.com"}, + }, + } + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route). + Build() + + reconciler := &Reconciler{Client: k8sClient, Scheme: scheme, EndpointRecordSetNamespace: "dns-api-system"} + if _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(route)}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var list endpointv1alpha1.EndpointRecordSetList + if err := k8sClient.List(ctx, &list, client.InNamespace("dns-api-system")); err != nil { + t.Fatalf("list EndpointRecordSets: %v", err) + } + if len(list.Items) != 0 { + t.Fatalf("EndpointRecordSets = %d, want 0", len(list.Items)) + } +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := endpointv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("add endpoint scheme: %v", err) + } + if err := gatewayv1.Install(scheme); err != nil { + t.Fatalf("add gateway scheme: %v", err) + } + return scheme +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go b/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go new file mode 100644 index 0000000..00f3f1d --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go @@ -0,0 +1,181 @@ +package gateway + +import ( + "context" + "net" + "sort" + "strings" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type parentInfo struct { + ref gatewayv1.ParentReference + gateway gatewayv1.Gateway + listener gatewayv1.Listener + addresses []gatewayv1.GatewayStatusAddress +} + +func (r *Reconciler) acceptedParents(ctx context.Context, route *gatewayv1.HTTPRoute) ([]parentInfo, error) { + acceptedKeys := map[string]struct{}{} + for _, parent := range route.Status.Parents { + for _, condition := range parent.Conditions { + if condition.Type == string(gatewayv1.RouteConditionAccepted) && condition.Status == "True" { + acceptedKeys[parentRefKey(route.Namespace, parent.ParentRef)] = struct{}{} + } + } + } + + parents := make([]parentInfo, 0, len(route.Spec.ParentRefs)) + for _, ref := range route.Spec.ParentRefs { + if _, ok := acceptedKeys[parentRefKey(route.Namespace, ref)]; !ok { + continue + } + namespace := route.Namespace + if ref.Namespace != nil { + namespace = string(*ref.Namespace) + } + var gateway gatewayv1.Gateway + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: string(ref.Name)}, &gateway); err != nil { + return nil, err + } + for _, listener := range gateway.Spec.Listeners { + if ref.SectionName != nil && listener.Name != *ref.SectionName { + continue + } + parents = append(parents, parentInfo{ + ref: ref, + gateway: gateway, + listener: listener, + addresses: gateway.Status.Addresses, + }) + } + } + return parents, nil +} + +func parentRefKey(routeNamespace string, ref gatewayv1.ParentReference) string { + namespace := routeNamespace + if ref.Namespace != nil { + namespace = string(*ref.Namespace) + } + section := "" + if ref.SectionName != nil { + section = string(*ref.SectionName) + } + return namespace + "/" + string(ref.Name) + "/" + section +} + +func effectiveHostnames(route *gatewayv1.HTTPRoute, parents []parentInfo) []string { + hostnames := map[string]struct{}{} + for _, parent := range parents { + listenerHostname := "" + if parent.listener.Hostname != nil { + listenerHostname = string(*parent.listener.Hostname) + } + if len(route.Spec.Hostnames) == 0 { + if listenerHostname != "" { + hostnames[listenerHostname] = struct{}{} + } + continue + } + for _, routeHostname := range route.Spec.Hostnames { + hostname := string(routeHostname) + if listenerHostname == "" { + hostnames[hostname] = struct{}{} + continue + } + if matched, ok := hostnameIntersection(hostname, listenerHostname); ok { + hostnames[matched] = struct{}{} + } + } + } + result := make([]string, 0, len(hostnames)) + for hostname := range hostnames { + result = append(result, hostname) + } + sort.Strings(result) + return result +} + +func effectiveHostnamesForParent(route *gatewayv1.HTTPRoute, parent parentInfo) []string { + listenerHostname := "" + if parent.listener.Hostname != nil { + listenerHostname = string(*parent.listener.Hostname) + } + hostnames := map[string]struct{}{} + if len(route.Spec.Hostnames) == 0 { + if listenerHostname != "" { + hostnames[listenerHostname] = struct{}{} + } + } else { + for _, routeHostname := range route.Spec.Hostnames { + hostname := string(routeHostname) + if listenerHostname == "" { + hostnames[hostname] = struct{}{} + continue + } + if matched, ok := hostnameIntersection(hostname, listenerHostname); ok { + hostnames[matched] = struct{}{} + } + } + } + result := make([]string, 0, len(hostnames)) + for hostname := range hostnames { + result = append(result, strings.TrimSuffix(hostname, ".")) + } + sort.Strings(result) + return result +} + +func hostnameIntersection(routeHostname, listenerHostname string) (string, bool) { + if routeHostname == listenerHostname { + return routeHostname, true + } + if strings.HasPrefix(listenerHostname, "*.") { + return routeHostname, strings.HasSuffix(routeHostname, strings.TrimPrefix(listenerHostname, "*")) + } + if strings.HasPrefix(routeHostname, "*.") { + return listenerHostname, strings.HasSuffix(listenerHostname, strings.TrimPrefix(routeHostname, "*")) + } + return "", false +} + +func endpointTargets(addresses []gatewayv1.GatewayStatusAddress) []endpointv1alpha1.EndpointTarget { + seen := map[string]struct{}{} + targets := make([]endpointv1alpha1.EndpointTarget, 0, len(addresses)) + for _, address := range addresses { + value := strings.TrimSuffix(string(address.Value), ".") + if value == "" { + continue + } + targetType := endpointv1alpha1.EndpointTargetTypeHostname + if net.ParseIP(value) != nil { + targetType = endpointv1alpha1.EndpointTargetTypeIPAddress + } + key := string(targetType) + "\x00" + value + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + targets = append(targets, endpointv1alpha1.EndpointTarget{Type: targetType, Value: value}) + } + sort.Slice(targets, func(i, j int) bool { + if targets[i].Type == targets[j].Type { + return targets[i].Value < targets[j].Value + } + return targets[i].Type < targets[j].Type + }) + return targets +} + +func appendUnique(items []string, item string) []string { + for _, existing := range items { + if existing == item { + return items + } + } + return append(items, item) +} diff --git a/internal/go/providers/route53/controllers/zoneunit/recordset.go b/internal/go/providers/route53/controllers/zoneunit/recordset.go index cc65500..0f911cf 100644 --- a/internal/go/providers/route53/controllers/zoneunit/recordset.go +++ b/internal/go/providers/route53/controllers/zoneunit/recordset.go @@ -277,9 +277,6 @@ func (r *ZoneReconciler) planRecordSetDelete(ctx context.Context, recordSet *dns } func (r *ZoneReconciler) acceptRecordSetForZone(ctx context.Context, zone *dnsv1alpha1.Zone, zoneClass *dnsv1alpha1.ZoneClass, recordSet *dnsv1alpha1.RecordSet) (bool, error) { - if allowed, message := r.recordSetAllowedByZoneMessage(ctx, recordSet, zone); !allowed { - return false, r.setRecordSetAccepted(ctx, recordSet, metav1.ConditionFalse, "NotAllowedByZone", message) - } if recordSet.Status.Zone != nil && recordSet.Status.Zone.Ref != (dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}) { return false, r.setRecordSetAccepted(ctx, recordSet, metav1.ConditionFalse, "InvalidZoneRef", "RecordSet status points to another Zone") } diff --git a/internal/go/providers/route53/controllers/zoneunit/zone.go b/internal/go/providers/route53/controllers/zoneunit/zone.go index 9c590fb..6faa8e5 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone.go @@ -532,6 +532,7 @@ func (r *ZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneClassToZoneUnits)). Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProviderToZoneUnits)). Watches(&route53v1alpha1.Route53Identity{}, handler.EnqueueRequestsFromMapFunc(r.mapIdentityToZoneUnits)). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToZoneUnits)). Complete(r) } @@ -543,6 +544,31 @@ func (r *ZoneReconciler) mapZoneClassToZoneUnits(ctx context.Context, obj client return r.zoneUnitRequestsForZoneClass(ctx, zoneClass.Namespace, zoneClass.Name) } +func (r *ZoneReconciler) mapNamespaceToZoneUnits(ctx context.Context, obj client.Object) []reconcile.Request { + namespace, ok := obj.(*corev1.Namespace) + if !ok { + return nil + } + var units dnsv1alpha1.ZoneUnitList + if err := r.List(ctx, &units); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for _, unit := range units.Items { + if unit.Namespace == namespace.Name { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&unit)}) + continue + } + for _, recordSet := range unit.Spec.RecordSets { + if recordSet.RecordSetNamespace == namespace.Name { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&unit)}) + break + } + } + } + return requests +} + func (r *ZoneReconciler) mapProviderToZoneUnits(ctx context.Context, obj client.Object) []reconcile.Request { provider, ok := obj.(*dnsv1alpha1.Provider) if !ok { @@ -729,13 +755,13 @@ func (r *ZoneReconciler) reconcileFinalizer(ctx context.Context, unit *dnsv1alph case shouldHaveFinalizer && !hasFinalizer: base := unit.DeepCopy() unit.Finalizers = append(unit.Finalizers, ZoneFinalizer) - return true, r.Patch(ctx, unit, client.MergeFrom(base)) + return true, client.IgnoreNotFound(r.Patch(ctx, unit, client.MergeFrom(base))) case !shouldHaveFinalizer && hasFinalizer: base := unit.DeepCopy() unit.Finalizers = slices.DeleteFunc(unit.Finalizers, func(finalizer string) bool { return finalizer == ZoneFinalizer }) - return true, r.Patch(ctx, unit, client.MergeFrom(base)) + return true, client.IgnoreNotFound(r.Patch(ctx, unit, client.MergeFrom(base))) default: return false, nil } @@ -1165,7 +1191,7 @@ func (r *ZoneReconciler) patchZoneStatus(ctx context.Context, zone *dnsv1alpha1. if equality.Semantic.DeepEqual(unitBase.Status, unit.Status) { return nil } - return r.Status().Patch(ctx, &unit, client.MergeFrom(unitBase)) + return client.IgnoreNotFound(r.Status().Patch(ctx, &unit, client.MergeFrom(unitBase))) } func (r *ZoneReconciler) hydrateRoute53ZoneStatusFromZoneUnit(ctx context.Context, zone *dnsv1alpha1.Zone) error { @@ -1301,7 +1327,7 @@ func (r *ZoneReconciler) removeFinalizer(ctx context.Context, unit *dnsv1alpha1. unit.Finalizers = slices.DeleteFunc(unit.Finalizers, func(finalizer string) bool { return finalizer == ZoneFinalizer }) - return r.Patch(ctx, unit, client.MergeFrom(base)) + return client.IgnoreNotFound(r.Patch(ctx, unit, client.MergeFrom(base))) } func (r *ZoneReconciler) controllerName() string { diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test.go index 5bd4b51..7b6ebd9 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test.go @@ -935,7 +935,7 @@ func TestZoneReconcilerCreatesARecordFromZoneReconcile(t *testing.T) { } } -func TestZoneReconcilerDoesNotUpsertRecordSetRejectedByZone(t *testing.T) { +func TestZoneReconcilerUpsertsRecordSetSelectedByZoneUnitAcrossNamespaces(t *testing.T) { ctx := context.Background() scheme := testScheme(t) provider := newFakeProvider() @@ -958,8 +958,8 @@ func TestZoneReconcilerDoesNotUpsertRecordSetRejectedByZone(t *testing.T) { if err != nil { t.Fatalf("Reconcile returned error: %v", err) } - if len(provider.upserted) != 0 { - t.Fatalf("upserted record sets = %d, want 0", len(provider.upserted)) + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) } var unit dnsv1alpha1.ZoneUnit @@ -972,15 +972,8 @@ func TestZoneReconcilerDoesNotUpsertRecordSetRejectedByZone(t *testing.T) { if index < 0 { t.Fatalf("ZoneUnit status.recordSets has no entry for %s/%s: %#v", recordSet.Namespace, recordSet.Name, unit.Status.RecordSets) } - assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "NotAllowedByZone") + assertCondition(t, unit.Status.RecordSets[index].Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") - var gotRecordSet dnsv1alpha1.RecordSet - if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &gotRecordSet); err != nil { - t.Fatalf("RecordSet was not found: %v", err) - } - if slices.Contains(gotRecordSet.Finalizers, RecordSetFinalizer) { - t.Fatalf("finalizers = %#v, want no Route 53 RecordSet finalizer", gotRecordSet.Finalizers) - } } func TestZoneReconcilerAcceptsRecordSetAdoptionFromDerivedIdentity(t *testing.T) { diff --git a/internal/go/providers/route53/conversion/alias.go b/internal/go/providers/route53/conversion/alias.go new file mode 100644 index 0000000..badc8a4 --- /dev/null +++ b/internal/go/providers/route53/conversion/alias.go @@ -0,0 +1,101 @@ +package conversion + +import "strings" + +var route53CanonicalHostedZones = map[string]string{ + // Application Load Balancers and Classic Load Balancers. + "us-east-2.elb.amazonaws.com": "Z3AADJGX6KTTL2", + "us-east-1.elb.amazonaws.com": "Z35SXDOTRQ7X7K", + "us-west-1.elb.amazonaws.com": "Z368ELLRRE2KJ0", + "us-west-2.elb.amazonaws.com": "Z1H1FL5HABSF5", + "ca-central-1.elb.amazonaws.com": "ZQSVJUPU6J1EY", + "ca-west-1.elb.amazonaws.com": "Z06473681N0SF6OS049SD", + "ap-east-1.elb.amazonaws.com": "Z3DQVH9N71FHZ0", + "ap-east-2.elb.amazonaws.com": "Z02789141MW7T1WBU19PO", + "ap-south-1.elb.amazonaws.com": "ZP97RAFLXTNZK", + "ap-south-2.elb.amazonaws.com": "Z0173938T07WNTVAEPZN", + "ap-northeast-1.elb.amazonaws.com": "Z14GRHDCWA56QT", + "ap-northeast-2.elb.amazonaws.com": "ZWKZPGTI48KDX", + "ap-northeast-3.elb.amazonaws.com": "Z5LXEXXYW11ES", + "ap-southeast-1.elb.amazonaws.com": "Z1LMS91P8CMLE5", + "ap-southeast-2.elb.amazonaws.com": "Z1GM3OXH4ZPM65", + "ap-southeast-3.elb.amazonaws.com": "Z08888821HLRG5A9ZRTER", + "ap-southeast-4.elb.amazonaws.com": "Z09517862IB2WZLPXG76F", + "ap-southeast-5.elb.amazonaws.com": "Z06010284QMVVW7WO5J", + "ap-southeast-6.elb.amazonaws.com": "Z023301818UFJ50CIO0MV", + "ap-southeast-7.elb.amazonaws.com": "Z0390008CMBRTHFGWBCB", + "eu-central-1.elb.amazonaws.com": "Z215JYRZR1TBD5", + "eu-central-2.elb.amazonaws.com": "Z06391101F2ZOEP8P5EB3", + "eu-west-1.elb.amazonaws.com": "Z32O12XQLNTSW2", + "eu-west-2.elb.amazonaws.com": "ZHURV8PSTC4K8", + "eu-west-3.elb.amazonaws.com": "Z3Q77PNBQS71R4", + "eu-north-1.elb.amazonaws.com": "Z23TAZ6LKFMNIO", + "eu-south-1.elb.amazonaws.com": "Z3ULH7SSC9OV64", + "eu-south-2.elb.amazonaws.com": "Z0956581394HF5D5LXGAP", + "sa-east-1.elb.amazonaws.com": "Z2P70J7HTTTPLU", + "cn-north-1.elb.amazonaws.com.cn": "Z1GDH35T77C1KE", + "cn-northwest-1.elb.amazonaws.com.cn": "ZM7IZAIOVVDZF", + "us-gov-west-1.elb.amazonaws.com": "Z33AYJ8TM3BH4J", + "us-gov-east-1.elb.amazonaws.com": "Z166TLBEWOO7G0", + "mx-central-1.elb.amazonaws.com": "Z023552324OKD1BB28BH5", + "me-central-1.elb.amazonaws.com": "Z08230872XQRWHG2XF6I", + "me-south-1.elb.amazonaws.com": "ZS929ML54UICD", + "af-south-1.elb.amazonaws.com": "Z268VQBMOI5EKX", + "il-central-1.elb.amazonaws.com": "Z09170902867EHPV2DABU", + + // Network Load Balancers. + "elb.us-east-2.amazonaws.com": "ZLMOA37VPKANP", + "elb.us-east-1.amazonaws.com": "Z26RNL4JYFTOTI", + "elb.us-west-1.amazonaws.com": "Z24FKFUX50B4VW", + "elb.us-west-2.amazonaws.com": "Z18D5FSROUN65G", + "elb.ca-central-1.amazonaws.com": "Z2EPGBW3API2WT", + "elb.ca-west-1.amazonaws.com": "Z02754302KBB00W2LKWZ9", + "elb.ap-east-1.amazonaws.com": "Z12Y7K3UBGUAD1", + "elb.ap-east-2.amazonaws.com": "Z09176273OC2HWIAUNYW", + "elb.ap-south-1.amazonaws.com": "ZVDDRBQ08TROA", + "elb.ap-south-2.amazonaws.com": "Z0711778386UTO08407HT", + "elb.ap-northeast-1.amazonaws.com": "Z31USIVHYNEOWT", + "elb.ap-northeast-2.amazonaws.com": "ZIBE1TIR4HY56", + "elb.ap-northeast-3.amazonaws.com": "Z1GWIQ4HH19I5X", + "elb.ap-southeast-1.amazonaws.com": "ZKVM4W9LS7TM", + "elb.ap-southeast-2.amazonaws.com": "ZCT6FZBF4DROD", + "elb.ap-southeast-3.amazonaws.com": "Z01971771FYVNCOVWJU1G", + "elb.ap-southeast-4.amazonaws.com": "Z01156963G8MIIL7X90IV", + "elb.ap-southeast-5.amazonaws.com": "Z026317210H9ACVTRO6FB", + "elb.ap-southeast-6.amazonaws.com": "Z01392953RKV2Q3RBP0KU", + "elb.ap-southeast-7.amazonaws.com": "Z054363131YWATEMWRG5L", + "elb.eu-central-1.amazonaws.com": "Z3F0SRJ5LGBH90", + "elb.eu-central-2.amazonaws.com": "Z02239872DOALSIDCX66S", + "elb.eu-west-1.amazonaws.com": "Z2IFOLAFXWLO4F", + "elb.eu-west-2.amazonaws.com": "ZD4D7Y8KGAS4G", + "elb.eu-west-3.amazonaws.com": "Z1CMS0P5QUZ6D5", + "elb.eu-north-1.amazonaws.com": "Z1UDT6IFJ4EJM", + "elb.eu-south-1.amazonaws.com": "Z23146JA1KNAFP", + "elb.eu-south-2.amazonaws.com": "Z1011216NVTVYADP1SSV", + "elb.sa-east-1.amazonaws.com": "ZTK26PT1VY4CU", + "elb.cn-north-1.amazonaws.com.cn": "Z3QFB96KMJ7ED6", + "elb.cn-northwest-1.amazonaws.com.cn": "ZQEIKTCZ8352D", + "elb.us-gov-west-1.amazonaws.com": "ZMG1MZ2THAWF1", + "elb.us-gov-east-1.amazonaws.com": "Z1ZSMQQ6Q24QQ8", + "elb.mx-central-1.amazonaws.com": "Z02031231H3ID6HYJ9A7U", + "elb.me-central-1.amazonaws.com": "Z00282643NTTLPANJJG2P", + "elb.me-south-1.amazonaws.com": "Z3QSRYVP46NYYV", + "elb.af-south-1.amazonaws.com": "Z203XCE67M25HM", + "elb.il-central-1.amazonaws.com": "Z0313266YDI6ZRHTGQY4", +} + +func route53CanonicalHostedZone(hostname string) string { + trimmed := strings.TrimSuffix(strings.ToLower(hostname), ".") + parts := strings.Split(trimmed, ".") + for i := len(parts) - 2; i >= 0; i-- { + if zone, ok := route53CanonicalHostedZones[strings.Join(parts[i:], ".")]; ok { + return zone + } + } + return "" +} + +func normalizeRoute53AliasDNSName(hostname string) string { + trimmed := strings.TrimSuffix(hostname, ".") + return trimmed + "." +} diff --git a/internal/go/providers/route53/conversion/handler.go b/internal/go/providers/route53/conversion/handler.go new file mode 100644 index 0000000..09582b4 --- /dev/null +++ b/internal/go/providers/route53/conversion/handler.go @@ -0,0 +1,212 @@ +package conversion + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + 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" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + GroupName = "endpoint.route53.dns.appthrust.io" + Version = "v1alpha1" + Resource = "endpointrecordsetconversions" +) + +type Handler struct{} + +func NewHandler() *Handler { + return &Handler{} +} + +func (h *Handler) Register(mux interface { + Register(path string, hook 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) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/apis/"+GroupName+"/"+Version: + writeJSON(w, http.StatusOK, metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "APIResourceList"}, + GroupVersion: GroupName + "/" + Version, + APIResources: []metav1.APIResource{{ + Name: Resource, + Namespaced: false, + Kind: "EndpointRecordSetConversion", + Verbs: []string{"create"}, + }}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/apis/"+GroupName+"/"+Version+"/"+Resource: + h.createConversion(w, r) + default: + http.NotFound(w, r) + } +} + +func (h *Handler) createConversion(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var conversion endpointconversionv1alpha1.EndpointRecordSetConversion + if err := json.NewDecoder(r.Body).Decode(&conversion); err != nil { + writeJSON(w, http.StatusBadRequest, failureConversion("", "MalformedRequest", "request body is malformed: "+err.Error(), false)) + return + } + response := endpointconversionv1alpha1.EndpointRecordSetConversion{ + TypeMeta: metav1.TypeMeta{ + APIVersion: GroupName + "/" + Version, + Kind: "EndpointRecordSetConversion", + }, + ObjectMeta: conversion.ObjectMeta, + Spec: conversion.Spec, + } + response.Status.UID = conversion.Spec.UID + fragments, result := convertEndpoint(conversion.Spec.Input) + response.Status.Result = result + if len(fragments) > 0 { + response.Status.Output = &endpointv1alpha1.EndpointRecordSetConversionOutput{Fragments: fragments} + } + writeJSON(w, http.StatusCreated, response) +} + +func convertEndpoint(input endpointv1alpha1.EndpointRecordSetConversionInput) ([]endpointv1alpha1.RecordSetSpecFragment, endpointconversionv1alpha1.EndpointRecordSetConversionResult) { + if input.Name == "" { + return nil, failureResult("InvalidInput", "input.name is required", false) + } + if len(input.Targets) == 0 { + return nil, failureResult("InvalidInput", "input.targets must not be empty", false) + } + var hostnameTargets []string + var ipv4 []string + var ipv6 []string + for _, target := range input.Targets { + value := strings.TrimSuffix(target.Value, ".") + switch target.Type { + case endpointv1alpha1.EndpointTargetTypeHostname: + hostnameTargets = appendUnique(hostnameTargets, value) + case endpointv1alpha1.EndpointTargetTypeIPAddress: + ip := net.ParseIP(value) + if ip == nil { + return nil, failureResult("InvalidInput", fmt.Sprintf("target %q is not a valid IP address", target.Value), false) + } + if ip.To4() != nil { + ipv4 = appendUnique(ipv4, value) + } else { + ipv6 = appendUnique(ipv6, value) + } + default: + return nil, failureResult("InvalidInput", "unsupported target type "+string(target.Type), false) + } + } + if len(hostnameTargets) > 0 && (len(ipv4) > 0 || len(ipv6) > 0) { + return nil, failureResult("UnsupportedTarget", "Route 53 endpoint conversion does not support mixed hostname and IP targets", false) + } + if len(hostnameTargets) > 1 { + return nil, failureResult("UnsupportedTarget", "multiple hostname targets require routing policy, which is not supported yet", false) + } + if len(hostnameTargets) == 1 { + return route53AliasFragments(input.Name, hostnameTargets[0]) + } + fragments := make([]endpointv1alpha1.RecordSetSpecFragment, 0, 2) + ttl := int32(300) + if len(ipv4) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{ + Type: endpointv1alpha1.EndpointRecordSetTypeA, + Name: input.Name, + TTL: &ttl, + A: &dnsv1alpha1.ARecordSet{Addresses: ipv4}, + }) + } + if len(ipv6) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{ + Type: endpointv1alpha1.EndpointRecordSetTypeAAAA, + Name: input.Name, + TTL: &ttl, + AAAA: &dnsv1alpha1.AAAARecordSet{Addresses: ipv6}, + }) + } + if len(fragments) == 0 { + return nil, failureResult("UnsupportedTarget", "no supported Route 53 target was found", false) + } + return fragments, successResult("Converted", "converted endpoint targets to Route 53 record sets") +} + +func route53AliasFragments(name, targetValue string) ([]endpointv1alpha1.RecordSetSpecFragment, endpointconversionv1alpha1.EndpointRecordSetConversionResult) { + target := normalizeRoute53AliasDNSName(targetValue) + hostedZoneID := route53CanonicalHostedZone(target) + if hostedZoneID == "" { + return nil, failureResult("UnsupportedTarget", fmt.Sprintf("Route 53 alias hosted zone ID is unknown for endpoint target %q", targetValue), false) + } + options, err := json.Marshal(route53v1alpha1.Route53RecordSetOptions{ + Alias: &route53v1alpha1.Route53AliasTarget{ + DNSName: target, + HostedZoneID: hostedZoneID, + EvaluateTargetHealth: false, + }, + }) + if err != nil { + return nil, failureResult("InternalError", "failed to encode Route 53 alias options: "+err.Error(), true) + } + a := endpointv1alpha1.RecordSetSpecFragment{ + Type: endpointv1alpha1.EndpointRecordSetTypeA, + Name: name, + Options: runtime.RawExtension{Raw: options}, + } + aaaa := a + aaaa.Type = endpointv1alpha1.EndpointRecordSetTypeAAAA + return []endpointv1alpha1.RecordSetSpecFragment{a, aaaa}, successResult("Converted", "converted hostname endpoint target to Route 53 alias A and AAAA record sets") +} + +func appendUnique(items []string, item string) []string { + for _, existing := range items { + if existing == item { + return items + } + } + return append(items, item) +} + +func successResult(reason, message string) endpointconversionv1alpha1.EndpointRecordSetConversionResult { + return endpointconversionv1alpha1.EndpointRecordSetConversionResult{ + Status: "Success", + Reason: reason, + Message: message, + } +} + +func failureResult(reason, message string, retryable bool) endpointconversionv1alpha1.EndpointRecordSetConversionResult { + return endpointconversionv1alpha1.EndpointRecordSetConversionResult{ + Status: "Failure", + Reason: reason, + Message: message, + Retryable: retryable, + } +} + +func failureConversion(uid, reason, message string, retryable bool) endpointconversionv1alpha1.EndpointRecordSetConversion { + return endpointconversionv1alpha1.EndpointRecordSetConversion{ + TypeMeta: metav1.TypeMeta{ + APIVersion: GroupName + "/" + Version, + Kind: "EndpointRecordSetConversion", + }, + Status: endpointconversionv1alpha1.EndpointRecordSetConversionStatus{ + UID: uid, + Result: failureResult(reason, message, retryable), + }, + } +} + +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/route53/conversion/handler_test.go b/internal/go/providers/route53/conversion/handler_test.go new file mode 100644 index 0000000..0b34cc1 --- /dev/null +++ b/internal/go/providers/route53/conversion/handler_test.go @@ -0,0 +1,112 @@ +package conversion + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHandlerConvertsELBHostnameToAliasAAndAAAA(t *testing.T) { + review := endpointconversionv1alpha1.EndpointRecordSetConversion{ + TypeMeta: metav1.TypeMeta{APIVersion: GroupName + "/" + Version, Kind: "EndpointRecordSetConversion"}, + Spec: endpointconversionv1alpha1.EndpointRecordSetConversionSpec{ + UID: "request-1", + Input: endpointv1alpha1.EndpointRecordSetConversionInput{ + Hostname: "api.example.com", + Name: "api", + Zone: endpointv1alpha1.EndpointRecordSetConversionZone{DomainName: "example.com"}, + Targets: []endpointv1alpha1.EndpointTarget{{ + Type: endpointv1alpha1.EndpointTargetTypeHostname, + Value: "k8s-public-123456.ap-northeast-1.elb.amazonaws.com", + }}, + }, + }, + } + body, err := json.Marshal(review) + if err != nil { + t.Fatalf("marshal review: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/apis/"+GroupName+"/"+Version+"/"+Resource, bytes.NewReader(body)) + response := httptest.NewRecorder() + + NewHandler().ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var got endpointconversionv1alpha1.EndpointRecordSetConversion + if err := json.NewDecoder(response.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Status.UID != "request-1" { + t.Fatalf("uid = %q, want request-1", got.Status.UID) + } + if got.Status.Result.Status != "Success" { + t.Fatalf("result = %#v, want success", got.Status.Result) + } + if got.Status.Output == nil || len(got.Status.Output.Fragments) != 2 { + t.Fatalf("fragments = %#v, want 2", got.Status.Output) + } + if got.Status.Output.Fragments[0].Type != endpointv1alpha1.EndpointRecordSetTypeA || got.Status.Output.Fragments[1].Type != endpointv1alpha1.EndpointRecordSetTypeAAAA { + t.Fatalf("record set types = %q, %q", got.Status.Output.Fragments[0].Type, got.Status.Output.Fragments[1].Type) + } + if got.Status.Output.Fragments[0].TTL != nil || got.Status.Output.Fragments[0].CNAME != nil { + t.Fatalf("A fragment = %#v, want no ttl and no cname", got.Status.Output.Fragments[0]) + } + var options route53v1alpha1.Route53RecordSetOptions + if err := json.Unmarshal(got.Status.Output.Fragments[0].Options.Raw, &options); err != nil { + t.Fatalf("decode options: %v", err) + } + if options.Alias == nil || + options.Alias.DNSName != "k8s-public-123456.ap-northeast-1.elb.amazonaws.com." || + options.Alias.HostedZoneID != "Z14GRHDCWA56QT" || + options.Alias.EvaluateTargetHealth { + t.Fatalf("alias = %#v", options.Alias) + } +} + +func TestHandlerRejectsUnknownHostnameTarget(t *testing.T) { + review := endpointconversionv1alpha1.EndpointRecordSetConversion{ + Spec: endpointconversionv1alpha1.EndpointRecordSetConversionSpec{ + UID: "request-1", + Input: endpointv1alpha1.EndpointRecordSetConversionInput{ + Hostname: "api.example.com", + Name: "api", + Zone: endpointv1alpha1.EndpointRecordSetConversionZone{DomainName: "example.com"}, + Targets: []endpointv1alpha1.EndpointTarget{{ + Type: endpointv1alpha1.EndpointTargetTypeHostname, + Value: "lb.example.net", + }}, + }, + }, + } + body, err := json.Marshal(review) + if err != nil { + t.Fatalf("marshal review: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/apis/"+GroupName+"/"+Version+"/"+Resource, bytes.NewReader(body)) + response := httptest.NewRecorder() + + NewHandler().ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var got endpointconversionv1alpha1.EndpointRecordSetConversion + if err := json.NewDecoder(response.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Status.Result.Status != "Failure" || got.Status.Result.Reason != "UnsupportedTarget" { + t.Fatalf("result = %#v, want UnsupportedTarget failure", got.Status.Result) + } + if got.Status.Output != nil && len(got.Status.Output.Fragments) != 0 { + t.Fatalf("fragments = %d, want 0", len(got.Status.Output.Fragments)) + } +} diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/doc.go b/pkg/go/api/endpoint/conversion/v1alpha1/doc.go new file mode 100644 index 0000000..76b61a8 --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// Package v1alpha1 contains Endpoint RecordSet Conversion API types. +// +kubebuilder:object:generate=true +// +kubebuilder:skip +// +groupName=endpoint.dns.appthrust.io +package v1alpha1 diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/endpointrecordsetconversion_types.go b/pkg/go/api/endpoint/conversion/v1alpha1/endpointrecordsetconversion_types.go new file mode 100644 index 0000000..47f77ab --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/endpointrecordsetconversion_types.go @@ -0,0 +1,71 @@ +package v1alpha1 + +import ( + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EndpointRecordSetConversion is a create-only aggregated API request. +// It is served by app providers and is not installed as a CRD. +// +kubebuilder:object:root=true +type EndpointRecordSetConversion struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EndpointRecordSetConversionSpec `json:"spec,omitempty"` + Status EndpointRecordSetConversionStatus `json:"status,omitempty"` +} + +type EndpointRecordSetConversionSpec struct { + // UID correlates request and response. + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid"` + + // Input is the provider-neutral endpoint conversion input. + Input endpointv1alpha1.EndpointRecordSetConversionInput `json:"input"` +} + +type EndpointRecordSetConversionStatus struct { + // UID echoes spec.uid. + // +kubebuilder:validation:MinLength=1 + UID string `json:"uid"` + + // Result is the provider conversion result. + Result EndpointRecordSetConversionResult `json:"result"` + + // Output is the provider conversion output. + // +optional + Output *endpointv1alpha1.EndpointRecordSetConversionOutput `json:"output,omitempty"` + + // Conditions describe provider conversion. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type EndpointRecordSetConversionResult struct { + // Status is Success or Failure. + // +kubebuilder:validation:Enum=Success;Failure + Status string `json:"status"` + + // Reason is a machine-readable result reason. + // +kubebuilder:validation:MinLength=1 + Reason string `json:"reason"` + + // Message is a human-readable result message. + // +optional + Message string `json:"message,omitempty"` + + // Retryable tells callers whether a failed request may succeed later. + // +optional + Retryable bool `json:"retryable,omitempty"` +} + +// EndpointRecordSetConversionList contains a list of EndpointRecordSetConversion. +// +kubebuilder:object:root=true +type EndpointRecordSetConversionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EndpointRecordSetConversion `json:"items"` +} diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/groupversion_info.go b/pkg/go/api/endpoint/conversion/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..3b4acd8 --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/groupversion_info.go @@ -0,0 +1,14 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = "endpoint.dns.appthrust.io" + +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +var SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + +var AddToScheme = SchemeBuilder.AddToScheme diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/register.go b/pkg/go/api/endpoint/conversion/v1alpha1/register.go new file mode 100644 index 0000000..ea44a06 --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/register.go @@ -0,0 +1,16 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes( + SchemeGroupVersion, + &EndpointRecordSetConversion{}, + &EndpointRecordSetConversionList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/scheme_test.go b/pkg/go/api/endpoint/conversion/v1alpha1/scheme_test.go new file mode 100644 index 0000000..bc4114e --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/scheme_test.go @@ -0,0 +1,22 @@ +package v1alpha1_test + +import ( + "testing" + + endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestAddToSchemeRegistersEndpointRecordSetConversion(t *testing.T) { + scheme := runtime.NewScheme() + if err := endpointconversionv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme returned error: %v", err) + } + obj, err := scheme.New(endpointconversionv1alpha1.SchemeGroupVersion.WithKind("EndpointRecordSetConversion")) + if err != nil { + t.Fatalf("EndpointRecordSetConversion is not registered: %v", err) + } + if _, ok := obj.(*endpointconversionv1alpha1.EndpointRecordSetConversion); !ok { + t.Fatalf("kind EndpointRecordSetConversion created %T", obj) + } +} diff --git a/pkg/go/api/endpoint/conversion/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/endpoint/conversion/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..3fd8d18 --- /dev/null +++ b/pkg/go/api/endpoint/conversion/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,129 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversion) DeepCopyInto(out *EndpointRecordSetConversion) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversion. +func (in *EndpointRecordSetConversion) DeepCopy() *EndpointRecordSetConversion { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversion) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointRecordSetConversion) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionList) DeepCopyInto(out *EndpointRecordSetConversionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EndpointRecordSetConversion, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionList. +func (in *EndpointRecordSetConversionList) DeepCopy() *EndpointRecordSetConversionList { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointRecordSetConversionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionResult) DeepCopyInto(out *EndpointRecordSetConversionResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionResult. +func (in *EndpointRecordSetConversionResult) DeepCopy() *EndpointRecordSetConversionResult { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionSpec) DeepCopyInto(out *EndpointRecordSetConversionSpec) { + *out = *in + in.Input.DeepCopyInto(&out.Input) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionSpec. +func (in *EndpointRecordSetConversionSpec) DeepCopy() *EndpointRecordSetConversionSpec { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionStatus) DeepCopyInto(out *EndpointRecordSetConversionStatus) { + *out = *in + out.Result = in.Result + if in.Output != nil { + in, out := &in.Output, &out.Output + *out = new(endpointv1alpha1.EndpointRecordSetConversionOutput) + (*in).DeepCopyInto(*out) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionStatus. +func (in *EndpointRecordSetConversionStatus) DeepCopy() *EndpointRecordSetConversionStatus { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/go/api/endpoint/v1alpha1/doc.go b/pkg/go/api/endpoint/v1alpha1/doc.go new file mode 100644 index 0000000..13a594d --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Package v1alpha1 contains endpoint dns-api application resource types. +// +kubebuilder:object:generate=true +// +groupName=endpoint.dns.appthrust.io +package v1alpha1 diff --git a/pkg/go/api/endpoint/v1alpha1/endpointprovider_capability_types.go b/pkg/go/api/endpoint/v1alpha1/endpointprovider_capability_types.go new file mode 100644 index 0000000..d28edd7 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/endpointprovider_capability_types.go @@ -0,0 +1,50 @@ +package v1alpha1 + +import ( + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EndpointProviderCapability declares endpoint app support for one Core Provider version. +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider.name` +// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.spec.provider.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type EndpointProviderCapability struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EndpointProviderCapabilitySpec `json:"spec,omitempty"` +} + +type EndpointProviderCapabilitySpec struct { + // Provider references a Core Provider version. + Provider dnsv1alpha1.ProviderReference `json:"provider"` + + // Conversion declares the aggregated API resource for provider-specific + // endpoint RecordSet conversion. + Conversion EndpointRecordSetConversionAPI `json:"conversion"` +} + +type EndpointRecordSetConversionAPI struct { + // Group is the API group that serves EndpointRecordSetConversion. + // +kubebuilder:validation:MinLength=1 + Group string `json:"group"` + + // Version defaults to spec.provider.version when omitted. + // +optional + Version string `json:"version,omitempty"` + + // Resource is the plural resource name, normally endpointrecordsetconversions. + // +kubebuilder:validation:MinLength=1 + Resource string `json:"resource"` +} + +// EndpointProviderCapabilityList contains a list of EndpointProviderCapability. +// +kubebuilder:object:root=true +type EndpointProviderCapabilityList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EndpointProviderCapability `json:"items"` +} diff --git a/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go b/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go new file mode 100644 index 0000000..5bd16a4 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go @@ -0,0 +1,221 @@ +package v1alpha1 + +import ( + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// EndpointTargetType is the endpoint target address type. +// +kubebuilder:validation:Enum=Hostname;IPAddress +type EndpointTargetType string + +const ( + EndpointTargetTypeHostname EndpointTargetType = "Hostname" + EndpointTargetTypeIPAddress EndpointTargetType = "IPAddress" +) + +// EndpointTarget is one provider-neutral endpoint address. +type EndpointTarget struct { + // Type is the target address type. + Type EndpointTargetType `json:"type"` + + // Value is the target address value. + // +kubebuilder:validation:MinLength=1 + Value string `json:"value"` +} + +// EndpointRecordSet is a provider-neutral endpoint publishing intent. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Hostnames",type=integer,JSONPath=`.status.hostnameCount` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type EndpointRecordSet struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EndpointRecordSetSpec `json:"spec,omitempty"` + Status EndpointRecordSetStatus `json:"status,omitempty"` +} + +type EndpointRecordSetSpec struct { + // Hostnames are fully qualified DNS hostnames to publish. + // +kubebuilder:validation:MinItems=1 + // +listType=set + Hostnames []string `json:"hostnames"` + + // Targets are endpoint addresses that the hostnames should publish. + // +kubebuilder:validation:MinItems=1 + // +listType=atomic + Targets []EndpointTarget `json:"targets"` +} + +type EndpointRecordSetStatus struct { + // ObservedGeneration is the generation observed by the endpoint controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // HostnameCount is the number of hostname status entries. + // +optional + HostnameCount int32 `json:"hostnameCount,omitempty"` + + // Hostnames contains per-hostname resolution and generated RecordSet status. + // +optional + // +listType=map + // +listMapKey=hostname + Hostnames []EndpointRecordSetHostnameStatus `json:"hostnames,omitempty"` + + // Conditions summarizes endpoint record set reconciliation. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type EndpointRecordSetHostnameStatus struct { + // Hostname is the DNS hostname from spec.hostnames. + // +kubebuilder:validation:MinLength=1 + Hostname string `json:"hostname"` + + // Zone is the selected Zone for this hostname. + // +optional + Zone *EndpointRecordSetZoneStatus `json:"zone,omitempty"` + + // RecordSets contains generated RecordSet outputs for this hostname. + // +optional + // +listType=map + // +listMapKey=name + // +listMapKey=type + RecordSets []EndpointRecordSetGeneratedRecordSetStatus `json:"recordSets,omitempty"` + + // Conditions describes per-hostname reconciliation. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type EndpointRecordSetZoneStatus struct { + // Ref points to the selected Zone. + Ref dnsv1alpha1.ObjectReference `json:"ref"` + + // DomainName is the selected Zone domain name. + DomainName string `json:"domainName"` + + // Provider is the selected Zone provider. + Provider dnsv1alpha1.ProviderReference `json:"provider"` +} + +type EndpointRecordSetGeneratedRecordSetStatus struct { + // Ref points to the generated Core RecordSet. + Ref dnsv1alpha1.ObjectReference `json:"ref"` + + // Name is the zone-relative DNS owner name. + Name string `json:"name"` + + // Type is the DNS record type. + Type EndpointRecordSetType `json:"type"` + + // Fragment is the Provider-converted RecordSet.spec fragment. + // +optional + Fragment *RecordSetSpecFragment `json:"fragment,omitempty"` + + // Conditions mirrors generated RecordSet conditions. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// EndpointRecordSetList contains a list of EndpointRecordSet. +// +kubebuilder:object:root=true +type EndpointRecordSetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EndpointRecordSet `json:"items"` +} + +// EndpointRecordSetType is the DNS record type supported by endpoint apps. +// +kubebuilder:validation:Enum=A;AAAA;CNAME +type EndpointRecordSetType string + +const ( + EndpointRecordSetTypeA EndpointRecordSetType = "A" + EndpointRecordSetTypeAAAA EndpointRecordSetType = "AAAA" + EndpointRecordSetTypeCNAME EndpointRecordSetType = "CNAME" +) + +func (t EndpointRecordSetType) DNSRecordType() dnsv1alpha1.RecordType { + return dnsv1alpha1.RecordType(t) +} + +// EndpointRecordSetConversionInput is the provider-neutral input passed to an +// endpoint Provider conversion API for one hostname and one selected Zone. +type EndpointRecordSetConversionInput struct { + // Hostname is the fully qualified DNS hostname being converted. + // +kubebuilder:validation:MinLength=1 + Hostname string `json:"hostname"` + + // Name is the zone-relative DNS owner name in the selected Zone. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Zone contains selected Zone context needed by Provider conversion. + Zone EndpointRecordSetConversionZone `json:"zone"` + + // Targets are endpoint addresses that the hostname should publish. + // +kubebuilder:validation:MinItems=1 + // +listType=atomic + Targets []EndpointTarget `json:"targets"` +} + +type EndpointRecordSetConversionZone struct { + // DomainName is the selected Zone domain name. + // +kubebuilder:validation:MinLength=1 + DomainName string `json:"domainName"` +} + +type EndpointRecordSetConversionOutput struct { + // Fragments are Provider-converted RecordSet.spec fragments. + // +kubebuilder:validation:MinItems=1 + // +listType=atomic + Fragments []RecordSetSpecFragment `json:"fragments"` +} + +// RecordSetSpecFragment is the Provider-converted RecordSet.spec shape returned +// by Endpoint RecordSet Conversion APIs. The endpoint controller adds zoneRef +// and provider when it creates the final dns.appthrust.io RecordSet. +// +kubebuilder:validation:XValidation:rule="self.type != 'A' || has(self.a) || has(self.options)",message="a or options is required when type is A" +// +kubebuilder:validation:XValidation:rule="self.type != 'AAAA' || has(self.aaaa) || has(self.options)",message="aaaa or options is required when type is AAAA" +// +kubebuilder:validation:XValidation:rule="self.type != 'CNAME' || has(self.cname)",message="cname is required when type is CNAME" +type RecordSetSpecFragment struct { + // Type is the DNS record type. + Type EndpointRecordSetType `json:"type"` + + // Name is the relative DNS owner name in the Zone. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // TTL is the record TTL in seconds. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2147483647 + // +optional + TTL *int32 `json:"ttl,omitempty"` + + // A defines a standard A record body. + // +optional + A *dnsv1alpha1.ARecordSet `json:"a,omitempty"` + + // AAAA defines a standard AAAA record body. + // +optional + AAAA *dnsv1alpha1.AAAARecordSet `json:"aaaa,omitempty"` + + // CNAME defines a standard CNAME record body. + // +optional + CNAME *dnsv1alpha1.CNAMERecordSet `json:"cname,omitempty"` + + // Options contains provider-specific record options. + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + Options runtime.RawExtension `json:"options,omitempty"` +} diff --git a/pkg/go/api/endpoint/v1alpha1/groupversion_info.go b/pkg/go/api/endpoint/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..3b4acd8 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/groupversion_info.go @@ -0,0 +1,14 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = "endpoint.dns.appthrust.io" + +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +var SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + +var AddToScheme = SchemeBuilder.AddToScheme diff --git a/pkg/go/api/endpoint/v1alpha1/register.go b/pkg/go/api/endpoint/v1alpha1/register.go new file mode 100644 index 0000000..47c58f0 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/register.go @@ -0,0 +1,18 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes( + SchemeGroupVersion, + &EndpointRecordSet{}, + &EndpointRecordSetList{}, + &EndpointProviderCapability{}, + &EndpointProviderCapabilityList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/go/api/endpoint/v1alpha1/scheme_test.go b/pkg/go/api/endpoint/v1alpha1/scheme_test.go new file mode 100644 index 0000000..5952710 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/scheme_test.go @@ -0,0 +1,31 @@ +package v1alpha1_test + +import ( + "testing" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestAddToSchemeRegistersEndpointTypes(t *testing.T) { + scheme := runtime.NewScheme() + if err := endpointv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme returned error: %v", err) + } + + cases := map[string]func(runtime.Object) bool{ + "EndpointProviderCapability": func(obj runtime.Object) bool { + _, ok := obj.(*endpointv1alpha1.EndpointProviderCapability) + return ok + }, + } + for kind, matches := range cases { + obj, err := scheme.New(endpointv1alpha1.SchemeGroupVersion.WithKind(kind)) + if err != nil { + t.Fatalf("%s is not registered: %v", kind, err) + } + if !matches(obj) { + t.Fatalf("kind %s created %T", kind, obj) + } + } +} diff --git a/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..1717560 --- /dev/null +++ b/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,402 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointProviderCapability) DeepCopyInto(out *EndpointProviderCapability) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointProviderCapability. +func (in *EndpointProviderCapability) DeepCopy() *EndpointProviderCapability { + if in == nil { + return nil + } + out := new(EndpointProviderCapability) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointProviderCapability) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointProviderCapabilityList) DeepCopyInto(out *EndpointProviderCapabilityList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EndpointProviderCapability, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointProviderCapabilityList. +func (in *EndpointProviderCapabilityList) DeepCopy() *EndpointProviderCapabilityList { + if in == nil { + return nil + } + out := new(EndpointProviderCapabilityList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointProviderCapabilityList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointProviderCapabilitySpec) DeepCopyInto(out *EndpointProviderCapabilitySpec) { + *out = *in + out.Provider = in.Provider + out.Conversion = in.Conversion +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointProviderCapabilitySpec. +func (in *EndpointProviderCapabilitySpec) DeepCopy() *EndpointProviderCapabilitySpec { + if in == nil { + return nil + } + out := new(EndpointProviderCapabilitySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSet) DeepCopyInto(out *EndpointRecordSet) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSet. +func (in *EndpointRecordSet) DeepCopy() *EndpointRecordSet { + if in == nil { + return nil + } + out := new(EndpointRecordSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointRecordSet) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionAPI) DeepCopyInto(out *EndpointRecordSetConversionAPI) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionAPI. +func (in *EndpointRecordSetConversionAPI) DeepCopy() *EndpointRecordSetConversionAPI { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionAPI) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionInput) DeepCopyInto(out *EndpointRecordSetConversionInput) { + *out = *in + out.Zone = in.Zone + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]EndpointTarget, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionInput. +func (in *EndpointRecordSetConversionInput) DeepCopy() *EndpointRecordSetConversionInput { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionInput) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionOutput) DeepCopyInto(out *EndpointRecordSetConversionOutput) { + *out = *in + if in.Fragments != nil { + in, out := &in.Fragments, &out.Fragments + *out = make([]RecordSetSpecFragment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionOutput. +func (in *EndpointRecordSetConversionOutput) DeepCopy() *EndpointRecordSetConversionOutput { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionOutput) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetConversionZone) DeepCopyInto(out *EndpointRecordSetConversionZone) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetConversionZone. +func (in *EndpointRecordSetConversionZone) DeepCopy() *EndpointRecordSetConversionZone { + if in == nil { + return nil + } + out := new(EndpointRecordSetConversionZone) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetGeneratedRecordSetStatus) DeepCopyInto(out *EndpointRecordSetGeneratedRecordSetStatus) { + *out = *in + out.Ref = in.Ref + if in.Fragment != nil { + in, out := &in.Fragment, &out.Fragment + *out = new(RecordSetSpecFragment) + (*in).DeepCopyInto(*out) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetGeneratedRecordSetStatus. +func (in *EndpointRecordSetGeneratedRecordSetStatus) DeepCopy() *EndpointRecordSetGeneratedRecordSetStatus { + if in == nil { + return nil + } + out := new(EndpointRecordSetGeneratedRecordSetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetHostnameStatus) DeepCopyInto(out *EndpointRecordSetHostnameStatus) { + *out = *in + if in.Zone != nil { + in, out := &in.Zone, &out.Zone + *out = new(EndpointRecordSetZoneStatus) + **out = **in + } + if in.RecordSets != nil { + in, out := &in.RecordSets, &out.RecordSets + *out = make([]EndpointRecordSetGeneratedRecordSetStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetHostnameStatus. +func (in *EndpointRecordSetHostnameStatus) DeepCopy() *EndpointRecordSetHostnameStatus { + if in == nil { + return nil + } + out := new(EndpointRecordSetHostnameStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetList) DeepCopyInto(out *EndpointRecordSetList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EndpointRecordSet, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetList. +func (in *EndpointRecordSetList) DeepCopy() *EndpointRecordSetList { + if in == nil { + return nil + } + out := new(EndpointRecordSetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EndpointRecordSetList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetSpec) DeepCopyInto(out *EndpointRecordSetSpec) { + *out = *in + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]EndpointTarget, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetSpec. +func (in *EndpointRecordSetSpec) DeepCopy() *EndpointRecordSetSpec { + if in == nil { + return nil + } + out := new(EndpointRecordSetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetStatus) DeepCopyInto(out *EndpointRecordSetStatus) { + *out = *in + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]EndpointRecordSetHostnameStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetStatus. +func (in *EndpointRecordSetStatus) DeepCopy() *EndpointRecordSetStatus { + if in == nil { + return nil + } + out := new(EndpointRecordSetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointRecordSetZoneStatus) DeepCopyInto(out *EndpointRecordSetZoneStatus) { + *out = *in + out.Ref = in.Ref + out.Provider = in.Provider +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetZoneStatus. +func (in *EndpointRecordSetZoneStatus) DeepCopy() *EndpointRecordSetZoneStatus { + if in == nil { + return nil + } + out := new(EndpointRecordSetZoneStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointTarget) DeepCopyInto(out *EndpointTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointTarget. +func (in *EndpointTarget) DeepCopy() *EndpointTarget { + if in == nil { + return nil + } + out := new(EndpointTarget) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordSetSpecFragment) DeepCopyInto(out *RecordSetSpecFragment) { + *out = *in + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(int32) + **out = **in + } + if in.A != nil { + in, out := &in.A, &out.A + *out = new(dnsv1alpha1.ARecordSet) + (*in).DeepCopyInto(*out) + } + if in.AAAA != nil { + in, out := &in.AAAA, &out.AAAA + *out = new(dnsv1alpha1.AAAARecordSet) + (*in).DeepCopyInto(*out) + } + if in.CNAME != nil { + in, out := &in.CNAME, &out.CNAME + *out = new(dnsv1alpha1.CNAMERecordSet) + **out = **in + } + in.Options.DeepCopyInto(&out.Options) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordSetSpecFragment. +func (in *RecordSetSpecFragment) DeepCopy() *RecordSetSpecFragment { + if in == nil { + return nil + } + out := new(RecordSetSpecFragment) + in.DeepCopyInto(out) + return out +}