diff --git a/docs/contributing/unstructured-source.md b/docs/contributing/unstructured-source.md new file mode 100644 index 0000000000..1cfd7b5e6c --- /dev/null +++ b/docs/contributing/unstructured-source.md @@ -0,0 +1,614 @@ +# Unstructured Source + +Unstructured source provides a generic mechanism to manage DNS records in your favourite DNS provider supported by external-dns by watching for a user specified Kubernetes API to extract hostnames, targets, TTL, and record type information from a combination of well-known annotations and evaluating JSONPath expressions against the resource. + +* [Details](#details) +* [Usage](#usage) +* [RBAC Configuration](#rbac-configuration) +* [Demo](#demo) + * [Setup](#setup-steps) + * [Examples](#examples): + * `ConfigMap` + * [DNS A-record with multiple targets](#dns-a-record-with-multiple-targets-from-a-configmap) + * [Multiple DNS A-records](#multiple-dns-a-records-from-a-configmap) + * `Deployment` + * [DNS CNAME-record from annotations](#dns-cname-record-from-annotation-on-a-deployment) + * `Workload` (_namespace-scoped CRD_) + * [DNS A-record](#dns-a-record-from-a-namespace-scoped-crd) + * [Multiple DNS A-records](#multiple-dns-a-records-from-a-namespace-scoped-crd) + * [DNS A-record with multiple targets](#dns-a-record-with-multiple-targets-from-a-namespace-scoped-crd) + * `ClusterWorkload` (_cluster-scoped CRD_) + * [DNS A-record](#dns-a-record-a-cluster-scoped-crd) + * [Cleanup](#cleanup-steps) + +## Details + +The unstructured source is largely designed for API resources that have one hostname and one target (likely an IP address), but it is flexible enough to construct DNS entries if there are multiple hostnames and/or targets per API resource. The high-level summary is: + +* one DNS entry is created per hostname from a resource +* each DNS entry contains all of the targets from a resource + +For instance, let's say one API resource evaluates to having the following information: + +* 2 hostnames +* 1 target + +Two DNS entries will be created, one for each hostname, both pointing to the same target. Alternatively, there may be an API resource that evaluates to: + +* 2 hostnames +* 3 targets + +In this case, two DNS entries will be created, one of each hostname, and both entries will still point to the same, three targets. + +## Usage + +One can use Unstructured source by specifying `--source` flag with `unstructured` and specifying: + +| Flag | Description | +|------|-------------| +| `--unstructured-source-apiversion` | The API Version of the API resource. | +| `--unstructured-source-kind` | The Kind of the API resource. | +| `--unstructured-source-target-json-path` | A JSONPath expression executed against the API resource that must evaluate to a comma or space delimited string with one or more targets. This path must conform to the [JSONPath format](https://kubernetes.io/docs/reference/kubectl/jsonpath/). If this flag is omitted then the hostname value is derived from the annotation, `external-dns.alpha.kubernetes.io/target` | +| `--unstructured-source-hostname-json-path` | An optional, JSONPath expression executed against the API resource that must evaluate to a comma or space delimited string with one or more hostnames. This path must conform to the [JSONPath format](https://kubernetes.io/docs/reference/kubectl/jsonpath/). If this flag is omitted then the hostname value is derived from the annotation, `external-dns.alpha.kubernetes.io/hostname` | + +The TTL and record type are always derived from annotations: + +| Annotation | Default | +|------------|---------| +| `external-dns.alpha.kubernetes.io/ttl` | `0` | +| `external-dns.alpha.kubernetes.io/record-type` | `A` | + +For example: + +``` +$ build/external-dns \ + --source unstructured \ + --unstructured-source-apiversion v1 \ + --unstructured-source-kind Pod \ + --unstructured-source-target-json-path '{.status.podIP}' \ + --provider inmemory \ + --once \ + --dry-run +``` + +## RBAC Configuration + +If the Kubernetes cluster uses RBAC, the `external-dns` ClusterRole requires access to `get`, `watch`, and `list` the API resource configured with the Unstructured source. For example, if the Unstructured source is configured for `Pod` resources then the following RBAC is required: + +``` +- apiGroups: ["v1"] + resources: ["pods"] + verbs: ["get","watch","list"] +``` + +## Demo + +This section provides a nice little demo of the unstructured source with several examples. + +### Setup Steps + +1. Build the `external-dns` binary: + + ```shell + make build + ``` + +1. Use [Docker](https://www.docker.com) and [Kind](https://kind.sigs.k8s.io) to create a local, Kubernetes cluster: + + ```shell + kind create cluster + ``` + +1. Update the kubeconfig context to point to the Kind cluster so that the External DNS binary can access the cluster: + + ```shell + kubectl config set-context kind-kind + ``` + +1. Apply all of the CRDs required by the examples below: + + ```shell + $ kubectl apply -f "docs/contributing/unstructured-source/*-manifest.yaml" + customresourcedefinition.apiextensions.k8s.io/clusterworkloads.example.com created + customresourcedefinition.apiextensions.k8s.io/workloads.example.com created + ``` + +1. Apply the example resources: + + ```shell + $ kubectl apply -f "docs/contributing/unstructured-source/*-example.yaml" + clusterworkload.example.com/my-workload-1 created + configmap/my-workload-1 created + configmap/my-workload-2 created + deployment.apps/my-workload-1 created + workload.example.com/my-workload-1 created + workload.example.com/my-workload-2 created + workload.example.com/my-workload-3 created + ``` + +1. Several of the examples require patching a `status` sub-resource, which is not supported by `kubectl`. The following commands persist the access information for the Kind cluster to files may be used by `curl` in order to patch these `status` sub-resources: + + 1. Save the API endpoint: + + ```shell + kubectl config view --raw \ + -o jsonpath='{.clusters[?(@.name == "kind-kind")].cluster.server}' \ + >url.txt + ``` + + 1. Save the cluster's certification authority (CA): + + ```shell + kubectl config view --raw \ + -o jsonpath='{.clusters[?(@.name == "kind-kind")].cluster.certificate-authority-data}' | \ + { base64 -d 2>/dev/null || base64 -D; } \ + >ca.crt + ``` + + 1. Save the client's public certificate: + + ```shell + kubectl config view --raw \ + -o jsonpath='{.users[?(@.name == "kind-kind")].user.client-certificate-data}' | \ + { base64 -d 2>/dev/null || base64 -D; } \ + >client.crt + ``` + + 1. Save the client's private key: + + ```shell + kubectl config view --raw \ + -o jsonpath='{.users[?(@.name == "kind-kind")].user.client-key-data}' | \ + { base64 -d 2>/dev/null || base64 -D; } \ + >client.key + ``` + +After running all of the desired examples the command `kind delete cluster` may be used to clean up the local Kubernetes cluster. + +### Examples + +#### DNS A-Record with Multiple Targets from a ConfigMap + +This example realizes a single DNS A-record from a `ConfigMap` resource: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one three, A-record targets -- one IP4 addr and two IP6 addrs. + The endpoint's DNS name and TTL are derived from the annotations + below, and since no record type is specified, the default type, + A-record, is used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com + external-dns.alpha.kubernetes.io/ttl: 10m +data: + ip4-addrs: 1.2.3.4 + ip6-addrs: "2001:db8:0:1:1:1:1:1,2001:db8:0:1:1:1:1:2" +``` + +Run External DNS: + +```shell +build/external-dns \ + --annotation-filter="example=my-workload-1" \ + --source unstructured \ + --unstructured-source-apiversion v1 \ + --unstructured-source-kind ConfigMap \ + --unstructured-source-target-json-path '{.data.ip4-addrs} {.data.ip6-addrs}' \ + --provider inmemory \ + --once \ + --dry-run +``` + +The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + +```shell +INFO[0000] Unstructured source configured for namespace-scoped resource with kind "ConfigMap" in apiVersion "v1" in namespace "" +INFO[0000] resource="my-workload-1", hostnames=[my-workload-1.example.com], targets=[1.2.3.4 2001:db8:0:1:1:1:1:1 2001:db8:0:1:1:1:1:2], ttl=600, recordType="A" +INFO[0000] CREATE: my-workload-1.example.com 600 IN A 1.2.3.4;2001:db8:0:1:1:1:1:1;2001:db8:0:1:1:1:1:2 [] +INFO[0000] CREATE: my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +INFO[0000] CREATE: a-my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +``` + +#### Multiple DNS A-Records from a ConfigMap + +This example realizes two DNS A-records from a `ConfigMap` resource: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-workload-2 + annotations: + example: my-workload-2 + description: | + This example results in the creation of two endpoints with + one, A-record targets, an IP4 address. An endpoint is created + for each of the DNS names specified in the annotation below. + Since no TTL or record type are specified, the default values + for each are used, a TTL of 0 and an A-record. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com, my-workload-2.example.com +data: + ip4-addrs: 1.2.3.4 +``` + +Run External DNS: + +```shell +build/external-dns \ + --annotation-filter="example=my-workload-2" \ + --source unstructured \ + --unstructured-source-apiversion v1 \ + --unstructured-source-kind ConfigMap \ + --unstructured-source-target-json-path '{.data.ip4-addrs}' \ + --provider inmemory \ + --once \ + --dry-run +``` + +The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + +```shell +INFO[0000] Unstructured source configured for namespace-scoped resource with kind "ConfigMap" in apiVersion "v1" in namespace "" +INFO[0000] resource="my-workload-2", hostnames=[my-workload-1.example.com my-workload-2.example.com], targets=[1.2.3.4], ttl=0, recordType="A" +INFO[0000] CREATE: my-workload-1.example.com 0 IN A 1.2.3.4 [] +INFO[0000] CREATE: my-workload-2.example.com 0 IN A 1.2.3.4 [] +INFO[0000] CREATE: my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] +INFO[0000] CREATE: a-my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] +INFO[0000] CREATE: my-workload-2.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] +INFO[0000] CREATE: a-my-workload-2.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] +``` + +### DNS CNAME-Record from Annotation on a Deployment + +This example realizes two DNS CNAME-records from a `Deployment` resource: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-workload-1 + labels: + app: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of two endpoints with + one, CNAME-record targets, a hostname. An endpoint is created + for each of the DNS names specified in the annotation below. + Since no TTL is specified, the default value, a TTL of 0, is + used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1a.example.com my-workload-1b.example.com + external-dns.alpha.kubernetes.io/record-type: CNAME + external-dns.alpha.kubernetes.io/target: my-workload-1.example.com +spec: + replicas: 0 + selector: + matchLabels: + app: my-workload-1 + template: + metadata: + labels: + app: my-workload-1 + spec: + containers: + - name: nginx + image: nginx:1.14.2 +``` + +Run External DNS: + +```shell +build/external-dns \ + --annotation-filter="example=my-workload-1" \ + --source unstructured \ + --unstructured-source-apiversion "apps/v1" \ + --unstructured-source-kind Deployment \ + --provider inmemory \ + --once \ + --dry-run +``` + +The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + +```shell +INFO[0000] Unstructured source configured for namespace-scoped resource with kind "Deployment" in apiVersion "apps/v1" in namespace "" +INFO[0000] resource="my-workload-1", hostnames=[my-workload-1a.example.com my-workload-1b.example.com], targets=[my-workload-1.example.com], ttl=0, recordType="CNAME" +INFO[0000] CREATE: my-workload-1a.example.com 0 IN CNAME my-workload-1.example.com [] +INFO[0000] CREATE: my-workload-1b.example.com 0 IN CNAME my-workload-1.example.com [] +INFO[0000] CREATE: my-workload-1a.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +INFO[0000] CREATE: cname-my-workload-1a.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +INFO[0000] CREATE: my-workload-1b.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +INFO[0000] CREATE: cname-my-workload-1b.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] +``` + +#### DNS A-Record from a Namespace-Scoped CRD + +This example realizes a single DNS A-record from a namespace-scoped, CRD resource: + +```yaml +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one, A-record target, an IP4 address. The endpoint's DNS name + and TTL are derived from the annotations below, and since no + record type is specified, the default type, A-record, is used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com + external-dns.alpha.kubernetes.io/ttl: 10m +spec: {} +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 +``` + +1. Patch the `status` sub-resource with `curl`: + + ```shell + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XGET -H 'Content-Type: application/json' -H 'Accept: application/json' \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-1" | \ + jq '.status.addr="1.2.3.4"' | \ + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XPUT -H 'Content-Type: application/json' -H 'Accept: application/json' -d @- \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-1/status" + ``` + +1. Run External DNS: + + ```shell + build/external-dns \ + --annotation-filter="example=my-workload-1" \ + --source unstructured \ + --unstructured-source-apiversion "example.com/v1alpha1" \ + --unstructured-source-kind Workload \ + --unstructured-source-target-json-path '{.status.addr}' \ + --provider inmemory \ + --once \ + --dry-run + ``` + + The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + + ```shell + INFO[0000] Unstructured source configured for namespace-scoped resource with kind "Workload" in apiVersion "example.com/v1alpha1" in namespace "" + INFO[0000] resource="my-workload-1", hostnames=[my-workload-1.example.com], targets=[1.2.3.4], ttl=600, recordType="A" + INFO[0000] CREATE: my-workload-1.example.com 600 IN A 1.2.3.4 [] + INFO[0000] CREATE: my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] + INFO[0000] CREATE: a-my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-1" [] + ``` + +#### Multiple DNS A-Records from a Namespace-Scoped CRD + +This example realizes three DNS A-record from a namespace-scoped, CRD resource: + +```yaml +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-2 + annotations: + example: my-workload-2 + description: | + This example results in the creation of three endpoints with + one, A-record target, an IP4 address. An endpoint is created + for each of the DNS names specified in the spec below. + Since no TTL or record type are specified, the default values + for each are used, a TTL of 0 and an A-record. +spec: + hostname: my-workload-2.example.com + additionalHostnames: + - my-workload-2a.example.com + - my-workload-2b.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 +``` + +1. Patch the `status` sub-resource with `curl`: + + ```shell + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XGET -H 'Content-Type: application/json' -H 'Accept: application/json' \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-2" | \ + jq '.status.addr="1.2.3.4"' | \ + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XPUT -H 'Content-Type: application/json' -H 'Accept: application/json' -d @- \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-2/status" + ``` + +1. Run External DNS: + + ```shell + build/external-dns \ + --annotation-filter="example=my-workload-2" \ + --source unstructured \ + --unstructured-source-apiversion "example.com/v1alpha1" \ + --unstructured-source-kind Workload \ + --unstructured-source-target-json-path '{.status.addr}' \ + --unstructured-source-hostname-json-path '{.spec.hostname} {.spec.additionalHostnames[*]}' \ + --provider inmemory \ + --once \ + --dry-run + ``` + + The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + + ```shell + INFO[0000] Unstructured source configured for namespace-scoped resource with kind "Workload" in apiVersion "example.com/v1alpha1" in namespace "" + INFO[0000] resource="my-workload-2", hostnames=[my-workload-2.example.com my-workload-2a.example.com my-workload-2b.example.com], targets=[1.2.3.4], ttl=0, recordType="A" + INFO[0000] CREATE: my-workload-2a.example.com 0 IN A 1.2.3.4 [] + INFO[0000] CREATE: my-workload-2b.example.com 0 IN A 1.2.3.4 [] + INFO[0000] CREATE: my-workload-2.example.com 0 IN A 1.2.3.4 [] + INFO[0000] CREATE: my-workload-2a.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + INFO[0000] CREATE: a-my-workload-2a.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + INFO[0000] CREATE: my-workload-2b.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + INFO[0000] CREATE: a-my-workload-2b.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + INFO[0000] CREATE: my-workload-2.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + INFO[0000] CREATE: a-my-workload-2.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-2" [] + ``` + +#### DNS A-Record with Multiple Targets from a Namespace-Scoped CRD + +This example realizes a DNS A-record from a namespace-scoped, CRD resource: + +```yaml +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-3 + annotations: + example: my-workload-3 + description: | + This example results in the creation of a single endpoint with + one four, A-record targets -- two IP4 addrs and two IP6 addrs. + The endpoint's DNS is derived from the spec below. Since no + TTL or record type are specified, the default values for each + are used, a TTL of 0 and an A-record. +spec: + hostname: my-workload-3.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 + additionalAddrs: + - 5.6.7.8 + - 2001:db8:0:1:1:1:1:1 + - 2001:db8:0:1:1:1:1:2 +``` + +1. Patch the `status` sub-resource with `curl`: + + ```shell + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XGET -H 'Content-Type: application/json' -H 'Accept: application/json' \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-3" | \ + jq '.status.addr="1.2.3.4"' | jq '.status.additionalAddrs=["5.6.7.8","2001:db8:0:1:1:1:1:1","2001:db8:0:1:1:1:1:2"]' | \ + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XPUT -H 'Content-Type: application/json' -H 'Accept: application/json' -d @- \ + "$(cat url.txt)/apis/example.com/v1alpha1/namespaces/default/workloads/my-workload-3/status" + ``` + +1. Run External DNS: + + ```shell + build/external-dns \ + --annotation-filter="example=my-workload-3" \ + --source unstructured \ + --unstructured-source-apiversion "example.com/v1alpha1" \ + --unstructured-source-kind Workload \ + --unstructured-source-target-json-path '{.status.addr} {.status.additionalAddrs[*]}' \ + --unstructured-source-hostname-json-path '{.spec.hostname}' \ + --provider inmemory \ + --once \ + --dry-run + ``` + + The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + + ```shell + INFO[0000] Unstructured source configured for namespace-scoped resource with kind "Workload" in apiVersion "example.com/v1alpha1" in namespace "" + INFO[0000] resource="my-workload-3", hostnames=[my-workload-3.example.com], targets=[1.2.3.4 5.6.7.8 2001:db8:0:1:1:1:1:1 2001:db8:0:1:1:1:1:2], ttl=0, recordType="A" + INFO[0000] CREATE: my-workload-3.example.com 0 IN A 1.2.3.4;5.6.7.8;2001:db8:0:1:1:1:1:1;2001:db8:0:1:1:1:1:2 [] + INFO[0000] CREATE: my-workload-3.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-3" [] + INFO[0000] CREATE: a-my-workload-3.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/default/my-workload-3" [] + ``` + +### DNS A-Record a Cluster-Scoped CRD + +This example realizes a single DNS A-record from a cluster-scoped, CRD resource: + +```yaml +apiVersion: example.com/v1alpha1 +kind: ClusterWorkload +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one, A-record targets, an IP4 address. The endpoint's DNS name + and TTL are derived from the spec and annotations below, and + since no record type is specified, the default type, A-record, + is used. + + This example highlights that the unstructured source may be used + with cluster-scoped resources as well. + external-dns.alpha.kubernetes.io/ttl: 10m +spec: + hostname: my-workload-1.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 +``` + +1. Patch the `status` sub-resource with `curl`: + + ```shell + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XGET -H 'Content-Type: application/json' -H 'Accept: application/json' \ + "$(cat url.txt)/apis/example.com/v1alpha1/clusterworkloads/my-workload-1" | \ + jq '.status.addr="1.2.3.4"' | \ + curl --cacert ca.crt --cert client.crt --key client.key \ + --silent --show-error \ + -XPUT -H 'Content-Type: application/json' -H 'Accept: application/json' -d @- \ + "$(cat url.txt)/apis/example.com/v1alpha1/clusterworkloads/my-workload-1/status" + ``` + +1. Run External DNS: + + ```shell + build/external-dns \ + --annotation-filter="example=my-workload-1" \ + --source unstructured \ + --unstructured-source-apiversion "example.com/v1alpha1" \ + --unstructured-source-kind ClusterWorkload \ + --unstructured-source-target-json-path '{.status.addr}' \ + --unstructured-source-hostname-json-path '{.spec.hostname}' \ + --provider inmemory \ + --once \ + --dry-run + ``` + + The following lines at the end of the External DNS output illustrate the successful creation of the DNS record(s): + + ```shell + INFO[0000] Unstructured source configured for cluster-scoped resource with kind "ClusterWorkload" in apiVersion "example.com/v1alpha1" + INFO[0000] resource="my-workload-1", hostnames=[my-workload-1.example.com], targets=[1.2.3.4], ttl=600, recordType="A" + INFO[0000] CREATE: my-workload-1.example.com 600 IN A 1.2.3.4 [] + INFO[0000] CREATE: my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/my-workload-1" [] + INFO[0000] CREATE: a-my-workload-1.example.com 0 IN TXT "heritage=external-dns,external-dns/owner=default,external-dns/resource=unstructured/my-workload-1" [] + ``` + +### Cleanup Steps + +1. Delete the Kind cluster: + + ```shell + kind delete cluster + ``` + +1. Cleanup the files created to use `curl` with the cluster: + + ```shell + rm url.txt ca.crt client.crt client.key + ``` \ No newline at end of file diff --git a/docs/contributing/unstructured-source/clusterworkload-example.yaml b/docs/contributing/unstructured-source/clusterworkload-example.yaml new file mode 100644 index 0000000000..defcdba33f --- /dev/null +++ b/docs/contributing/unstructured-source/clusterworkload-example.yaml @@ -0,0 +1,24 @@ +--- + +apiVersion: example.com/v1alpha1 +kind: ClusterWorkload +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one, A-record targets, an IP4 address. The endpoint's DNS name + and TTL are derived from the spec and annotations below, and + since no record type is specified, the default type, A-record, + is used. + + This example highlights that the unstructured source may be used + with cluster-scoped resources as well. + external-dns.alpha.kubernetes.io/ttl: 10m +spec: + hostname: my-workload-1.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 diff --git a/docs/contributing/unstructured-source/clusterworkload-manifest.yaml b/docs/contributing/unstructured-source/clusterworkload-manifest.yaml new file mode 100644 index 0000000000..cf2b310972 --- /dev/null +++ b/docs/contributing/unstructured-source/clusterworkload-manifest.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterworkloads.example.com +spec: + group: example.com + names: + kind: ClusterWorkload + listKind: ClusterWorkloadList + plural: clusterworkloads + singular: clusterworkload + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + 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: + description: WorkloadSpec defines the desired state of Workload + properties: + hostname: + description: The hostname used to identify the workload. + type: string + additionalHostnames: + description: Additional hostnames used to identify the workload. + items: + type: string + type: array + type: object + status: + description: WorkloadStatus defines the observed state of Workload + properties: + addr: + description: The address at which the workload can be reached. + type: string + additionalAddrs: + description: Additional addresses at which the workload can be reached. + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/docs/contributing/unstructured-source/configmap-example.yaml b/docs/contributing/unstructured-source/configmap-example.yaml new file mode 100644 index 0000000000..5f328842db --- /dev/null +++ b/docs/contributing/unstructured-source/configmap-example.yaml @@ -0,0 +1,37 @@ +--- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one three, A-record targets -- one IP4 addr and two IP6 addrs. + The endpoint's DNS name and TTL are derived from the annotations + below, and since no record type is specified, the default type, + A-record, is used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com + external-dns.alpha.kubernetes.io/ttl: 10m +data: + ip4-addrs: 1.2.3.4 + ip6-addrs: "2001:db8:0:1:1:1:1:1,2001:db8:0:1:1:1:1:2" + +--- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-workload-2 + annotations: + example: my-workload-2 + description: | + This example results in the creation of two endpoints with + one, A-record targets, an IP4 address. An endpoint is created + for each of the DNS names specified in the annotation below. + Since no TTL or record type are specified, the default values + for each are used, a TTL of 0 and an A-record. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com, my-workload-2.example.com +data: + ip4-addrs: 1.2.3.4 diff --git a/docs/contributing/unstructured-source/deployment-example.yaml b/docs/contributing/unstructured-source/deployment-example.yaml new file mode 100644 index 0000000000..9b90325fec --- /dev/null +++ b/docs/contributing/unstructured-source/deployment-example.yaml @@ -0,0 +1,32 @@ +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-workload-1 + labels: + app: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of two endpoints with + one, CNAME-record targets, a hostname. An endpoint is created + for each of the DNS names specified in the annotation below. + Since no TTL is specified, the default value, a TTL of 0, is + used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1a.example.com my-workload-1b.example.com + external-dns.alpha.kubernetes.io/record-type: CNAME + external-dns.alpha.kubernetes.io/target: my-workload-1.example.com +spec: + replicas: 0 + selector: + matchLabels: + app: my-workload-1 + template: + metadata: + labels: + app: my-workload-1 + spec: + containers: + - name: nginx + image: nginx:1.14.2 diff --git a/docs/contributing/unstructured-source/workload-example.yaml b/docs/contributing/unstructured-source/workload-example.yaml new file mode 100644 index 0000000000..4bd45d4128 --- /dev/null +++ b/docs/contributing/unstructured-source/workload-example.yaml @@ -0,0 +1,69 @@ +--- + +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-1 + annotations: + example: my-workload-1 + description: | + This example results in the creation of a single endpoint with + one, A-record target, an IP4 address. The endpoint's DNS name + and TTL are derived from the annotations below, and since no + record type is specified, the default type, A-record, is used. + external-dns.alpha.kubernetes.io/hostname: my-workload-1.example.com + external-dns.alpha.kubernetes.io/ttl: 10m +spec: {} +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 + +--- + +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-2 + annotations: + example: my-workload-2 + description: | + This example results in the creation of three endpoints with + one, A-record target, an IP4 address. An endpoint is created + for each of the DNS names specified in the spec below. + Since no TTL or record type are specified, the default values + for each are used, a TTL of 0 and an A-record. +spec: + hostname: my-workload-2.example.com + additionalHostnames: + - my-workload-2a.example.com + - my-workload-2b.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 + +--- + +apiVersion: example.com/v1alpha1 +kind: Workload +metadata: + name: my-workload-3 + annotations: + example: my-workload-3 + description: | + This example results in the creation of a single endpoint with + one four, A-record targets -- two IP4 addrs and two IP6 addrs. + The endpoint's DNS is derived from the spec below. Since no + TTL or record type are specified, the default values for each + are used, a TTL of 0 and an A-record. +spec: + hostname: my-workload-3.example.com +# kubectl is disallowed from editing the status sub-resource. it is included +# here for completeness, but the example actually used curl to set the status +status: + addr: 1.2.3.4 + additionalAddrs: + - 5.6.7.8 + - 2001:db8:0:1:1:1:1:1 + - 2001:db8:0:1:1:1:1:2 diff --git a/docs/contributing/unstructured-source/workload-manifest.yaml b/docs/contributing/unstructured-source/workload-manifest.yaml new file mode 100644 index 0000000000..defbce4564 --- /dev/null +++ b/docs/contributing/unstructured-source/workload-manifest.yaml @@ -0,0 +1,61 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: workloads.example.com +spec: + group: example.com + names: + kind: Workload + listKind: WorkloadList + plural: workloads + singular: workload + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + 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: + description: WorkloadSpec defines the desired state of Workload + properties: + hostname: + description: The hostname used to identify the workload. + type: string + additionalHostnames: + description: Additional hostnames used to identify the workload. + items: + type: string + type: array + type: object + status: + description: WorkloadStatus defines the observed state of Workload + properties: + addr: + description: The address at which the workload can be reached. + type: string + additionalAddrs: + description: Additional addresses at which the workload can be reached. + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/main.go b/main.go index ece1b02909..10df4f0ed5 100644 --- a/main.go +++ b/main.go @@ -110,35 +110,39 @@ func main() { // Create a source.Config from the flags passed by the user. sourceCfg := &source.Config{ - Namespace: cfg.Namespace, - AnnotationFilter: cfg.AnnotationFilter, - LabelFilter: labelSelector, - FQDNTemplate: cfg.FQDNTemplate, - CombineFQDNAndAnnotation: cfg.CombineFQDNAndAnnotation, - IgnoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, - IgnoreIngressTLSSpec: cfg.IgnoreIngressTLSSpec, - IgnoreIngressRulesSpec: cfg.IgnoreIngressRulesSpec, - GatewayNamespace: cfg.GatewayNamespace, - GatewayLabelFilter: cfg.GatewayLabelFilter, - Compatibility: cfg.Compatibility, - PublishInternal: cfg.PublishInternal, - PublishHostIP: cfg.PublishHostIP, - AlwaysPublishNotReadyAddresses: cfg.AlwaysPublishNotReadyAddresses, - ConnectorServer: cfg.ConnectorSourceServer, - CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, - CRDSourceKind: cfg.CRDSourceKind, - KubeConfig: cfg.KubeConfig, - APIServerURL: cfg.APIServerURL, - ServiceTypeFilter: cfg.ServiceTypeFilter, - CFAPIEndpoint: cfg.CFAPIEndpoint, - CFUsername: cfg.CFUsername, - CFPassword: cfg.CFPassword, - ContourLoadBalancerService: cfg.ContourLoadBalancerService, - GlooNamespace: cfg.GlooNamespace, - SkipperRouteGroupVersion: cfg.SkipperRouteGroupVersion, - RequestTimeout: cfg.RequestTimeout, - DefaultTargets: cfg.DefaultTargets, - OCPRouterName: cfg.OCPRouterName, + Namespace: cfg.Namespace, + AnnotationFilter: cfg.AnnotationFilter, + LabelFilter: labelSelector, + FQDNTemplate: cfg.FQDNTemplate, + CombineFQDNAndAnnotation: cfg.CombineFQDNAndAnnotation, + IgnoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, + IgnoreIngressTLSSpec: cfg.IgnoreIngressTLSSpec, + IgnoreIngressRulesSpec: cfg.IgnoreIngressRulesSpec, + GatewayNamespace: cfg.GatewayNamespace, + GatewayLabelFilter: cfg.GatewayLabelFilter, + Compatibility: cfg.Compatibility, + PublishInternal: cfg.PublishInternal, + PublishHostIP: cfg.PublishHostIP, + AlwaysPublishNotReadyAddresses: cfg.AlwaysPublishNotReadyAddresses, + ConnectorServer: cfg.ConnectorSourceServer, + CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, + CRDSourceKind: cfg.CRDSourceKind, + KubeConfig: cfg.KubeConfig, + APIServerURL: cfg.APIServerURL, + ServiceTypeFilter: cfg.ServiceTypeFilter, + CFAPIEndpoint: cfg.CFAPIEndpoint, + CFUsername: cfg.CFUsername, + CFPassword: cfg.CFPassword, + ContourLoadBalancerService: cfg.ContourLoadBalancerService, + GlooNamespace: cfg.GlooNamespace, + SkipperRouteGroupVersion: cfg.SkipperRouteGroupVersion, + RequestTimeout: cfg.RequestTimeout, + DefaultTargets: cfg.DefaultTargets, + OCPRouterName: cfg.OCPRouterName, + UnstructuredSourceAPIVersion: cfg.UnstructuredSourceAPIVersion, + UnstructuredSourceKind: cfg.UnstructuredSourceKind, + UnstructuredSourceTargetJsonPath: cfg.UnstructuredSourceTargetJsonPath, + UnstructuredSourceHostnameJsonPath: cfg.UnstructuredSourceHostnameJsonPath, } // Lookup all the selected sources by names and pass them the desired configuration. diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 12759ab32a..ad563e2812 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -43,162 +43,166 @@ var Version = "unknown" // Config is a project-wide configuration type Config struct { - APIServerURL string - KubeConfig string - RequestTimeout time.Duration - DefaultTargets []string - ContourLoadBalancerService string - GlooNamespace string - SkipperRouteGroupVersion string - Sources []string - Namespace string - AnnotationFilter string - LabelFilter string - FQDNTemplate string - CombineFQDNAndAnnotation bool - IgnoreHostnameAnnotation bool - IgnoreIngressTLSSpec bool - IgnoreIngressRulesSpec bool - GatewayNamespace string - GatewayLabelFilter string - Compatibility string - PublishInternal bool - PublishHostIP bool - AlwaysPublishNotReadyAddresses bool - ConnectorSourceServer string - Provider string - GoogleProject string - GoogleBatchChangeSize int - GoogleBatchChangeInterval time.Duration - GoogleZoneVisibility string - DomainFilter []string - ExcludeDomains []string - RegexDomainFilter *regexp.Regexp - RegexDomainExclusion *regexp.Regexp - ZoneNameFilter []string - ZoneIDFilter []string - TargetNetFilter []string - ExcludeTargetNets []string - AlibabaCloudConfigFile string - AlibabaCloudZoneType string - AWSZoneType string - AWSZoneTagFilter []string - AWSAssumeRole string - AWSAssumeRoleExternalID string - AWSBatchChangeSize int - AWSBatchChangeInterval time.Duration - AWSEvaluateTargetHealth bool - AWSAPIRetries int - AWSPreferCNAME bool - AWSZoneCacheDuration time.Duration - AWSSDServiceCleanup bool - AzureConfigFile string - AzureResourceGroup string - AzureSubscriptionID string - AzureUserAssignedIdentityClientID string - BluecatDNSConfiguration string - BluecatConfigFile string - BluecatDNSView string - BluecatGatewayHost string - BluecatRootZone string - BluecatDNSServerName string - BluecatDNSDeployType string - BluecatSkipTLSVerify bool - CloudflareProxied bool - CloudflareDNSRecordsPerPage int - CoreDNSPrefix string - RcodezeroTXTEncrypt bool - AkamaiServiceConsumerDomain string - AkamaiClientToken string - AkamaiClientSecret string - AkamaiAccessToken string - AkamaiEdgercPath string - AkamaiEdgercSection string - InfobloxGridHost string - InfobloxWapiPort int - InfobloxWapiUsername string - InfobloxWapiPassword string `secure:"yes"` - InfobloxWapiVersion string - InfobloxSSLVerify bool - InfobloxView string - InfobloxMaxResults int - InfobloxFQDNRegEx string - InfobloxNameRegEx string - InfobloxCreatePTR bool - InfobloxCacheDuration int - DynCustomerName string - DynUsername string - DynPassword string `secure:"yes"` - DynMinTTLSeconds int - OCIConfigFile string - InMemoryZones []string - OVHEndpoint string - OVHApiRateLimit int - PDNSServer string - PDNSAPIKey string `secure:"yes"` - PDNSTLSEnabled bool - TLSCA string - TLSClientCert string - TLSClientCertKey string - Policy string - Registry string - TXTOwnerID string - TXTPrefix string - TXTSuffix string - Interval time.Duration - MinEventSyncInterval time.Duration - Once bool - DryRun bool - UpdateEvents bool - LogFormat string - MetricsAddress string - LogLevel string - TXTCacheInterval time.Duration - TXTWildcardReplacement string - ExoscaleEndpoint string - ExoscaleAPIKey string `secure:"yes"` - ExoscaleAPISecret string `secure:"yes"` - CRDSourceAPIVersion string - CRDSourceKind string - ServiceTypeFilter []string - CFAPIEndpoint string - CFUsername string - CFPassword string - RFC2136Host string - RFC2136Port int - RFC2136Zone string - RFC2136Insecure bool - RFC2136GSSTSIG bool - RFC2136KerberosRealm string - RFC2136KerberosUsername string - RFC2136KerberosPassword string `secure:"yes"` - RFC2136TSIGKeyName string - RFC2136TSIGSecret string `secure:"yes"` - RFC2136TSIGSecretAlg string - RFC2136TAXFR bool - RFC2136MinTTL time.Duration - RFC2136BatchChangeSize int - NS1Endpoint string - NS1IgnoreSSL bool - NS1MinTTLSeconds int - TransIPAccountName string - TransIPPrivateKeyFile string - DigitalOceanAPIPageSize int - ManagedDNSRecordTypes []string - GoDaddyAPIKey string `secure:"yes"` - GoDaddySecretKey string `secure:"yes"` - GoDaddyTTL int64 - GoDaddyOTE bool - OCPRouterName string - IBMCloudProxied bool - IBMCloudConfigFile string - TencentCloudConfigFile string - TencentCloudZoneType string - PiholeServer string - PiholePassword string `secure:"yes"` - PiholeTLSInsecureSkipVerify bool - PluralCluster string - PluralProvider string + APIServerURL string + KubeConfig string + RequestTimeout time.Duration + DefaultTargets []string + ContourLoadBalancerService string + GlooNamespace string + SkipperRouteGroupVersion string + Sources []string + Namespace string + AnnotationFilter string + LabelFilter string + FQDNTemplate string + CombineFQDNAndAnnotation bool + IgnoreHostnameAnnotation bool + IgnoreIngressTLSSpec bool + IgnoreIngressRulesSpec bool + GatewayNamespace string + GatewayLabelFilter string + Compatibility string + PublishInternal bool + PublishHostIP bool + AlwaysPublishNotReadyAddresses bool + ConnectorSourceServer string + Provider string + GoogleProject string + GoogleBatchChangeSize int + GoogleBatchChangeInterval time.Duration + GoogleZoneVisibility string + DomainFilter []string + ExcludeDomains []string + RegexDomainFilter *regexp.Regexp + RegexDomainExclusion *regexp.Regexp + ZoneNameFilter []string + ZoneIDFilter []string + TargetNetFilter []string + ExcludeTargetNets []string + AlibabaCloudConfigFile string + AlibabaCloudZoneType string + AWSZoneType string + AWSZoneTagFilter []string + AWSAssumeRole string + AWSAssumeRoleExternalID string + AWSBatchChangeSize int + AWSBatchChangeInterval time.Duration + AWSEvaluateTargetHealth bool + AWSAPIRetries int + AWSPreferCNAME bool + AWSZoneCacheDuration time.Duration + AWSSDServiceCleanup bool + AzureConfigFile string + AzureResourceGroup string + AzureSubscriptionID string + AzureUserAssignedIdentityClientID string + BluecatDNSConfiguration string + BluecatConfigFile string + BluecatDNSView string + BluecatGatewayHost string + BluecatRootZone string + BluecatDNSServerName string + BluecatDNSDeployType string + BluecatSkipTLSVerify bool + CloudflareProxied bool + CloudflareDNSRecordsPerPage int + CoreDNSPrefix string + RcodezeroTXTEncrypt bool + AkamaiServiceConsumerDomain string + AkamaiClientToken string + AkamaiClientSecret string + AkamaiAccessToken string + AkamaiEdgercPath string + AkamaiEdgercSection string + InfobloxGridHost string + InfobloxWapiPort int + InfobloxWapiUsername string + InfobloxWapiPassword string `secure:"yes"` + InfobloxWapiVersion string + InfobloxSSLVerify bool + InfobloxView string + InfobloxMaxResults int + InfobloxFQDNRegEx string + InfobloxNameRegEx string + InfobloxCreatePTR bool + InfobloxCacheDuration int + DynCustomerName string + DynUsername string + DynPassword string `secure:"yes"` + DynMinTTLSeconds int + OCIConfigFile string + InMemoryZones []string + OVHEndpoint string + OVHApiRateLimit int + PDNSServer string + PDNSAPIKey string `secure:"yes"` + PDNSTLSEnabled bool + TLSCA string + TLSClientCert string + TLSClientCertKey string + Policy string + Registry string + TXTOwnerID string + TXTPrefix string + TXTSuffix string + Interval time.Duration + MinEventSyncInterval time.Duration + Once bool + DryRun bool + UpdateEvents bool + LogFormat string + MetricsAddress string + LogLevel string + TXTCacheInterval time.Duration + TXTWildcardReplacement string + ExoscaleEndpoint string + ExoscaleAPIKey string `secure:"yes"` + ExoscaleAPISecret string `secure:"yes"` + CRDSourceAPIVersion string + CRDSourceKind string + ServiceTypeFilter []string + CFAPIEndpoint string + CFUsername string + CFPassword string + RFC2136Host string + RFC2136Port int + RFC2136Zone string + RFC2136Insecure bool + RFC2136GSSTSIG bool + RFC2136KerberosRealm string + RFC2136KerberosUsername string + RFC2136KerberosPassword string `secure:"yes"` + RFC2136TSIGKeyName string + RFC2136TSIGSecret string `secure:"yes"` + RFC2136TSIGSecretAlg string + RFC2136TAXFR bool + RFC2136MinTTL time.Duration + RFC2136BatchChangeSize int + NS1Endpoint string + NS1IgnoreSSL bool + NS1MinTTLSeconds int + TransIPAccountName string + TransIPPrivateKeyFile string + DigitalOceanAPIPageSize int + ManagedDNSRecordTypes []string + GoDaddyAPIKey string `secure:"yes"` + GoDaddySecretKey string `secure:"yes"` + GoDaddyTTL int64 + GoDaddyOTE bool + OCPRouterName string + IBMCloudProxied bool + IBMCloudConfigFile string + TencentCloudConfigFile string + TencentCloudZoneType string + PiholeServer string + PiholePassword string `secure:"yes"` + PiholeTLSInsecureSkipVerify bool + PluralCluster string + PluralProvider string + UnstructuredSourceAPIVersion string + UnstructuredSourceKind string + UnstructuredSourceTargetJsonPath string + UnstructuredSourceHostnameJsonPath string } var defaultConfig = &Config{ @@ -404,7 +408,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("skipper-routegroup-groupversion", "The resource version for skipper routegroup").Default(source.DefaultRoutegroupVersion).StringVar(&cfg.SkipperRouteGroupVersion) // Flags related to processing source - app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver") + app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, unstructured)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver", "unstructured") app.Flag("openshift-router-name", "if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record.").StringVar(&cfg.OCPRouterName) app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("annotation-filter", "Filter sources managed by external-dns via annotation using label selector semantics (default: all sources)").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter) @@ -428,6 +432,10 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("default-targets", "Set globally default IP address that will apply as a target instead of source addresses. Specify multiple times for multiple targets (optional)").StringsVar(&cfg.DefaultTargets) app.Flag("target-net-filter", "Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional)").StringsVar(&cfg.TargetNetFilter) app.Flag("exclude-target-net", "Exclude target nets (optional)").StringsVar(&cfg.ExcludeTargetNets) + app.Flag("unstructured-source-apiversion", "API version of the API for unstructured source, e.g. `v1` or `externaldns.k8s.io/v1alpha1`, valid only when using unstructured source").StringVar(&cfg.UnstructuredSourceAPIVersion) + app.Flag("unstructured-source-kind", "Kind of the API for the unstructured source in API group and version specified by unstructured-source-apiversion").StringVar(&cfg.UnstructuredSourceKind) + app.Flag("unstructured-source-target-json-path", "Specifies the JSONPath expression that must evaluate to comma or space delimited string with one or more DNS targets for the unstructured source, ex. {.status.podIP} or {.status.nodes[*].addr}. If this flag is omitted, the DNS targets are derived from the annotation `external-dns.alpha.kubernetes.io/target` (optional) ").StringVar(&cfg.UnstructuredSourceTargetJsonPath) + app.Flag("unstructured-source-hostname-json-path", "Specifies the JSONPath expression that must evaluate to a comma or space delimited string with one or more DNS names for the unstructured source, ex. {.metadata.labels.fqdn} or {.status.hostnames[*]}. If this flag is omitted, the DNS names are derived from the annotation `external-dns.alpha.kubernetes.io/hostname` (optional)").StringVar(&cfg.UnstructuredSourceHostnameJsonPath) // Flags related to providers providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "infoblox", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr"} diff --git a/source/source.go b/source/source.go index da9909923c..be70057540 100644 --- a/source/source.go +++ b/source/source.go @@ -23,6 +23,7 @@ import ( "math" "net" "reflect" + "regexp" "strconv" "strings" "text/template" @@ -50,6 +51,8 @@ const ( targetAnnotationKey = "external-dns.alpha.kubernetes.io/target" // The annotation used for defining the desired DNS record TTL ttlAnnotationKey = "external-dns.alpha.kubernetes.io/ttl" + // The annotation used for defining the desired DNS record type + recordTypeAnnotationKey = "external-dns.alpha.kubernetes.io/record-type" // The annotation used for switching to the alias record types e. g. AWS Alias records instead of a normal CNAME aliasAnnotationKey = "external-dns.alpha.kubernetes.io/alias" // The annotation used to determine the source of hostnames for ingresses. This is an optional field - all @@ -151,7 +154,65 @@ func getHostnamesFromAnnotations(annotations map[string]string) []string { if !exists { return nil } - return strings.Split(strings.Replace(hostnameAnnotation, " ", "", -1), ",") + return splitByWhitespaceAndComma(hostnameAnnotation) +} + +func getInternalHostnamesFromAnnotations(annotations map[string]string) []string { + internalHostnameAnnotation, exists := annotations[internalHostnameAnnotationKey] + if !exists { + return nil + } + return splitByWhitespaceAndComma(internalHostnameAnnotation) +} + +var reduceWhitespaceRx = regexp.MustCompile(`\s+`) + +// splitByWhitespaceAndComma accepts a comma or space delimited string of +// possibly quoted elements and returns a slice of those elements, +// unquoting them if necessary. +func splitByWhitespaceAndComma(in string) []string { + // Ensure that any contiguous whitespace characters in the input string + // are replaced to a single space to make splitting on a single space + // character possible. + inReducedWS := reduceWhitespaceRx.ReplaceAllString(in, " ") + + // Replace all instances of a single quote with a double quote or + // else the attempt at unquoting that occurs later will not work + // since strconv.Unquote interprets any single-quoted strings as + // a one-character literal. + noSingleQuotes := strings.Replace(inReducedWS, `'`, `"`, -1) + + // Split the transformed input on a comma. + splitByComma := strings.Split(noSingleQuotes, ",") + + // Iterate through the comma-separated strings, gathering the results + // as we go. + var result []string + for i := range splitByComma { + // Trim any leading and/or trailing whitespace chars. + noSurroundingWhitespace := strings.TrimSpace(splitByComma[i]) + + // Split the string by a single space char. + splitBySpace := strings.Split(noSurroundingWhitespace, " ") + + for j := range splitBySpace { + // Unquote the string if possible. + possiblyQuoted := splitBySpace[j] + var unquotedString string + if u, err := strconv.Unquote(possiblyQuoted); err != nil { + unquotedString = possiblyQuoted + } else { + unquotedString = u + } + + // Skip if the result is empty or a single whitespace char. + if len(unquotedString) >= 1 && unquotedString != " " { + result = append(result, unquotedString) + } + } + } + + return result } func getAccessFromAnnotations(annotations map[string]string) string { @@ -162,19 +223,15 @@ func getEndpointsTypeFromAnnotations(annotations map[string]string) string { return annotations[endpointsTypeAnnotationKey] } -func getInternalHostnamesFromAnnotations(annotations map[string]string) []string { - internalHostnameAnnotation, exists := annotations[internalHostnameAnnotationKey] - if !exists { - return nil - } - return strings.Split(strings.Replace(internalHostnameAnnotation, " ", "", -1), ",") -} - func getAliasFromAnnotations(annotations map[string]string) bool { aliasAnnotation, exists := annotations[aliasAnnotationKey] return exists && aliasAnnotation == "true" } +func getRecordTypeFromAnnotations(annotations map[string]string) string { + return annotations[recordTypeAnnotationKey] +} + func getProviderSpecificAnnotations(annotations map[string]string) (endpoint.ProviderSpecific, string) { providerSpecificAnnotations := endpoint.ProviderSpecific{} diff --git a/source/source_test.go b/source/source_test.go index 0c31b93ba0..31e50420a0 100644 --- a/source/source_test.go +++ b/source/source_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" ) @@ -105,3 +106,145 @@ func TestSuitableType(t *testing.T) { } } } + +func Test_splitByWhitespaceAndComma(t *testing.T) { + // Run parallel with other top-level tests in this package. + t.Parallel() + + testCases := []struct { + name string + inputString string + expectedSlice []string + }{ + // single hostname + { + name: "single hostname", + inputString: "fu.bar", + expectedSlice: []string{"fu.bar"}, + }, + + // single hostname with double quotes + { + name: "single hostname with double quotes", + inputString: `"fu.bar"`, + expectedSlice: []string{"fu.bar"}, + }, + + // single hostname with single quotes + { + name: "single hostname with single quotes", + inputString: `'fu.bar'`, + expectedSlice: []string{"fu.bar"}, + }, + + // single hostname and whitespace + { + name: "single hostname with leading whitespace", + inputString: " fu.bar", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with trailing whitespace", + inputString: "fu.bar \t ", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with leading and trailing whitespace", + inputString: " \tfu.bar \n\r ", + expectedSlice: []string{"fu.bar"}, + }, + + // single hostname and commas + { + name: "single hostname with leading comma", + inputString: ",fu.bar", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with trailing comma", + inputString: "fu.bar,", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with leading and trailing comma", + inputString: ",fu.bar,", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with multiple leading and trailing commas", + inputString: ",,fu.bar,,,", + expectedSlice: []string{"fu.bar"}, + }, + + // single hostname and whitespace and commas + { + name: "single hostname with leading whitespace and trailing comma", + inputString: " fu.bar,", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with leading comma and trailing whitespace", + inputString: ",fu.bar\n", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with leading and trailing whitespace and comma", + inputString: ",\tfu.bar\r,", + expectedSlice: []string{"fu.bar"}, + }, + { + name: "single hostname with multiple leading and trailing whitespace chars and commas", + inputString: ",,\n \r,\t\t fu.bar,,\r, \t\t\t\t \n \r\t", + expectedSlice: []string{"fu.bar"}, + }, + + // two hostsnames and whitespace + { + name: "two hostnames separated by a single whitespace char", + inputString: "fu.bar hello.world", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + { + name: "two hostnames with leading whitespace separated by a single whitespace char", + inputString: " fu.bar hello.world", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + { + name: "two hostnames with trailing whitespace separated by a single whitespace char", + inputString: "fu.bar hello.world ", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + { + name: "two hostnames with leading and trailing whitespace separated by a single whitespace char", + inputString: " fu.bar hello.world ", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + { + name: "two hostnames separated by multiple whitespace chars", + inputString: "fu.bar \t\t hello.world", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + { + name: "two hostnames separated by multiple whitespace chars with leading and trailing whitespace separated by a single whitespace char", + inputString: " \t\n fu.bar\r hello.world\r ", + expectedSlice: []string{"fu.bar", "hello.world"}, + }, + + // the kitchen sink + { + name: "the kitchen sink", + inputString: "\n\n,\n\n \t \"fu.bar\",,,,,,,,,\t\r, ,hello.world,'bye.bye',,,,", + expectedSlice: []string{"fu.bar", "hello.world", "bye.bye"}, + }, + } + + for i := range testCases { + tc := testCases[i] // capture the range variable + t.Run(tc.name, func(t *testing.T) { + // All test cases should run in parallel. + t.Parallel() + actualSlice := splitByWhitespaceAndComma(tc.inputString) + require.ElementsMatch(t, tc.expectedSlice, actualSlice) + }) + } +} diff --git a/source/store.go b/source/store.go index 9f5d24b007..d44ec9efd5 100644 --- a/source/store.go +++ b/source/store.go @@ -43,35 +43,39 @@ var ErrSourceNotFound = errors.New("source not found") // Config holds shared configuration options for all Sources. type Config struct { - Namespace string - AnnotationFilter string - LabelFilter labels.Selector - FQDNTemplate string - CombineFQDNAndAnnotation bool - IgnoreHostnameAnnotation bool - IgnoreIngressTLSSpec bool - IgnoreIngressRulesSpec bool - GatewayNamespace string - GatewayLabelFilter string - Compatibility string - PublishInternal bool - PublishHostIP bool - AlwaysPublishNotReadyAddresses bool - ConnectorServer string - CRDSourceAPIVersion string - CRDSourceKind string - KubeConfig string - APIServerURL string - ServiceTypeFilter []string - CFAPIEndpoint string - CFUsername string - CFPassword string - ContourLoadBalancerService string - GlooNamespace string - SkipperRouteGroupVersion string - RequestTimeout time.Duration - DefaultTargets []string - OCPRouterName string + Namespace string + AnnotationFilter string + LabelFilter labels.Selector + FQDNTemplate string + CombineFQDNAndAnnotation bool + IgnoreHostnameAnnotation bool + IgnoreIngressTLSSpec bool + IgnoreIngressRulesSpec bool + GatewayNamespace string + GatewayLabelFilter string + Compatibility string + PublishInternal bool + PublishHostIP bool + AlwaysPublishNotReadyAddresses bool + ConnectorServer string + CRDSourceAPIVersion string + CRDSourceKind string + KubeConfig string + APIServerURL string + ServiceTypeFilter []string + CFAPIEndpoint string + CFUsername string + CFPassword string + ContourLoadBalancerService string + GlooNamespace string + SkipperRouteGroupVersion string + RequestTimeout time.Duration + DefaultTargets []string + OCPRouterName string + UnstructuredSourceAPIVersion string + UnstructuredSourceKind string + UnstructuredSourceTargetJsonPath string + UnstructuredSourceHostnameJsonPath string } // ClientGenerator provides clients @@ -340,6 +344,26 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg return nil, err } return NewF5VirtualServerSource(ctx, dynamicClient, kubernetesClient, cfg.Namespace, cfg.AnnotationFilter) + case "unstructured": + kubernetesClient, err := p.KubeClient() + if err != nil { + return nil, err + } + dynamicClient, err := p.DynamicKubernetesClient() + if err != nil { + return nil, err + } + return NewUnstructuredSource( + ctx, + dynamicClient, + kubernetesClient, + cfg.Namespace, + cfg.UnstructuredSourceAPIVersion, + cfg.UnstructuredSourceKind, + cfg.UnstructuredSourceTargetJsonPath, + cfg.UnstructuredSourceHostnameJsonPath, + cfg.LabelFilter, + cfg.AnnotationFilter) } return nil, ErrSourceNotFound diff --git a/source/unstructured_source.go b/source/unstructured_source.go new file mode 100644 index 0000000000..02cbaa73e4 --- /dev/null +++ b/source/unstructured_source.go @@ -0,0 +1,320 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "bytes" + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/jsonpath" + + "sigs.k8s.io/external-dns/endpoint" +) + +// unstructuredSource is an implementation of Source that provides endpoints by +// listing a specified Kubernetes API and fetching endpoints based on the +// source's configured property paths. +type unstructuredSource struct { + dynamicKubeClient dynamic.Interface + kubeClient kubernetes.Interface + targetJsonPath *jsonpath.JSONPath + hostnameJsonPath *jsonpath.JSONPath + namespace string + namespacedResource bool + informer informers.GenericInformer + labelSelector labels.Selector + annotationSelector labels.Selector +} + +// NewUnstructuredSource creates a new unstructuredSource with the given config. +func NewUnstructuredSource( + ctx context.Context, + dynamicKubeClient dynamic.Interface, + kubeClient kubernetes.Interface, + namespace, apiVersion, kind, + targetJsonPath, hostnameJsonPath string, + labelSelector labels.Selector, annotationFilter string) (Source, error) { + var ( + targetJsonPathObj *jsonpath.JSONPath + hostnameJsonPathObj *jsonpath.JSONPath + ) + if targetJsonPath != "" { + targetJsonPathObj = jsonpath.New("p") + if err := targetJsonPathObj.Parse(targetJsonPath); err != nil { + return nil, fmt.Errorf("failed to parse targetJsonPath %q as JSONPath expression while initializing unstructured source: %w", targetJsonPath, err) + } + } + if hostnameJsonPath != "" { + hostnameJsonPathObj = jsonpath.New("p") + if err := hostnameJsonPathObj.Parse(hostnameJsonPath); err != nil { + return nil, fmt.Errorf("failed to parse hostnameJsonPath %q as JSONPath expression while initializing unstructured source: %w", hostnameJsonPath, err) + } + } + + // Build a selector to filter resources by annotations. + annotationProtoSelector, err := metav1.ParseToLabelSelector(annotationFilter) + if err != nil { + return nil, fmt.Errorf("failed to parse annotationFilter %q while initializing unstructured source: %w", annotationFilter, err) + } + annotationSelector, err := metav1.LabelSelectorAsSelector(annotationProtoSelector) + if err != nil { + return nil, fmt.Errorf("failed to create annotationSelector while initializing unstructured source: %w", err) + } + + groupVersion, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse apiVersion %q while initializing unstructured source: %w", apiVersion, err) + } + groupVersionResource := groupVersion.WithResource(strings.ToLower(kind) + "s") + + // Determine if the specified API group and resource exists and + // whether or not the API is namespace-scoped or cluster-scoped. + var apiResource *metav1.APIResource + apiResourceList, err := kubeClient.Discovery().ServerResourcesForGroupVersion(apiVersion) + if err != nil { + return nil, fmt.Errorf("failed to list resources for apiVersion %q while initializing unstructured source: %s", apiVersion, err) + } + for _, ar := range apiResourceList.APIResources { + if ar.Kind == kind { + apiResource = &ar + break + } + } + if apiResource == nil { + return nil, fmt.Errorf("failed to find kind %q in apiVersion %q while initializing unstructured source", kind, apiVersion) + } + + if apiResource.Namespaced { + log.Infof("Unstructured source configured for namespace-scoped resource with kind %q in apiVersion %q in namespace %q", kind, apiVersion, namespace) + } else { + log.Infof("Unstructured source configured for cluster-scoped resource with kind %q in apiVersion %q", kind, apiVersion) + } + + // Use the shared informer to listen for add/update/delete for the + // resource in the specified namespace (if it's a namespaced resource). + // If the resource is cluster-scoped, the informer is provided with an + // empty namespace. + // Set resync period to 0, to prevent processing when nothing has changed. + informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( + dynamicKubeClient, + 0, + namespace, + nil) + informer := informerFactory.ForResource(groupVersionResource) + + // Add default resource event handlers to properly initialize informer. + informer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + }, + }, + ) + + informerFactory.Start(ctx.Done()) + + if err := waitForDynamicCacheSync(context.Background(), informerFactory); err != nil { + return nil, fmt.Errorf( + "failed to wait for dynamic client cache to sync while initializing unstructured source: %w", err) + } + + return &unstructuredSource{ + kubeClient: kubeClient, + dynamicKubeClient: dynamicKubeClient, + informer: informer, + targetJsonPath: targetJsonPathObj, + hostnameJsonPath: hostnameJsonPathObj, + namespace: namespace, + namespacedResource: apiResource.Namespaced, + labelSelector: labelSelector, + annotationSelector: annotationSelector, + }, nil +} + +func (us *unstructuredSource) AddEventHandler(ctx context.Context, handler func()) { +} + +// Endpoints returns endpoint objects. +func (us *unstructuredSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + var ( + endpoints []*endpoint.Endpoint + listFn func(labels.Selector) ([]runtime.Object, error) + ) + + // List the resources using the label selector. + lister := us.informer.Lister() + if us.namespacedResource { + listFn = lister.ByNamespace(us.namespace).List + } else { + listFn = lister.List + } + list, err := listFn(us.labelSelector) + if err != nil { + if us.namespacedResource { + return nil, fmt.Errorf( + "failed to list resources in namespace %q in unstructured source: %w", us.namespace, err) + } + return nil, fmt.Errorf( + "failed to list cluster resources in unstructured source: %w", err) + } + + for _, obj := range list { + item, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf( + "failed to assert %[1]T %[1]v is *unstructured.Unstructured", obj) + } + + // If there is an annotation selector then skip any resource + // that does not match the annotation selector. + if !us.annotationSelector.Empty() { + if !us.annotationSelector.Matches(labels.Set(item.GetAnnotations())) { + continue + } + } + + hostnames, err := us.getHostnames(item) + if err != nil { + log.Warnf("failed to get hostname(s) for resource %q: %s", item.GetName(), err) + continue + } + + targets, err := us.getTargets(item) + if err != nil { + log.Warnf("failed to get target(s) for resource %q that has hostnames %s: %s", item.GetName(), hostnames, err) + continue + } + + ttl, err := us.getTTL(item) + if err != nil { + log.Warnf("failed to get TTL for resource %q that has hostnames %s and targets %s: %s", item.GetName(), hostnames, targets, err) + continue + } + + recordType := us.getRecordType(item) + + log.Infof("resource=%q, hostnames=%s, targets=%s, ttl=%v, recordType=%q\n", item.GetName(), hostnames, targets, ttl, recordType) + + // Create an endpoint for each host name. + for i := range hostnames { + endpoints = append( + endpoints, &endpoint.Endpoint{ + Labels: us.getEndpointLabels(item), + DNSName: hostnames[i], + Targets: endpoint.NewTargets(targets...), + RecordType: recordType, + RecordTTL: ttl, + }) + } + } + + return endpoints, nil +} + +func (us *unstructuredSource) getEndpointLabels(item *unstructured.Unstructured) map[string]string { + var val string + if us.namespacedResource { + val = fmt.Sprintf( + "unstructured/%s/%s", + item.GetNamespace(), + item.GetName()) + } else { + val = fmt.Sprintf( + "unstructured/%s", + item.GetName()) + } + return map[string]string{ + endpoint.ResourceLabelKey: val, + } +} + +func (us *unstructuredSource) getTTL(item *unstructured.Unstructured) (endpoint.TTL, error) { + ttl, err := getTTLFromAnnotations(item.GetAnnotations()) + if err != nil { + return 0, err + } + return ttl, nil +} + +func (us *unstructuredSource) getRecordType(item *unstructured.Unstructured) string { + recordType := getRecordTypeFromAnnotations(item.GetAnnotations()) + if recordType == "" { + recordType = endpoint.RecordTypeA + } + return recordType +} + +func (us *unstructuredSource) getTargets(item *unstructured.Unstructured) ([]string, error) { + var targets []string + if us.targetJsonPath == nil { + targets = getTargetsFromTargetAnnotation(item.GetAnnotations()) + } else { + var err error + targets, err = us.executeJsonPath(us.targetJsonPath, item.Object) + if err != nil { + return nil, fmt.Errorf("failed to execute JSONPath while getting targets: %w", err) + } + } + if len(targets) == 0 { + return nil, fmt.Errorf("no targets") + } + return targets, nil +} + +func (us *unstructuredSource) getHostnames(item *unstructured.Unstructured) ([]string, error) { + var hostnames []string + if us.hostnameJsonPath == nil { + hostnames = getHostnamesFromAnnotations(item.GetAnnotations()) + } else { + var err error + hostnames, err = us.executeJsonPath(us.hostnameJsonPath, item.Object) + if err != nil { + return nil, fmt.Errorf("failed to execute JSONPath while getting hostnames: %w", err) + } + } + if len(hostnames) == 0 { + return nil, fmt.Errorf("no hostnames") + } + return hostnames, nil +} + +func (us *unstructuredSource) executeJsonPath(jp *jsonpath.JSONPath, data interface{}) ([]string, error) { + var w bytes.Buffer + if err := jp.Execute(&w, data); err != nil { + return nil, err + } + s := w.String() + + // Trim any surrounding brackets that decorate JSONPath expressions + // that evaluate to a list. + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + + return splitByWhitespaceAndComma(s), nil +} diff --git a/source/unstructured_source_test.go b/source/unstructured_source_test.go new file mode 100644 index 0000000000..52890463fd --- /dev/null +++ b/source/unstructured_source_test.go @@ -0,0 +1,987 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + fakeDynamic "k8s.io/client-go/dynamic/fake" + fakeKube "k8s.io/client-go/kubernetes/fake" + + "sigs.k8s.io/external-dns/endpoint" +) + +type UnstructuredSuite struct { + suite.Suite +} + +func (suite *UnstructuredSuite) SetupTest() { +} + +func TestUnstructuredSource(t *testing.T) { + suite.Run(t, new(UnstructuredSuite)) + t.Run("Interface", testUnstructuredSourceImplementsSource) + t.Run("Endpoints", testUnstructuredSourceEndpoints) +} + +// testUnstructuredSourceImplementsSource tests that unstructuredSource +// is a valid Source. +func testUnstructuredSourceImplementsSource(t *testing.T) { + require.Implements(t, (*Source)(nil), new(unstructuredSource)) +} + +// testUnstructuredSourceEndpoints tests various scenarios of using +// Unstructured source. +func testUnstructuredSourceEndpoints(t *testing.T) { + for _, ti := range []struct { + title string + registeredNamespace string + namespace string + registeredAPIVersion string + apiVersion string + registeredKind string + kind string + targetJsonPath string + hostNameJsonPath string + endpoints []*endpoint.Endpoint + expectEndpoints bool + expectError bool + annotationFilter string + labelFilter string + annotations map[string]string + labels map[string]string + setFn func(*unstructured.Unstructured) + }{ + + { + title: "invalid api version", + registeredAPIVersion: "v1", + apiVersion: "v2", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.data.ip-addr}", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + expectEndpoints: false, + expectError: true, + }, + { + title: "invalid kind", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "FakeConfigMap", + targetJsonPath: "{.data.ip-addr}", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + expectEndpoints: false, + expectError: true, + }, + { + title: "endpoints from ConfigMap within a specific namespace", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.data.ip-addr}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedStringMap( + obj.Object, + map[string]string{"ip-addr": "1.2.3.4"}, + "data", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "endpoints from Pod within a specific namespace", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "endpoints from annotation on ConfigMap within a specific namespace", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.metadata.annotations.target}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + "target": "1.2.3.4", + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "endpoints from labels on Pod within a specific namespace", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.metadata.labels.target}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + labels: map[string]string{ + "target": "1.2.3.4", + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "no endpoints within a specific namespace", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.data.ip-addr}", + namespace: "foo", + registeredNamespace: "bar", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + expectEndpoints: false, + expectError: false, + }, + + { + title: "invalid api resource with no targets", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + expectEndpoints: false, + expectError: false, + }, + { + title: "valid api gvk with single endpoint", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid api gvk with single CNAME endpoint", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.data.hostname}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"def.example.org"}, + RecordType: endpoint.RecordTypeCNAME, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeCNAME, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedStringMap( + obj.Object, + map[string]string{"hostname": "def.example.org"}, + "data", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with single endpoint", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with multiple endpoints from hostnames via a string slice derived from a JSONPath expression that evaluates to a comma-delimited string", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + hostNameJsonPath: `{range .status.hostnames[*]}{@}.example.org,{end}`, + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + { + DNSName: "def.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + unstructured.SetNestedStringSlice( + obj.Object, + []string{"abc", "def"}, + "status", "hostnames", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with multiple endpoints from hostnames from nested objects in status", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + hostNameJsonPath: `{.status.nodes[*].name}`, + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + { + DNSName: "def.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + unstructured.SetNestedSlice( + obj.Object, + []interface{}{ + map[string]interface{}{ + "name": "abc.example.org", + }, + map[string]interface{}{ + "name": "def.example.org", + }, + }, + "status", "nodes", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with multiple endpoints with multiple targets from nested objects in status", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.addrs}", + hostNameJsonPath: `{.status.nodes[*].name}`, + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + { + DNSName: "abc-alias.example.org", + Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedStringSlice( + obj.Object, + []string{"1.2.3.4", "5.6.7.8"}, + "status", "addrs", + ) + unstructured.SetNestedSlice( + obj.Object, + []interface{}{ + map[string]interface{}{ + "name": "abc.example.org", + }, + map[string]interface{}{ + "name": "abc-alias.example.org", + }, + }, + "status", "nodes", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with multiple endpoints from hostnames via string slice in status", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + hostNameJsonPath: "{.status.hostnames}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + { + DNSName: "def.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + unstructured.SetNestedStringSlice( + obj.Object, + []string{"abc.example.org", "def.example.org"}, + "status", "hostnames", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with multiple endpoints from host names via comma-delimited string in status", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + hostNameJsonPath: "{.status.hostnames}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + { + DNSName: "def.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + unstructured.SetNestedField( + obj.Object, + "abc.example.org,def.example.org", + "status", "hostnames", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with endpoint formatted using JSONPath expression", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddr}", + hostNameJsonPath: "{.status.hostname}.example.org", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + }, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + unstructured.SetNestedField( + obj.Object, + "abc", + "status", "hostname", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid cluster-scoped crd gvk with single endpoint", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyClusterScopedCRD", + kind: "MyClusterScopedCRD", + targetJsonPath: "{.status.ipAddr}", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "ipAddr", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid namespace-scoped crd gvk with single endpoint from terminating indexed property", + registeredAPIVersion: "example.com/v1alpha1", + apiVersion: "example.com/v1alpha1", + registeredKind: "MyNamespaceScopedCRD", + kind: "MyNamespaceScopedCRD", + targetJsonPath: "{.status.ipAddrs[0]}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedStringSlice( + obj.Object, + []string{"1.2.3.4", "5.6.7.8"}, + "status", "ipAddrs", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid api gvk with multiple endpoints", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "ConfigMap", + kind: "ConfigMap", + targetJsonPath: "{.data.ip-addr}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + { + DNSName: "def.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org,def.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedStringMap( + obj.Object, + map[string]string{"ip-addr": "1.2.3.4"}, + "data", + ) + }, + expectEndpoints: true, + expectError: false, + }, + { + title: "valid api gvk with annotation and non matching annotation filter", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + annotationFilter: "test=filter_something_else", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: false, + expectError: false, + }, + + { + title: "valid api with annotation and matching annotation filter", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + annotationFilter: "test=filter_something_else", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + annotations: map[string]string{ + "test": "filter_something_else", + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: true, + expectError: false, + }, + + { + title: "valid api gvk with label and non matching label filter", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + labelFilter: "test=that", + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: false, + expectError: false, + }, + { + title: "valid api gvk with label and matching label filter", + registeredAPIVersion: "v1", + apiVersion: "v1", + registeredKind: "Pod", + kind: "Pod", + targetJsonPath: "{.status.podIP}", + namespace: "foo", + registeredNamespace: "foo", + endpoints: []*endpoint.Endpoint{ + { + DNSName: "abc.example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 180, + }, + }, + labelFilter: "test=that", + labels: map[string]string{ + "test": "that", + }, + annotations: map[string]string{ + hostnameAnnotationKey: "abc.example.org", + ttlAnnotationKey: "180", + recordTypeAnnotationKey: endpoint.RecordTypeA, + }, + setFn: func(obj *unstructured.Unstructured) { + unstructured.SetNestedField( + obj.Object, + "1.2.3.4", + "status", "podIP", + ) + }, + expectEndpoints: true, + expectError: false, + }, + } { + ti := ti + t.Run(ti.title, func(t *testing.T) { + t.Parallel() + + groupVersion, err := schema.ParseGroupVersion(ti.apiVersion) + require.NoError(t, err) + + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName( + groupVersion.WithKind(ti.kind), + &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName( + groupVersion.WithKind(ti.kind+"List"), + &unstructured.UnstructuredList{}) + + labelSelector, err := labels.Parse(ti.labelFilter) + require.NoError(t, err) + + fakeKubeClient := fakeKube.NewSimpleClientset() + fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) + ctx := context.Background() + + fakeKubeClient.Resources = []*metav1.APIResourceList{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "APIResourceList", + }, + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Kind: "ConfigMap", + Namespaced: true, + }, + { + Kind: "Pod", + Namespaced: true, + }, + }, + }, + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "APIResourceList", + }, + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Kind: "Deployment", + Namespaced: true, + }, + }, + }, + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "APIResourceList", + }, + GroupVersion: "example.com/v1alpha1", + APIResources: []metav1.APIResource{ + { + Kind: "MyNamespaceScopedCRD", + Namespaced: true, + }, + { + Kind: "MyClusterScopedCRD", + Namespaced: false, + }, + }, + }, + } + + // Create the object in the fake client. + obj := &unstructured.Unstructured{Object: map[string]interface{}{}} + obj.SetAPIVersion(ti.registeredAPIVersion) + obj.SetKind(ti.registeredKind) + obj.SetName("test") + obj.SetNamespace(ti.registeredNamespace) + obj.SetAnnotations(ti.annotations) + obj.SetLabels(ti.labels) + obj.SetGeneration(1) + if ti.setFn != nil { + ti.setFn(obj) + } + groupVersionResource := groupVersion.WithResource(strings.ToLower(ti.registeredKind) + "s") + + var createErr error + if ti.namespace == "" { + _, createErr = fakeDynamicClient.Resource( + groupVersionResource).Create(ctx, obj, metav1.CreateOptions{}) + } else { + _, createErr = fakeDynamicClient.Resource( + groupVersionResource).Namespace( + ti.registeredNamespace).Create(ctx, obj, metav1.CreateOptions{}) + } + require.NoError(t, createErr) + + src, err := NewUnstructuredSource( + ctx, + fakeDynamicClient, + fakeKubeClient, + ti.namespace, + ti.apiVersion, + ti.kind, + ti.targetJsonPath, + ti.hostNameJsonPath, + labelSelector, + ti.annotationFilter) + if ti.expectError { + require.Nil(t, src) + require.Error(t, err) + return + } + require.NotNil(t, src) + require.NoError(t, err) + + receivedEndpoints, err := src.Endpoints(ctx) + if ti.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + if len(receivedEndpoints) == 0 && !ti.expectEndpoints { + return + } + + // Validate received endpoints against expected endpoints. + validateEndpoints(t, receivedEndpoints, ti.endpoints) + }) + } +}