mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-09 16:11:23 +00:00
cmd/{k8s-nameserver,k8s-operator},k8s-operator: add a kube nameserver, make operator deploy it (#11919)
* cmd/k8s-nameserver,k8s-operator: add a nameserver that can resolve ts.net DNS names in cluster. Adds a simple nameserver that can respond to A record queries for ts.net DNS names. It can respond to queries from in-memory records, populated from a ConfigMap mounted at /config. It dynamically updates its records as the ConfigMap contents changes. It will respond with NXDOMAIN to queries for any other record types (AAAA to be implemented in the future). It can respond to queries over UDP or TCP. It runs a miekg/dns DNS server with a single registered handler for ts.net domain names. Queries for other domain names will be refused. The intended use of this is: 1) to allow non-tailnet cluster workloads to talk to HTTPS tailnet services exposed via Tailscale operator egress over HTTPS 2) to allow non-tailnet cluster workloads to talk to workloads in the same cluster that have been exposed to tailnet over their MagicDNS names but on their cluster IPs. DNSConfig CRD can be used to configure the operator to deploy kube nameserver (./cmd/k8s-nameserver) to cluster. Updates tailscale/tailscale#10499 Signed-off-by: Irbe Krumina <irbe@tailscale.com>
This commit is contained in:
@@ -24,6 +24,9 @@ rules:
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["dnsconfigs", "dnsconfigs/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@@ -45,10 +48,10 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
resources: ["statefulsets", "deployments"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
96
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
96
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: DNSConfig
|
||||
listKind: DNSConfigList
|
||||
plural: dnsconfigs
|
||||
shortNames:
|
||||
- dc
|
||||
singular: dnsconfig
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Service IP address of the nameserver
|
||||
jsonPath: .status.nameserverStatus.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
type: object
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
status:
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
type: integer
|
||||
format: int64
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
type: string
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
type: string
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
type: string
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
nameserverStatus:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
4
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
4
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dnsconfig
|
37
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
37
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 5
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nameserver
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nameserver
|
||||
spec:
|
||||
containers:
|
||||
- imagePullPolicy: IfNotPresent
|
||||
name: nameserver
|
||||
ports:
|
||||
- name: tcp
|
||||
protocol: TCP
|
||||
containerPort: 1053
|
||||
- name: udp
|
||||
protocol: UDP
|
||||
containerPort: 1053
|
||||
volumeMounts:
|
||||
- name: dnsconfig
|
||||
mountPath: /config
|
||||
restartPolicy: Always
|
||||
serviceAccount: nameserver
|
||||
serviceAccountName: nameserver
|
||||
volumes:
|
||||
- name: dnsconfig
|
||||
configMap:
|
||||
name: dnsconfig
|
6
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
6
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: nameserver
|
||||
imagePullSecrets:
|
||||
- name: foo
|
16
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
16
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
selector:
|
||||
app: nameserver
|
||||
ports:
|
||||
- name: udp
|
||||
targetPort: 1053
|
||||
port: 53
|
||||
protocol: UDP
|
||||
- name: tcp
|
||||
targetPort: 1053
|
||||
port: 53
|
||||
protocol: TCP
|
@@ -159,6 +159,103 @@ spec:
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: DNSConfig
|
||||
listKind: DNSConfigList
|
||||
plural: dnsconfigs
|
||||
shortNames:
|
||||
- dc
|
||||
singular: dnsconfig
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Service IP address of the nameserver
|
||||
jsonPath: .status.nameserverStatus.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
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:
|
||||
properties:
|
||||
nameserver:
|
||||
properties:
|
||||
image:
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
type: object
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
format: int64
|
||||
type: integer
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
type: string
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
type: string
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
nameserverStatus:
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
@@ -1252,6 +1349,16 @@ rules:
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- apiGroups:
|
||||
- tailscale.com
|
||||
resources:
|
||||
- dnsconfigs
|
||||
- dnsconfigs/status
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@@ -1276,12 +1383,15 @@ rules:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- configmaps
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
- deployments
|
||||
verbs:
|
||||
- '*'
|
||||
---
|
||||
|
@@ -22,9 +22,11 @@ const (
|
||||
operatorDeploymentFilesPath = "cmd/k8s-operator/deploy"
|
||||
connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
|
||||
proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml"
|
||||
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
|
||||
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
|
||||
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
|
||||
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
|
||||
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
|
||||
|
||||
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
|
||||
helmConditionalEnd = "{{- end -}}"
|
||||
@@ -108,7 +110,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// generate places tailscale.com CRDs (currently Connector and ProxyClass) into
|
||||
// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into
|
||||
// the Helm chart templates behind .Values.installCRDs=true condition (true by
|
||||
// default).
|
||||
func generate(baseDir string) error {
|
||||
@@ -140,6 +142,9 @@ func generate(baseDir string) error {
|
||||
if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err)
|
||||
}
|
||||
if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,5 +156,8 @@ func cleanup(baseDir string) error {
|
||||
if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -56,6 +56,9 @@ func Test_generate(t *testing.T) {
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") {
|
||||
t.Errorf("ProxyClass CRD not found in default chart install")
|
||||
}
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD not found in default chart install")
|
||||
}
|
||||
|
||||
// Test that CRDs can be excluded from Helm chart install
|
||||
installContentsWithoutCRD := bytes.NewBuffer([]byte{})
|
||||
@@ -71,4 +74,7 @@ func Test_generate(t *testing.T) {
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") {
|
||||
t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
}
|
||||
|
275
cmd/k8s-operator/nameserver.go
Normal file
275
cmd/k8s-operator/nameserver.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"sigs.k8s.io/yaml"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonNameserverCreationFailed = "NameserverCreationFailed"
|
||||
reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent"
|
||||
|
||||
reasonNameserverCreated = "NameserverCreated"
|
||||
|
||||
messageNameserverCreationFailed = "Failed creating nameserver resources: %v"
|
||||
messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present."
|
||||
)
|
||||
|
||||
// NameserverReconciler knows how to create nameserver resources in cluster in
|
||||
// response to users applying DNSConfig.
|
||||
type NameserverReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
recorder record.EventRecorder
|
||||
clock tstime.Clock
|
||||
tsNamespace string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
managedNameservers set.Slice[types.UID] // one or none
|
||||
}
|
||||
|
||||
var (
|
||||
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
|
||||
)
|
||||
|
||||
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("dnsConfig", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
var dnsCfg tsapi.DNSConfig
|
||||
err = a.Get(ctx, req.NamespacedName, &dnsCfg)
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Request object not found, could have been deleted after reconcile request.
|
||||
logger.Debugf("dnsconfig not found, assuming it was deleted")
|
||||
return reconcile.Result{}, nil
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err)
|
||||
}
|
||||
if !dnsCfg.DeletionTimestamp.IsZero() {
|
||||
ix := xslices.Index(dnsCfg.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
logger.Info("Cleaning up DNSConfig resources")
|
||||
if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil {
|
||||
logger.Errorf("error cleaning up reconciler resource: %v", err)
|
||||
return res, err
|
||||
}
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
logger.Errorf("error removing finalizer: %v", err)
|
||||
return reconcile.Result{}, err
|
||||
}
|
||||
logger.Infof("Nameserver resources cleaned up")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil {
|
||||
err = errors.Wrap(err, updateErr.Error())
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
var dnsCfgs tsapi.DNSConfigList
|
||||
if err := a.List(ctx, &dnsCfgs); err != nil {
|
||||
return res, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||
}
|
||||
if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton
|
||||
msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created."
|
||||
logger.Error(msg)
|
||||
a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
}
|
||||
|
||||
if !slices.Contains(dnsCfg.Finalizers, FinalizerName) {
|
||||
logger.Infof("ensuring nameserver resources")
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
msg := fmt.Sprintf(messageNameserverCreationFailed, err)
|
||||
logger.Error(msg)
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg)
|
||||
}
|
||||
}
|
||||
if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Add(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace},
|
||||
}
|
||||
if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil {
|
||||
return res, fmt.Errorf("error getting Service: %w", err)
|
||||
}
|
||||
if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" {
|
||||
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||
IP: ip,
|
||||
}
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated)
|
||||
}
|
||||
logger.Info("nameserver Service does not have an IP address allocated, waiting...")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
func nameserverResourceLabels(name, namespace string) map[string]string {
|
||||
labels := childResourceLabels(name, namespace, "nameserver")
|
||||
labels["app.kubernetes.io/name"] = "tailscale"
|
||||
labels["app.kubernetes.io/component"] = "nameserver"
|
||||
return labels
|
||||
}
|
||||
|
||||
func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace)
|
||||
dCfg := &deployConfig{
|
||||
ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))},
|
||||
namespace: a.tsNamespace,
|
||||
labels: labels,
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image.Repo != "" {
|
||||
dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image.Tag != "" {
|
||||
dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag
|
||||
}
|
||||
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
|
||||
if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil {
|
||||
return fmt.Errorf("error reconciling %s: %w", deployable.kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCleanup removes DNSConfig from being tracked. The cluster resources
|
||||
// created, will be automatically garbage collected as they are owned by the
|
||||
// DNSConfig.
|
||||
func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Remove(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
return nil
|
||||
}
|
||||
|
||||
type deployable struct {
|
||||
kind string
|
||||
updateObj func(context.Context, *deployConfig, client.Client) error
|
||||
}
|
||||
|
||||
type deployConfig struct {
|
||||
imageRepo string
|
||||
imageTag string
|
||||
labels map[string]string
|
||||
ownerRefs []metav1.OwnerReference
|
||||
namespace string
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed deploy/manifests/nameserver/cm.yaml
|
||||
cmYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/deploy.yaml
|
||||
deployYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/sa.yaml
|
||||
saYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/svc.yaml
|
||||
svcYaml []byte
|
||||
|
||||
deployDeployable = deployable{
|
||||
kind: "Deployment",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
d := new(appsv1.Deployment)
|
||||
if err := yaml.Unmarshal(deployYaml, &d); err != nil {
|
||||
return fmt.Errorf("error unmarshalling Deployment yaml: %w", err)
|
||||
}
|
||||
d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
|
||||
d.ObjectMeta.Namespace = cfg.namespace
|
||||
d.ObjectMeta.Labels = cfg.labels
|
||||
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
updateF := func(oldD *appsv1.Deployment) {
|
||||
oldD.Spec = d.Spec
|
||||
}
|
||||
_, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF)
|
||||
return err
|
||||
},
|
||||
}
|
||||
saDeployable = deployable{
|
||||
kind: "ServiceAccount",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
sa := new(corev1.ServiceAccount)
|
||||
if err := yaml.Unmarshal(saYaml, &sa); err != nil {
|
||||
return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err)
|
||||
}
|
||||
sa.ObjectMeta.Labels = cfg.labels
|
||||
sa.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
sa.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
svcDeployable = deployable{
|
||||
kind: "Service",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
svc := new(corev1.Service)
|
||||
if err := yaml.Unmarshal(svcYaml, &svc); err != nil {
|
||||
return fmt.Errorf("error unmarshalling Service yaml: %w", err)
|
||||
}
|
||||
svc.ObjectMeta.Labels = cfg.labels
|
||||
svc.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
svc.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmDeployable = deployable{
|
||||
kind: "ConfigMap",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
cm := new(corev1.ConfigMap)
|
||||
if err := yaml.Unmarshal(cmYaml, &cm); err != nil {
|
||||
return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err)
|
||||
}
|
||||
cm.ObjectMeta.Labels = cfg.labels
|
||||
cm.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
cm.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
)
|
118
cmd/k8s-operator/nameserver_test.go
Normal file
118
cmd/k8s-operator/nameserver_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/yaml"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestNameserverReconciler(t *testing.T) {
|
||||
dnsCfg := &tsapi.DNSConfig{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.DNSConfigSpec{
|
||||
Nameserver: &tsapi.Nameserver{
|
||||
Image: &tsapi.Image{
|
||||
Repo: "test",
|
||||
Tag: "v0.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(dnsCfg).
|
||||
WithStatusSubresource(dnsCfg).
|
||||
Build()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
nr := &NameserverReconciler{
|
||||
Client: fc,
|
||||
clock: cl,
|
||||
logger: zl.Sugar(),
|
||||
tsNamespace: "tailscale",
|
||||
}
|
||||
expectReconciled(t, nr, "", "test")
|
||||
// Verify that nameserver Deployment has been created and has the expected fields.
|
||||
wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}}
|
||||
if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil {
|
||||
t.Fatalf("unmarshalling yaml: %v", err)
|
||||
}
|
||||
dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))
|
||||
wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef}
|
||||
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1"
|
||||
wantsDeploy.Namespace = "tailscale"
|
||||
labels := nameserverResourceLabels("test", "tailscale")
|
||||
wantsDeploy.ObjectMeta.Labels = labels
|
||||
expectEqual(t, fc, wantsDeploy, nil)
|
||||
|
||||
// Verify that DNSConfig advertizes the nameserver's Service IP address,
|
||||
// has the ready status condition and tailscale finalizer.
|
||||
mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) {
|
||||
svc.Spec.ClusterIP = "1.2.3.4"
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
dnsCfg.Finalizers = []string{FinalizerName}
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.NameserverReady,
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: reasonNameserverCreated,
|
||||
Message: reasonNameserverCreated,
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
expectEqual(t, fc, dnsCfg, nil)
|
||||
|
||||
// // Verify that nameserver image gets updated to match DNSConfig spec.
|
||||
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
|
||||
dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2"
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2"
|
||||
expectEqual(t, fc, wantsDeploy, nil)
|
||||
|
||||
// Verify that when another actor sets ConfigMap data, it does not get
|
||||
// overwritten by nameserver reconciler.
|
||||
dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}}
|
||||
bs, err := json.Marshal(dnsRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling ConfigMap contents: %v", err)
|
||||
}
|
||||
mustUpdate(t, fc, "tailscale", "dnsconfig", func(cm *corev1.ConfigMap) {
|
||||
mak.Set(&cm.Data, "dns.json", string(bs))
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsconfig",
|
||||
Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}},
|
||||
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
|
||||
Data: map[string]string{"dns.json": string(bs)},
|
||||
}
|
||||
expectEqual(t, fc, wantCm, nil)
|
||||
}
|
@@ -223,8 +223,11 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
// resources that we GET via the controller manager's client.
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&corev1.ServiceAccount{}: nsFilter,
|
||||
&corev1.ConfigMap{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&appsv1.Deployment{}: nsFilter,
|
||||
},
|
||||
},
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
@@ -309,7 +312,28 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
startlog.Fatalf("could not create connector reconciler: %v", err)
|
||||
}
|
||||
// TODO (irbekrm): switch to metadata-only watches for resources whose
|
||||
// spec we don't need to inspect to reduce memory consumption
|
||||
// https://github.com/kubernetes-sigs/controller-runtime/issues/1159
|
||||
nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.DNSConfig{}).
|
||||
Watches(&appsv1.Deployment{}, nameserverFilter).
|
||||
Watches(&corev1.ConfigMap{}, nameserverFilter).
|
||||
Watches(&corev1.Service{}, nameserverFilter).
|
||||
Watches(&corev1.ServiceAccount{}, nameserverFilter).
|
||||
Complete(&NameserverReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: tsNamespace,
|
||||
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("nameserver-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create nameserver reconciler: %v", err)
|
||||
}
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyClass{}).
|
||||
|
@@ -1196,7 +1196,6 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
}
|
||||
|
||||
func Test_isMagicDNSName(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
|
@@ -3,8 +3,6 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
Reference in New Issue
Block a user