pkg/manifests/external_dns.go (553 lines of code) (raw):

package manifests import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "path" "sort" "strings" "github.com/Azure/aks-app-routing-operator/api/v1alpha1" "github.com/Azure/aks-app-routing-operator/pkg/config" "github.com/Azure/aks-app-routing-operator/pkg/util" "github.com/Azure/go-autorest/autorest/azure" "github.com/Azure/go-autorest/autorest/to" "k8s.io/apimachinery/pkg/runtime/schema" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) const ( replicas = 1 // this must stay at 1 unless external-dns adds support for multiple replicas https://github.com/kubernetes-sigs/external-dns/issues/2430 k8sNameKey = "app.kubernetes.io/name" externalDnsResourceName = "external-dns" txtWildcardReplacement = "approutingwildcard" ) type IdentityType int const ( IdentityTypeMSI IdentityType = iota IdentityTypeWorkloadIdentity ) func (i IdentityType) externalDNSIdentityConfiguration() string { switch i { case IdentityTypeWorkloadIdentity: return "useWorkloadIdentityExtension" default: return "useManagedIdentityExtension" } } type ResourceType int const ( ResourceTypeIngress ResourceType = iota ResourceTypeGateway ) func (rt ResourceType) String() string { switch rt { case ResourceTypeGateway: return "Gateway" default: return "Ingress" } } func (rt ResourceType) generateResourceDeploymentArgs() []string { switch rt { case ResourceTypeGateway: return []string{ "--source=gateway-httproute", "--source=gateway-grpcroute", } default: return []string{"--source=ingress"} } } func (rt ResourceType) generateRBACRules(dnsconfig *ExternalDnsConfig) []rbacv1.PolicyRule { switch rt { case ResourceTypeGateway: ret := []rbacv1.PolicyRule{ { APIGroups: []string{"gateway.networking.k8s.io"}, Resources: []string{"gateways", "httproutes", "grpcroutes"}, Verbs: []string{"get", "watch", "list"}, }, } if !dnsconfig.isNamespaced { ret = append(ret, rbacv1.PolicyRule{ APIGroups: []string{""}, Resources: []string{"namespaces"}, Verbs: []string{"get", "watch", "list"}, }) } return ret default: return []rbacv1.PolicyRule{ { APIGroups: []string{"extensions", "networking.k8s.io"}, Resources: []string{"ingresses"}, Verbs: []string{"get", "watch", "list"}, }, } } } // OldExternalDnsGks is a slice of GroupKinds that were previously used by ExternalDns. // If the manifests used by app routing's external dns removes a GroupKind be sure to add // it here to clean it up var OldExternalDnsGks []schema.GroupKind type Provider int const ( PublicProvider Provider = iota PrivateProvider ) func (p Provider) string() string { switch p { case PublicProvider: return "azure" case PrivateProvider: return "azure-private-dns" default: return "" } } // InputExternalDNSConfig is the input configuration to generate ExternalDNSConfigs from the CRD or MC-level configuration type InputExternalDNSConfig struct { TenantId, ClientId, InputServiceAccount, Namespace, InputResourceName string // Provider is specified when an InputConfig is coming from the MC External DNS Reconciler, since no zones may be provided for the clean case Provider *Provider // IdentityType can either be MSI or WorkloadIdentity IdentityType IdentityType // ResourceTypes refer to the resource types that ExternalDNS should look for to configure DNS. These can include Gateway and/or Ingress ResourceTypes map[ResourceType]struct{} // DnsZoneresourceIDs contains the DNS zones that ExternalDNS will use to configure DNS DnsZoneresourceIDs []string // Filters contains various filters that ExternalDNS will use to filter resources it scans for DNS configuration Filters *v1alpha1.ExternalDNSFilters // IsNamespaced is true if the ExternalDNS deployment should only scan for resources in the resource namespace, and false if it should scan all namespaces IsNamespaced bool } // ExternalDnsConfig contains externaldns resources based on input configuration type ExternalDnsConfig struct { // internally exposed tenantId, subscription, resourceGroup, clientId, serviceAccountName, namespace, resourceName string identityType IdentityType resourceTypes map[ResourceType]struct{} provider Provider isNamespaced bool // crd-specific specific fields routeAndIngressLabelSelector string gatewayLabelSelector string // externally exposed resources []client.Object labels map[string]string dnsZoneResourceIDs []string } func (e *ExternalDnsConfig) Resources() []client.Object { return e.resources } func (e *ExternalDnsConfig) Labels() map[string]string { return e.labels } func (e *ExternalDnsConfig) DnsZoneResourceIds() []string { return e.dnsZoneResourceIDs } func NewExternalDNSConfig(conf *config.Config, inputConfig InputExternalDNSConfig) (*ExternalDnsConfig, error) { // valid values for enums if inputConfig.IdentityType != IdentityTypeMSI && inputConfig.IdentityType != IdentityTypeWorkloadIdentity { return nil, fmt.Errorf("invalid identity type: %v", inputConfig.IdentityType) } _, containsGateway := inputConfig.ResourceTypes[ResourceTypeGateway] if containsGateway && inputConfig.IdentityType != IdentityTypeWorkloadIdentity { return nil, errors.New("gateway resource type can only be used with workload identity") } var firstZoneResourceType string var firstZoneSub string var firstZoneRg string var provider Provider if len(inputConfig.DnsZoneresourceIDs) > 0 { firstZone, err := azure.ParseResourceID(inputConfig.DnsZoneresourceIDs[0]) if err != nil { return nil, fmt.Errorf("invalid dns zone resource id: %s", inputConfig.DnsZoneresourceIDs[0]) } firstZoneResourceType = firstZone.ResourceType firstZoneSub = firstZone.SubscriptionID firstZoneRg = firstZone.ResourceGroup // for some reason this passes tests without the if condition when arr has len 0 or 1, but I still feel weird about not having it if len(inputConfig.DnsZoneresourceIDs) > 1 { for _, zone := range inputConfig.DnsZoneresourceIDs[1:] { parsedZone, err := azure.ParseResourceID(zone) if err != nil { return nil, fmt.Errorf("invalid dns zone resource id: %s", zone) } if !strings.EqualFold(parsedZone.ResourceType, firstZoneResourceType) { return nil, fmt.Errorf("all DNS zones must be of the same type, found zones with resourcetypes %s and %s", firstZoneResourceType, parsedZone.ResourceType) } if err := config.ValidateProviderSubAndRg(parsedZone, firstZoneSub, firstZoneRg); err != nil { return nil, err } } } switch strings.ToLower(firstZoneResourceType) { case config.PrivateZoneType: provider = PrivateProvider case config.PublicZoneType: provider = PublicProvider default: return nil, fmt.Errorf("invalid resource type %s", firstZoneResourceType) } } else { // if no zones provided, this must be coming from the original externalDNS reconciler, in which case, read config from input to determine resources to clean if inputConfig.Provider == nil { return nil, errors.New("provider must be specified via inputconfig if no DNS zones are provided") } provider = *inputConfig.Provider } var resourceName string switch inputConfig.InputResourceName { case "": switch provider { case PrivateProvider: resourceName = externalDnsResourceName + "-private" default: resourceName = externalDnsResourceName } default: resourceName = inputConfig.InputResourceName + "-" + externalDnsResourceName } if inputConfig.IdentityType == IdentityTypeWorkloadIdentity && inputConfig.InputServiceAccount == "" { return nil, errors.New("workload identity requires a service account name") } var serviceAccount string switch inputConfig.IdentityType { case IdentityTypeWorkloadIdentity: serviceAccount = inputConfig.InputServiceAccount default: serviceAccount = resourceName } ret := &ExternalDnsConfig{ resourceName: resourceName, tenantId: inputConfig.TenantId, subscription: firstZoneSub, resourceGroup: firstZoneRg, clientId: inputConfig.ClientId, serviceAccountName: serviceAccount, namespace: inputConfig.Namespace, identityType: inputConfig.IdentityType, resourceTypes: inputConfig.ResourceTypes, provider: provider, dnsZoneResourceIDs: inputConfig.DnsZoneresourceIDs, isNamespaced: inputConfig.IsNamespaced, } if inputConfig.Filters != nil { gatewayLabel, err := parseLabel(inputConfig.Filters.GatewayLabelSelector) if err != nil { return nil, fmt.Errorf("parsing gateway label selector: %w", err) } routeAndIngressLabel, err := parseLabel(inputConfig.Filters.RouteAndIngressLabelSelector) if err != nil { return nil, fmt.Errorf("parsing route and ingress label selector: %w", err) } ret.gatewayLabelSelector = gatewayLabel ret.routeAndIngressLabelSelector = routeAndIngressLabel } ret.resources = externalDnsResources(conf, []*ExternalDnsConfig{ret}) ret.labels = externalDNSLabels(ret) return ret, nil } func parseLabel(filterString *string) (string, error) { if filterString == nil || *filterString == "" { return "", nil } parts := strings.Split(*filterString, "=") if len(parts) != 2 { return "", fmt.Errorf("invalid label selector format: %s", *filterString) } return parts[0] + "==" + parts[1], nil } func externalDNSLabels(e *ExternalDnsConfig) map[string]string { labels := map[string]string{ k8sNameKey: e.resourceName, } return labels } // externalDnsResources returns Kubernetes objects required for external dns func externalDnsResources(conf *config.Config, externalDnsConfigs []*ExternalDnsConfig) []client.Object { var objs []client.Object namespaces := make(map[string]bool) for _, dnsConfig := range externalDnsConfigs { // Can safely assume the namespace exists if using kube-system if _, seen := namespaces[dnsConfig.namespace]; dnsConfig.namespace != "" && dnsConfig.namespace != "kube-system" && !seen { namespaces[dnsConfig.namespace] = true objs = append(objs, Namespace(conf, dnsConfig.namespace)) } objs = append(objs, externalDnsResourcesFromConfig(conf, dnsConfig)...) } return objs } func externalDnsResourcesFromConfig(conf *config.Config, externalDnsConfig *ExternalDnsConfig) []client.Object { var objs []client.Object if externalDnsConfig.identityType == IdentityTypeMSI { objs = append(objs, newExternalDNSServiceAccount(externalDnsConfig)) } if externalDnsConfig.isNamespaced { objs = append(objs, newExternalDNSRole(externalDnsConfig)) objs = append(objs, newExternalDNSRoleBinding(conf, externalDnsConfig)) } else { objs = append(objs, newExternalDNSClusterRole(externalDnsConfig)) objs = append(objs, newExternalDNSClusterRoleBinding(conf, externalDnsConfig)) } dnsCm, dnsCmHash := newExternalDNSConfigMap(conf, externalDnsConfig) objs = append(objs, dnsCm) objs = append(objs, newExternalDNSDeployment(conf, externalDnsConfig, dnsCmHash)) for _, obj := range objs { l := util.MergeMaps(obj.GetLabels(), externalDNSLabels(externalDnsConfig)) obj.SetLabels(l) } return objs } func newExternalDNSServiceAccount(externalDnsConfig *ExternalDnsConfig) *corev1.ServiceAccount { return &corev1.ServiceAccount{ TypeMeta: metav1.TypeMeta{ Kind: "ServiceAccount", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Namespace: externalDnsConfig.namespace, Labels: GetTopLevelLabels(), }, } } func newExternalDNSClusterRole(externalDnsConfig *ExternalDnsConfig) *rbacv1.ClusterRole { role := &rbacv1.ClusterRole{ TypeMeta: metav1.TypeMeta{ Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Labels: GetTopLevelLabels(), }, Rules: []rbacv1.PolicyRule{ { APIGroups: []string{""}, Resources: []string{"endpoints", "pods", "services", "configmaps"}, Verbs: []string{"get", "watch", "list"}, }, { APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get", "watch", "list"}, }, }, } // sort for fixture tests sortedRts := make([]ResourceType, 0, len(externalDnsConfig.resourceTypes)) for resourceType := range externalDnsConfig.resourceTypes { sortedRts = append(sortedRts, resourceType) } sort.Slice(sortedRts, func(i, j int) bool { return sortedRts[i] < sortedRts[j] }) for _, resourceType := range sortedRts { role.Rules = append(role.Rules, resourceType.generateRBACRules(externalDnsConfig)...) } return role } func newExternalDNSRole(externalDnsConfig *ExternalDnsConfig) *rbacv1.Role { role := &rbacv1.Role{ TypeMeta: metav1.TypeMeta{ Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Namespace: externalDnsConfig.namespace, Labels: GetTopLevelLabels(), }, Rules: []rbacv1.PolicyRule{ { APIGroups: []string{""}, Resources: []string{"endpoints", "pods", "services", "configmaps"}, Verbs: []string{"get", "watch", "list"}, }, { APIGroups: []string{""}, Resources: []string{"nodes"}, Verbs: []string{"get", "watch", "list"}, }, }, } // sort for fixture tests sortedRts := make([]ResourceType, 0, len(externalDnsConfig.resourceTypes)) for resourceType := range externalDnsConfig.resourceTypes { sortedRts = append(sortedRts, resourceType) } sort.Slice(sortedRts, func(i, j int) bool { return sortedRts[i] < sortedRts[j] }) for _, resourceType := range sortedRts { role.Rules = append(role.Rules, resourceType.generateRBACRules(externalDnsConfig)...) } return role } func newExternalDNSClusterRoleBinding(conf *config.Config, externalDnsConfig *ExternalDnsConfig) *rbacv1.ClusterRoleBinding { ret := &rbacv1.ClusterRoleBinding{ TypeMeta: metav1.TypeMeta{ Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Labels: GetTopLevelLabels(), }, RoleRef: rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: externalDnsConfig.resourceName, }, Subjects: []rbacv1.Subject{{ Kind: "ServiceAccount", Name: externalDnsConfig.serviceAccountName, Namespace: externalDnsConfig.namespace, }}, } return ret } func newExternalDNSRoleBinding(conf *config.Config, externalDnsConfig *ExternalDnsConfig) *rbacv1.RoleBinding { ret := &rbacv1.RoleBinding{ TypeMeta: metav1.TypeMeta{ Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Namespace: externalDnsConfig.namespace, Labels: GetTopLevelLabels(), }, RoleRef: rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: externalDnsConfig.resourceName, }, Subjects: []rbacv1.Subject{{ Kind: "ServiceAccount", Name: externalDnsConfig.serviceAccountName, Namespace: externalDnsConfig.namespace, }}, } return ret } func newExternalDNSConfigMap(conf *config.Config, externalDnsConfig *ExternalDnsConfig) (*corev1.ConfigMap, string) { jsMap := map[string]interface{}{ "tenantId": externalDnsConfig.tenantId, "subscriptionId": externalDnsConfig.subscription, "resourceGroup": externalDnsConfig.resourceGroup, "cloud": conf.Cloud, "location": conf.Location, } jsMap[externalDnsConfig.identityType.externalDNSIdentityConfiguration()] = true if externalDnsConfig.identityType == IdentityTypeMSI { jsMap["userAssignedIdentityID"] = externalDnsConfig.clientId } js, err := json.Marshal(&jsMap) if err != nil { panic(err) } hash := sha256.Sum256(js) return &corev1.ConfigMap{ TypeMeta: metav1.TypeMeta{ Kind: "ConfigMap", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Namespace: externalDnsConfig.namespace, Labels: GetTopLevelLabels(), }, Data: map[string]string{ "azure.json": string(js), }, }, hex.EncodeToString(hash[:]) } func newExternalDNSDeployment(conf *config.Config, externalDnsConfig *ExternalDnsConfig, configMapHash string) *appsv1.Deployment { domainFilters := []string{} for _, zoneId := range externalDnsConfig.dnsZoneResourceIDs { parsedZone, err := azure.ParseResourceID(zoneId) if err != nil { continue } domainFilters = append(domainFilters, fmt.Sprintf("--domain-filter=%s", parsedZone.ResourceName)) } podLabels := GetTopLevelLabels() podLabels["app"] = externalDnsConfig.resourceName podLabels["checksum/configmap"] = configMapHash[:16] serviceAccount := externalDnsConfig.serviceAccountName deploymentArgs := []string{ "--provider=" + externalDnsConfig.provider.string(), "--interval=" + conf.DnsSyncInterval.String(), "--txt-owner-id=" + conf.ClusterUid, "--txt-wildcard-replacement=" + txtWildcardReplacement, } deploymentArgs = append(deploymentArgs, labelSelectorDeploymentArgs(externalDnsConfig)...) resourceTypeArgs := make([]string, 0) for resourceType := range externalDnsConfig.resourceTypes { resourceTypeArgs = append(resourceTypeArgs, resourceType.generateResourceDeploymentArgs()...) } sort.Slice(resourceTypeArgs, func(i, j int) bool { return resourceTypeArgs[i] < resourceTypeArgs[j] }) deploymentArgs = append(deploymentArgs, resourceTypeArgs...) deploymentArgs = append(deploymentArgs, domainFilters...) return &appsv1.Deployment{ TypeMeta: metav1.TypeMeta{ Kind: "Deployment", APIVersion: "apps/v1", }, ObjectMeta: metav1.ObjectMeta{ Name: externalDnsConfig.resourceName, Namespace: externalDnsConfig.namespace, Labels: GetTopLevelLabels(), }, Spec: appsv1.DeploymentSpec{ Replicas: to.Int32Ptr(replicas), RevisionHistoryLimit: util.Int32Ptr(2), Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": externalDnsConfig.resourceName}}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: podLabels, }, Spec: *WithPreferSystemNodes(&corev1.PodSpec{ ServiceAccountName: serviceAccount, Containers: []corev1.Container{*withLivenessProbeMatchingReadiness(withTypicalReadinessProbe(7979, &corev1.Container{ Name: "controller", Image: path.Join(conf.Registry, "/oss/v2/kubernetes/external-dns:v0.15.0"), Args: deploymentArgs, VolumeMounts: []corev1.VolumeMount{{ Name: "azure-config", MountPath: "/etc/kubernetes", ReadOnly: true, }}, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceMemory: resource.MustParse("250Mi"), }, Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), corev1.ResourceMemory: resource.MustParse("250Mi"), }, }, SecurityContext: &corev1.SecurityContext{ Privileged: util.ToPtr(false), AllowPrivilegeEscalation: util.ToPtr(false), ReadOnlyRootFilesystem: util.ToPtr(true), RunAsNonRoot: util.ToPtr(true), RunAsUser: util.Int64Ptr(65532), RunAsGroup: util.Int64Ptr(65532), Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{"ALL"}, }, }, }))}, Volumes: []corev1.Volume{{ Name: "azure-config", VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ Name: externalDnsConfig.resourceName, }, }, }, }}, }), }, }, } } func labelSelectorDeploymentArgs(e *ExternalDnsConfig) []string { ret := make([]string, 0) if e.gatewayLabelSelector != "" { ret = append(ret, "--gateway-label-filter="+e.gatewayLabelSelector) } if e.routeAndIngressLabelSelector != "" { ret = append(ret, "--label-filter="+e.routeAndIngressLabelSelector) } return ret }