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 new file mode 100644 index 0000000..1965a49 --- /dev/null +++ b/docs/design/endpoint-api.md @@ -0,0 +1,172 @@ +# 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 create `EndpointRecordSet` resources. The endpoint controller resolves those resources into Core `RecordSet` resources. + +## Endpoint RecordSet + +`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. + +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` +- `ttl` +- `a`, `aaaa`, or `cname` +- `options` +- `adoption` + +Those fields are selected or produced later by the endpoint controller and Provider conversion API. + +## Endpoint Controller + +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 Apps. + +Example: + +```yaml +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: route53-v1alpha1 +spec: + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.route53.dns.appthrust.io + 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.conversion` identifies the provider-specific create-only conversion API. The effective conversion GVR is: + +```text +group = spec.conversion.group +version = spec.conversion.version, or spec.provider.version when omitted +resource = spec.conversion.resource +``` + +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`. + +## Endpoint RecordSet Conversion 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 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. + +Example: + +```yaml +apiVersion: endpoint.route53.dns.appthrust.io/v1alpha1 +kind: EndpointRecordSetConversion +spec: + uid: 5f0f0b5e-0000-0000-0000-000000000001 + input: + hostname: api.example.com + name: api + 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: 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 +``` + +`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. + +`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`. + +`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: + +- `EndpointRecordSetConversionUnsupported`: no matching `EndpointProviderCapability` or no provider-supported output. +- `EndpointProviderCapabilityConflict`: multiple matching `EndpointProviderCapability` resources exist for the same Core Provider name and version. +- `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 + +`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 + +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..c85d406 --- /dev/null +++ b/docs/design/gateway-api-integration.md @@ -0,0 +1,207 @@ +# Gateway API Endpoint Integration + +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 are future work. + +## Architecture + +```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 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. + +## Responsibilities + +The Gateway controller: + +- 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 Gateway controller does not: + +- select `Zone`; +- select Core `Provider`; +- decide `A`, `AAAA`, or `CNAME`; +- decide TTL; +- call provider conversion APIs; +- create `RecordSet`; +- write provider-specific `adoption`. + +The endpoint controller: + +- 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. + +Provider controllers still make final provider-side acceptance and programming decisions. + +## EndpointRecordSet Creation + +The Gateway controller creates one `EndpointRecordSet` per `HTTPRoute` parent/listener binding. + +For a route: + +```yaml +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 +``` + +and a Gateway: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + namespace: platform + name: public +spec: + listeners: + - name: https + protocol: HTTPS + port: 443 +status: + addresses: + - type: Hostname + value: k8s-public-123.ap-northeast-1.elb.amazonaws.com +``` + +the controller creates: + +```yaml +apiVersion: endpoint.dns.appthrust.io/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: + hostnames: + - api.example.com + targets: + - type: Hostname + value: k8s-public-123.ap-northeast-1.elb.amazonaws.com +``` + +The generated name is deterministic from: + +```text +HTTPRoute namespace/name +Gateway namespace/name +listener name +``` + +Hostnames are not part of the object name, because changing route hostnames should update the same endpoint intent object rather than break ownership continuity. + +## Hostname Resolution + +For each accepted route/listener binding: + +- 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. + +Wildcard intersection chooses the concrete hostname when one side is concrete and the other side is wildcard. + +## Target Resolution + +Targets come from `Gateway.status.addresses`. + +Supported target types: + +- `Hostname` +- `IPAddress` + +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. + +## Parent Acceptance + +The initial behavior is conservative: DNS intent is generated only after every `HTTPRoute.spec.parentRefs[]` entry has an `Accepted=True` parent status. + +This avoids partial DNS publication for a route whose complete Gateway attachment is not settled. + +## Observability + +`HTTPRouteDNSReport` is not part of the design. Gateway-specific reporting would be a projection and is not needed for the initial implementation. + +Users inspect: + +- 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. + +`EndpointRecordSet.status` is the common observability surface for Gateway, Ingress, Service, and future source Apps. + +## Adoption and GitOps Restore + +The Gateway controller does not write adoption. `EndpointRecordSet.spec` does not contain adoption. + +Endpoint-based adoption and GitOps restore are not part of this design yet. Direct Core `RecordSet.spec.adoption` remains the supported adoption path. + +## Provider Conversion + +Gateway is provider-neutral. Provider-specific endpoint record shape belongs to the Endpoint Provider conversion API. + +For Route 53: + +- 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. + +The endpoint controller receives `RecordSetSpecFragment[]`, attaches `zoneRef` and `provider`, and applies Core `RecordSet` resources. + +## API Groups + +Common endpoint resources: + +```text +endpoint.dns.appthrust.io +``` + +Provider conversion implementation: + +```text +endpoint.route53.dns.appthrust.io +``` + +Gateway source controller labels: + +```text +gateway.endpoint.dns.appthrust.io/* +``` diff --git a/docs/design/overview.md b/docs/design/overview.md index 3e2c852..867575d 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 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. + ## 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. 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 +}