cmd/k8s-operator,k8s-operator: Allow the use of multiple tailnets (#18344)
This commit contains the implementation of multi-tailnet support within the Kubernetes Operator
Each of our custom resources now expose the `spec.tailnet` field. This field is a string that must match the name of an existing `Tailnet` resource. A `Tailnet` resource looks like this:
```yaml
apiVersion: tailscale.com/v1alpha1
kind: Tailnet
metadata:
name: example # This is the name that must be referenced by other resources
spec:
credentials:
secretName: example-oauth
```
Each `Tailnet` references a `Secret` resource that contains a set of oauth credentials. This secret must be created in the same namespace as the operator:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: example-oauth # This is the name that's referenced by the Tailnet resource.
namespace: tailscale
stringData:
client_id: "client-id"
client_secret: "client-secret"
```
When created, the operator performs a basic check that the oauth client has access to all required scopes. This is done using read actions on devices, keys & services. While this doesn't capture a missing "write" permission, it catches completely missing permissions. Once this check passes, the `Tailnet` moves into a ready state and can be referenced. Attempting to use a `Tailnet` in a non-ready state will stall the deployment of `Connector`s, `ProxyGroup`s and `Recorder`s until the `Tailnet` becomes ready.
The `spec.tailnet` field informs the operator that a `Connector`, `ProxyGroup`, or `Recorder` must be given an auth key generated using the specified oauth client. For backwards compatibility, the set of credentials the operator is configured with are considered the default. That is, where `spec.tailnet` is not set, the resource will be deployed in the same tailnet as the operator.
Updates https://github.com/tailscale/corp/issues/34561
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
- [ProxyGroupList](#proxygrouplist)
|
||||
- [Recorder](#recorder)
|
||||
- [RecorderList](#recorderlist)
|
||||
- [Tailnet](#tailnet)
|
||||
- [TailnetList](#tailnetlist)
|
||||
|
||||
|
||||
|
||||
@@ -139,6 +141,7 @@ _Appears in:_
|
||||
| `appConnector` _[AppConnector](#appconnector)_ | AppConnector defines whether the Connector device should act as a Tailscale app connector. A Connector that is<br />configured as an app connector cannot be a subnet router or an exit node. If this field is unset, the<br />Connector does not act as an app connector.<br />Note that you will need to manually configure the permissions and the domains for the app connector via the<br />Admin panel.<br />Note also that the main tested and supported use case of this config option is to deploy an app connector on<br />Kubernetes to access SaaS applications available on the public internet. Using the app connector to expose<br />cluster workloads or other internal workloads to tailnet might work, but this is not a use case that we have<br />tested or optimised for.<br />If you are using the app connector to access SaaS applications because you need a predictable egress IP that<br />can be whitelisted, it is also your responsibility to ensure that cluster traffic from the connector flows<br />via that predictable IP, for example by enforcing that cluster egress traffic is routed via an egress NAT<br />device with a static IP address.<br />https://tailscale.com/kb/1281/app-connectors | | |
|
||||
| `exitNode` _boolean_ | ExitNode defines whether the Connector device should act as a Tailscale exit node. Defaults to false.<br />This field is mutually exclusive with the appConnector field.<br />https://tailscale.com/kb/1103/exit-nodes | | |
|
||||
| `replicas` _integer_ | Replicas specifies how many devices to create. Set this to enable<br />high availability for app connectors, subnet routers, or exit nodes.<br />https://tailscale.com/kb/1115/high-availability. Defaults to 1. | | Minimum: 0 <br /> |
|
||||
| `tailnet` _string_ | Tailnet specifies the tailnet this Connector should join. If blank, the default tailnet is used. When set, this<br />name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set. | | |
|
||||
|
||||
|
||||
#### ConnectorStatus
|
||||
@@ -741,6 +744,7 @@ _Appears in:_
|
||||
| `hostnamePrefix` _[HostnamePrefix](#hostnameprefix)_ | HostnamePrefix is the hostname prefix to use for tailnet devices created<br />by the ProxyGroup. Each device will have the integer number from its<br />StatefulSet pod appended to this prefix to form the full hostname.<br />HostnamePrefix can contain lower case letters, numbers and dashes, it<br />must not start with a dash and must be between 1 and 62 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}$` <br />Type: string <br /> |
|
||||
| `proxyClass` _string_ | ProxyClass is the name of the ProxyClass custom resource that contains<br />configuration options that should be applied to the resources created<br />for this ProxyGroup. If unset, and there is no default ProxyClass<br />configured, the operator will create resources with the default<br />configuration. | | |
|
||||
| `kubeAPIServer` _[KubeAPIServerConfig](#kubeapiserverconfig)_ | KubeAPIServer contains configuration specific to the kube-apiserver<br />ProxyGroup type. This field is only used when Type is set to "kube-apiserver". | | |
|
||||
| `tailnet` _string_ | Tailnet specifies the tailnet this ProxyGroup should join. If blank, the default tailnet is used. When set, this<br />name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set. | | |
|
||||
|
||||
|
||||
#### ProxyGroupStatus
|
||||
@@ -901,6 +905,7 @@ _Appears in:_
|
||||
| `enableUI` _boolean_ | Set to true to enable the Recorder UI. The UI lists and plays recorded sessions.<br />The UI will be served at <MagicDNS name of the recorder>:443. Defaults to false.<br />Corresponds to --ui tsrecorder flag https://tailscale.com/kb/1246/tailscale-ssh-session-recording#deploy-a-recorder-node.<br />Required if S3 storage is not set up, to ensure that recordings are accessible. | | |
|
||||
| `storage` _[Storage](#storage)_ | Configure where to store session recordings. By default, recordings will<br />be stored in a local ephemeral volume, and will not be persisted past the<br />lifetime of a specific pod. | | |
|
||||
| `replicas` _integer_ | Replicas specifies how many instances of tsrecorder to run. Defaults to 1. | | Minimum: 0 <br /> |
|
||||
| `tailnet` _string_ | Tailnet specifies the tailnet this Recorder should join. If blank, the default tailnet is used. When set, this<br />name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set. | | |
|
||||
|
||||
|
||||
#### RecorderStatefulSet
|
||||
@@ -1154,6 +1159,44 @@ _Appears in:_
|
||||
|
||||
|
||||
|
||||
#### Tailnet
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_Appears in:_
|
||||
- [TailnetList](#tailnetlist)
|
||||
|
||||
| Field | Description | Default | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | |
|
||||
| `kind` _string_ | `Tailnet` | | |
|
||||
| `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | |
|
||||
| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | |
|
||||
| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
|
||||
| `spec` _[TailnetSpec](#tailnetspec)_ | Spec describes the desired state of the Tailnet.<br />More info:<br />https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | |
|
||||
| `status` _[TailnetStatus](#tailnetstatus)_ | Status describes the status of the Tailnet. This is set<br />and managed by the Tailscale operator. | | |
|
||||
|
||||
|
||||
#### TailnetCredentials
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_Appears in:_
|
||||
- [TailnetSpec](#tailnetspec)
|
||||
|
||||
| Field | Description | Default | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| `secretName` _string_ | The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and<br />"client_secret". | | |
|
||||
|
||||
|
||||
#### TailnetDevice
|
||||
|
||||
|
||||
@@ -1172,6 +1215,59 @@ _Appears in:_
|
||||
| `staticEndpoints` _string array_ | StaticEndpoints are user configured, 'static' endpoints by which tailnet peers can reach this device. | | |
|
||||
|
||||
|
||||
#### TailnetList
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
| Field | Description | Default | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | |
|
||||
| `kind` _string_ | `TailnetList` | | |
|
||||
| `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | |
|
||||
| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | |
|
||||
| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
|
||||
| `items` _[Tailnet](#tailnet) array_ | | | |
|
||||
|
||||
|
||||
#### TailnetSpec
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_Appears in:_
|
||||
- [Tailnet](#tailnet)
|
||||
|
||||
| Field | Description | Default | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| `loginUrl` _string_ | URL of the control plane to be used by all resources managed by the operator using this Tailnet. | | |
|
||||
| `credentials` _[TailnetCredentials](#tailnetcredentials)_ | Denotes the location of the OAuth credentials to use for authenticating with this Tailnet. | | |
|
||||
|
||||
|
||||
#### TailnetStatus
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_Appears in:_
|
||||
- [Tailnet](#tailnet)
|
||||
|
||||
| Field | Description | Default | Validation |
|
||||
| --- | --- | --- | --- |
|
||||
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | | | |
|
||||
|
||||
|
||||
#### TailscaleConfig
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
&RecorderList{},
|
||||
&ProxyGroup{},
|
||||
&ProxyGroupList{},
|
||||
&Tailnet{},
|
||||
&TailnetList{},
|
||||
)
|
||||
|
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||
|
||||
@@ -133,6 +133,12 @@ type ConnectorSpec struct {
|
||||
// +optional
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
Replicas *int32 `json:"replicas,omitempty"`
|
||||
|
||||
// Tailnet specifies the tailnet this Connector should join. If blank, the default tailnet is used. When set, this
|
||||
// name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
|
||||
// +optional
|
||||
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Connector tailnet is immutable"
|
||||
Tailnet string `json:"tailnet,omitempty"`
|
||||
}
|
||||
|
||||
// SubnetRouter defines subnet routes that should be exposed to tailnet via a
|
||||
|
||||
@@ -97,6 +97,12 @@ type ProxyGroupSpec struct {
|
||||
// ProxyGroup type. This field is only used when Type is set to "kube-apiserver".
|
||||
// +optional
|
||||
KubeAPIServer *KubeAPIServerConfig `json:"kubeAPIServer,omitempty"`
|
||||
|
||||
// Tailnet specifies the tailnet this ProxyGroup should join. If blank, the default tailnet is used. When set, this
|
||||
// name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
|
||||
// +optional
|
||||
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ProxyGroup tailnet is immutable"
|
||||
Tailnet string `json:"tailnet,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyGroupStatus struct {
|
||||
|
||||
@@ -81,6 +81,12 @@ type RecorderSpec struct {
|
||||
// +optional
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
Replicas *int32 `json:"replicas,omitzero"`
|
||||
|
||||
// Tailnet specifies the tailnet this Recorder should join. If blank, the default tailnet is used. When set, this
|
||||
// name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
|
||||
// +optional
|
||||
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Recorder tailnet is immutable"
|
||||
Tailnet string `json:"tailnet,omitempty"`
|
||||
}
|
||||
|
||||
type RecorderStatefulSet struct {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// Code comments on these types should be treated as user facing documentation-
|
||||
// they will appear on the Tailnet CRD i.e. if someone runs kubectl explain tailnet.
|
||||
|
||||
var TailnetKind = "Tailnet"
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster,shortName=tn
|
||||
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
|
||||
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "TailnetReady")].reason`,description="Status of the deployed Tailnet resources."
|
||||
|
||||
type Tailnet struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitzero"`
|
||||
|
||||
// Spec describes the desired state of the Tailnet.
|
||||
// More info:
|
||||
// https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
Spec TailnetSpec `json:"spec"`
|
||||
|
||||
// Status describes the status of the Tailnet. This is set
|
||||
// and managed by the Tailscale operator.
|
||||
// +optional
|
||||
Status TailnetStatus `json:"status"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
type TailnetList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata"`
|
||||
|
||||
Items []Tailnet `json:"items"`
|
||||
}
|
||||
|
||||
type TailnetSpec struct {
|
||||
// URL of the control plane to be used by all resources managed by the operator using this Tailnet.
|
||||
// +optional
|
||||
LoginURL string `json:"loginUrl,omitempty"`
|
||||
// Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
|
||||
Credentials TailnetCredentials `json:"credentials"`
|
||||
}
|
||||
|
||||
type TailnetCredentials struct {
|
||||
// The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
|
||||
// "client_secret".
|
||||
SecretName string `json:"secretName"`
|
||||
}
|
||||
|
||||
type TailnetStatus struct {
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions"`
|
||||
}
|
||||
|
||||
// TailnetReady is set to True if the Tailnet is available for use by operator workloads.
|
||||
const TailnetReady ConditionType = `TailnetReady`
|
||||
@@ -1365,6 +1365,48 @@ func (in Tags) DeepCopy() Tags {
|
||||
return *out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Tailnet) DeepCopyInto(out *Tailnet) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
out.Spec = in.Spec
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Tailnet.
|
||||
func (in *Tailnet) DeepCopy() *Tailnet {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Tailnet)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Tailnet) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailnetCredentials) DeepCopyInto(out *TailnetCredentials) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailnetCredentials.
|
||||
func (in *TailnetCredentials) DeepCopy() *TailnetCredentials {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TailnetCredentials)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailnetDevice) DeepCopyInto(out *TailnetDevice) {
|
||||
*out = *in
|
||||
@@ -1390,6 +1432,76 @@ func (in *TailnetDevice) DeepCopy() *TailnetDevice {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailnetList) DeepCopyInto(out *TailnetList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Tailnet, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailnetList.
|
||||
func (in *TailnetList) DeepCopy() *TailnetList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TailnetList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *TailnetList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailnetSpec) DeepCopyInto(out *TailnetSpec) {
|
||||
*out = *in
|
||||
out.Credentials = in.Credentials
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailnetSpec.
|
||||
func (in *TailnetSpec) DeepCopy() *TailnetSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TailnetSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailnetStatus) DeepCopyInto(out *TailnetStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TailnetStatus.
|
||||
func (in *TailnetStatus) DeepCopy() *TailnetStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TailnetStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TailscaleConfig) DeepCopyInto(out *TailscaleConfig) {
|
||||
*out = *in
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
xslices "golang.org/x/exp/slices"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
)
|
||||
@@ -91,6 +92,14 @@ func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionT
|
||||
pg.Status.Conditions = conds
|
||||
}
|
||||
|
||||
// SetTailnetCondition ensures that Tailnet status has a condition with the
|
||||
// given attributes. LastTransitionTime gets set every time condition's status
|
||||
// changes.
|
||||
func SetTailnetCondition(tn *tsapi.Tailnet, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, clock tstime.Clock, logger *zap.SugaredLogger) {
|
||||
conds := updateCondition(tn.Status.Conditions, conditionType, status, reason, message, tn.Generation, clock, logger)
|
||||
tn.Status.Conditions = conds
|
||||
}
|
||||
|
||||
func updateCondition(conds []metav1.Condition, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []metav1.Condition {
|
||||
newCondition := metav1.Condition{
|
||||
Type: string(conditionType),
|
||||
@@ -187,3 +196,14 @@ func SvcIsReady(svc *corev1.Service) bool {
|
||||
cond := svc.Status.Conditions[idx]
|
||||
return cond.Status == metav1.ConditionTrue
|
||||
}
|
||||
|
||||
func TailnetIsReady(tn *tsapi.Tailnet) bool {
|
||||
idx := xslices.IndexFunc(tn.Status.Conditions, func(cond metav1.Condition) bool {
|
||||
return cond.Type == string(tsapi.TailnetReady)
|
||||
})
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
cond := tn.Status.Conditions[idx]
|
||||
return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == tn.Generation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// Package reconciler provides utilities for working with Kubernetes resources within controller reconciliation
|
||||
// loops.
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
const (
|
||||
// FinalizerName is the common finalizer used across all Tailscale Kubernetes resources.
|
||||
FinalizerName = "tailscale.com/finalizer"
|
||||
)
|
||||
|
||||
// SetFinalizer adds the finalizer to the resource if not already present.
|
||||
func SetFinalizer(obj client.Object) {
|
||||
if idx := slices.Index(obj.GetFinalizers(), FinalizerName); idx >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
obj.SetFinalizers(append(obj.GetFinalizers(), FinalizerName))
|
||||
}
|
||||
|
||||
// RemoveFinalizer removes the finalizer from the resource if present.
|
||||
func RemoveFinalizer(obj client.Object) {
|
||||
idx := slices.Index(obj.GetFinalizers(), FinalizerName)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
finalizers := obj.GetFinalizers()
|
||||
obj.SetFinalizers(append(finalizers[:idx], finalizers[idx+1:]...))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package reconciler_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"tailscale.com/k8s-operator/reconciler"
|
||||
)
|
||||
|
||||
func TestFinalizers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
object := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"hello": "world",
|
||||
},
|
||||
}
|
||||
|
||||
reconciler.SetFinalizer(object)
|
||||
|
||||
if !slices.Contains(object.Finalizers, reconciler.FinalizerName) {
|
||||
t.Fatalf("object does not have finalizer %q: %v", reconciler.FinalizerName, object.Finalizers)
|
||||
}
|
||||
|
||||
reconciler.RemoveFinalizer(object)
|
||||
|
||||
if slices.Contains(object.Finalizers, reconciler.FinalizerName) {
|
||||
t.Fatalf("object still has finalizer %q: %v", reconciler.FinalizerName, object.Finalizers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package tailnet_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
)
|
||||
|
||||
type (
|
||||
MockTailnetClient struct {
|
||||
ErrorOnDevices bool
|
||||
ErrorOnKeys bool
|
||||
ErrorOnServices bool
|
||||
}
|
||||
)
|
||||
|
||||
func (m MockTailnetClient) Devices(_ context.Context, _ *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error) {
|
||||
if m.ErrorOnDevices {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m MockTailnetClient) Keys(_ context.Context) ([]string, error) {
|
||||
if m.ErrorOnKeys {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m MockTailnetClient) ListVIPServices(_ context.Context) (*tailscale.VIPServiceList, error) {
|
||||
if m.ErrorOnServices {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// Package tailnet provides reconciliation logic for the Tailnet custom resource definition. It is responsible for
|
||||
// ensuring the referenced OAuth credentials are valid and have the required scopes to be able to generate authentication
|
||||
// keys, manage devices & manage VIP services.
|
||||
package tailnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/k8s-operator/reconciler"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
type (
|
||||
// The Reconciler type is a reconcile.TypedReconciler implementation used to manage the reconciliation of
|
||||
// Tailnet custom resources.
|
||||
Reconciler struct {
|
||||
client.Client
|
||||
|
||||
tailscaleNamespace string
|
||||
clock tstime.Clock
|
||||
logger *zap.SugaredLogger
|
||||
clientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient
|
||||
|
||||
// Metrics related fields
|
||||
mu sync.Mutex
|
||||
tailnets set.Slice[types.UID]
|
||||
}
|
||||
|
||||
// The ReconcilerOptions type contains configuration values for the Reconciler.
|
||||
ReconcilerOptions struct {
|
||||
// The client for interacting with the Kubernetes API.
|
||||
Client client.Client
|
||||
// The namespace the operator is installed in. This reconciler expects Tailnet OAuth credentials to be stored
|
||||
// in Secret resources within this namespace.
|
||||
TailscaleNamespace string
|
||||
// Controls which clock to use for performing time-based functions. This is typically modified for use
|
||||
// in tests.
|
||||
Clock tstime.Clock
|
||||
// The logger to use for this Reconciler.
|
||||
Logger *zap.SugaredLogger
|
||||
// ClientFunc is a function that takes tailscale credentials and returns an implementation for the Tailscale
|
||||
// HTTP API. This should generally be nil unless needed for testing.
|
||||
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient
|
||||
}
|
||||
|
||||
// The TailscaleClient interface describes types that interact with the Tailscale HTTP API.
|
||||
TailscaleClient interface {
|
||||
Devices(context.Context, *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error)
|
||||
Keys(ctx context.Context) ([]string, error)
|
||||
ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error)
|
||||
}
|
||||
)
|
||||
|
||||
const reconcilerName = "tailnet-reconciler"
|
||||
|
||||
// NewReconciler returns a new instance of the Reconciler type. It watches specifically for changes to Tailnet custom
|
||||
// resources. The ReconcilerOptions can be used to modify the behaviour of the Reconciler.
|
||||
func NewReconciler(options ReconcilerOptions) *Reconciler {
|
||||
return &Reconciler{
|
||||
Client: options.Client,
|
||||
tailscaleNamespace: options.TailscaleNamespace,
|
||||
clock: options.Clock,
|
||||
logger: options.Logger.Named(reconcilerName),
|
||||
clientFunc: options.ClientFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// Register the Reconciler onto the given manager.Manager implementation.
|
||||
func (r *Reconciler) Register(mgr manager.Manager) error {
|
||||
return builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&tsapi.Tailnet{}).
|
||||
Named(reconcilerName).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
var (
|
||||
// gaugeTailnetResources tracks the overall number of Tailnet resources currently managed by this operator instance.
|
||||
gaugeTailnetResources = clientmetric.NewGauge(kubetypes.MetricTailnetCount)
|
||||
)
|
||||
|
||||
// Reconcile is invoked when a change occurs to Tailnet resources within the cluster. On create/update, the Tailnet
|
||||
// resource is validated ensuring that the specified Secret exists and contains valid OAuth credentials that have
|
||||
// required permissions to perform all necessary functions by the operator.
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
|
||||
var tailnet tsapi.Tailnet
|
||||
err := r.Get(ctx, req.NamespacedName, &tailnet)
|
||||
switch {
|
||||
case apierrors.IsNotFound(err):
|
||||
return reconcile.Result{}, nil
|
||||
case err != nil:
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get Tailnet %q: %w", req.NamespacedName, err)
|
||||
}
|
||||
|
||||
if !tailnet.DeletionTimestamp.IsZero() {
|
||||
return r.delete(ctx, &tailnet)
|
||||
}
|
||||
|
||||
return r.createOrUpdate(ctx, &tailnet)
|
||||
}
|
||||
|
||||
func (r *Reconciler) delete(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) {
|
||||
reconciler.RemoveFinalizer(tailnet)
|
||||
if err := r.Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to remove finalizer from Tailnet %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.tailnets.Remove(tailnet.UID)
|
||||
r.mu.Unlock()
|
||||
gaugeTailnetResources.Set(int64(r.tailnets.Len()))
|
||||
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
// Constants for condition reasons.
|
||||
const (
|
||||
ReasonInvalidOAuth = "InvalidOAuth"
|
||||
ReasonInvalidSecret = "InvalidSecret"
|
||||
ReasonValid = "TailnetValid"
|
||||
)
|
||||
|
||||
func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet) (reconcile.Result, error) {
|
||||
r.mu.Lock()
|
||||
r.tailnets.Add(tailnet.UID)
|
||||
r.mu.Unlock()
|
||||
gaugeTailnetResources.Set(int64(r.tailnets.Len()))
|
||||
|
||||
name := types.NamespacedName{Name: tailnet.Spec.Credentials.SecretName, Namespace: r.tailscaleNamespace}
|
||||
|
||||
var secret corev1.Secret
|
||||
err := r.Get(ctx, name, &secret)
|
||||
|
||||
// The referenced Secret does not exist within the tailscale namespace, so we'll mark the Tailnet as not ready
|
||||
// for use.
|
||||
if apierrors.IsNotFound(err) {
|
||||
operatorutils.SetTailnetCondition(
|
||||
tailnet,
|
||||
tsapi.TailnetReady,
|
||||
metav1.ConditionFalse,
|
||||
ReasonInvalidSecret,
|
||||
fmt.Sprintf("referenced secret %q does not exist in namespace %q", name.Name, r.tailscaleNamespace),
|
||||
r.clock,
|
||||
r.logger,
|
||||
)
|
||||
|
||||
if err = r.Status().Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get secret %q: %w", name, err)
|
||||
}
|
||||
|
||||
// We first ensure that the referenced secret contains the required fields. Otherwise, we set the Tailnet as
|
||||
// invalid. The operator will not allow the use of this Tailnet while it is in an invalid state.
|
||||
if ok := r.ensureSecret(tailnet, &secret); !ok {
|
||||
if err = r.Status().Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
return reconcile.Result{RequeueAfter: time.Minute / 2}, nil
|
||||
}
|
||||
|
||||
tsClient := r.createClient(ctx, tailnet, &secret)
|
||||
|
||||
// Second, we ensure the OAuth credentials supplied in the secret are valid and have the required scopes to access
|
||||
// the various API endpoints required by the operator.
|
||||
if ok := r.ensurePermissions(ctx, tsClient, tailnet); !ok {
|
||||
if err = r.Status().Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
// We provide a requeue duration here as a user will likely want to go and modify their scopes and come back.
|
||||
// This should save them having to delete and recreate the resource.
|
||||
return reconcile.Result{RequeueAfter: time.Minute / 2}, nil
|
||||
}
|
||||
|
||||
operatorutils.SetTailnetCondition(
|
||||
tailnet,
|
||||
tsapi.TailnetReady,
|
||||
metav1.ConditionTrue,
|
||||
ReasonValid,
|
||||
ReasonValid,
|
||||
r.clock,
|
||||
r.logger,
|
||||
)
|
||||
|
||||
if err = r.Status().Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
reconciler.SetFinalizer(tailnet)
|
||||
if err = r.Update(ctx, tailnet); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to add finalizer to Tailnet %q: %w", tailnet.Name, err)
|
||||
}
|
||||
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
// Constants for OAuth credential fields within the Secret referenced by the Tailnet.
|
||||
const (
|
||||
clientIDKey = "client_id"
|
||||
clientSecretKey = "client_secret"
|
||||
)
|
||||
|
||||
func (r *Reconciler) createClient(ctx context.Context, tailnet *tsapi.Tailnet, secret *corev1.Secret) TailscaleClient {
|
||||
if r.clientFunc != nil {
|
||||
return r.clientFunc(tailnet, secret)
|
||||
}
|
||||
|
||||
baseURL := ipn.DefaultControlURL
|
||||
if tailnet.Spec.LoginURL != "" {
|
||||
baseURL = tailnet.Spec.LoginURL
|
||||
}
|
||||
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(secret.Data[clientIDKey]),
|
||||
ClientSecret: string(secret.Data[clientSecretKey]),
|
||||
TokenURL: baseURL + "/api/v2/oauth/token",
|
||||
}
|
||||
|
||||
source := credentials.TokenSource(ctx)
|
||||
httpClient := oauth2.NewClient(ctx, source)
|
||||
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.UserAgent = "tailscale-k8s-operator"
|
||||
tsClient.HTTPClient = httpClient
|
||||
tsClient.BaseURL = baseURL
|
||||
|
||||
return tsClient
|
||||
}
|
||||
|
||||
func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient TailscaleClient, tailnet *tsapi.Tailnet) bool {
|
||||
// Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource
|
||||
// can perform the basic operations required for the operator to function. This has a caveat of only performing
|
||||
// read actions, as we don't want to create arbitrary keys and VIP services. However, it will catch when a user
|
||||
// has completely forgotten an entire scope that's required.
|
||||
var errs error
|
||||
if _, err := tsClient.Devices(ctx, nil); err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w", err))
|
||||
}
|
||||
|
||||
if _, err := tsClient.Keys(ctx); err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w", err))
|
||||
}
|
||||
|
||||
if _, err := tsClient.ListVIPServices(ctx); err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w", err))
|
||||
}
|
||||
|
||||
if errs != nil {
|
||||
operatorutils.SetTailnetCondition(
|
||||
tailnet,
|
||||
tsapi.TailnetReady,
|
||||
metav1.ConditionFalse,
|
||||
ReasonInvalidOAuth,
|
||||
errs.Error(),
|
||||
r.clock,
|
||||
r.logger,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Reconciler) ensureSecret(tailnet *tsapi.Tailnet, secret *corev1.Secret) bool {
|
||||
var message string
|
||||
|
||||
switch {
|
||||
case len(secret.Data) == 0:
|
||||
message = fmt.Sprintf("Secret %q is empty", secret.Name)
|
||||
case len(secret.Data[clientIDKey]) == 0:
|
||||
message = fmt.Sprintf("Secret %q is missing the client_id field", secret.Name)
|
||||
case len(secret.Data[clientSecretKey]) == 0:
|
||||
message = fmt.Sprintf("Secret %q is missing the client_secret field", secret.Name)
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
operatorutils.SetTailnetCondition(
|
||||
tailnet,
|
||||
tsapi.TailnetReady,
|
||||
metav1.ConditionFalse,
|
||||
ReasonInvalidSecret,
|
||||
message,
|
||||
r.clock,
|
||||
r.logger,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package tailnet_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/k8s-operator/reconciler/tailnet"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
func TestReconciler_Reconcile(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
logger, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
Name string
|
||||
Request reconcile.Request
|
||||
Tailnet *tsapi.Tailnet
|
||||
Secret *corev1.Secret
|
||||
ExpectsError bool
|
||||
ExpectedConditions []metav1.Condition
|
||||
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tailnet.TailscaleClient
|
||||
}{
|
||||
{
|
||||
Name: "ignores unknown tailnet requests",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for missing secret",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidSecret,
|
||||
Message: `referenced secret "test" does not exist in namespace "tailscale"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for empty secret",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidSecret,
|
||||
Message: `Secret "test" is empty`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for missing client id",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_secret": []byte("test"),
|
||||
},
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidSecret,
|
||||
Message: `Secret "test" is missing the client_id field`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for missing client secret",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_id": []byte("test"),
|
||||
},
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidSecret,
|
||||
Message: `Secret "test" is missing the client_secret field`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for bad devices scope",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_id": []byte("test"),
|
||||
"client_secret": []byte("test"),
|
||||
},
|
||||
},
|
||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
||||
return &MockTailnetClient{ErrorOnDevices: true}
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidOAuth,
|
||||
Message: `failed to list devices: EOF`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for bad services scope",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_id": []byte("test"),
|
||||
"client_secret": []byte("test"),
|
||||
},
|
||||
},
|
||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
||||
return &MockTailnetClient{ErrorOnServices: true}
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidOAuth,
|
||||
Message: `failed to list tailscale services: EOF`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid status for bad keys scope",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_id": []byte("test"),
|
||||
"client_secret": []byte("test"),
|
||||
},
|
||||
},
|
||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
||||
return &MockTailnetClient{ErrorOnKeys: true}
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: tailnet.ReasonInvalidOAuth,
|
||||
Message: `failed to list auth keys: EOF`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ready when valid and scopes are correct",
|
||||
Request: reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: "default",
|
||||
},
|
||||
},
|
||||
Tailnet: &tsapi.Tailnet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "default",
|
||||
},
|
||||
Spec: tsapi.TailnetSpec{
|
||||
Credentials: tsapi.TailnetCredentials{
|
||||
SecretName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
Secret: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "tailscale",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"client_id": []byte("test"),
|
||||
"client_secret": []byte("test"),
|
||||
},
|
||||
},
|
||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
||||
return &MockTailnetClient{}
|
||||
},
|
||||
ExpectedConditions: []metav1.Condition{
|
||||
{
|
||||
Type: string(tsapi.TailnetReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: tailnet.ReasonValid,
|
||||
Message: tailnet.ReasonValid,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
builder := fake.NewClientBuilder().WithScheme(tsapi.GlobalScheme)
|
||||
if tc.Tailnet != nil {
|
||||
builder = builder.WithObjects(tc.Tailnet).WithStatusSubresource(tc.Tailnet)
|
||||
}
|
||||
if tc.Secret != nil {
|
||||
builder = builder.WithObjects(tc.Secret)
|
||||
}
|
||||
|
||||
fc := builder.Build()
|
||||
opts := tailnet.ReconcilerOptions{
|
||||
Client: fc,
|
||||
Clock: clock,
|
||||
Logger: logger.Sugar(),
|
||||
ClientFunc: tc.ClientFunc,
|
||||
TailscaleNamespace: "tailscale",
|
||||
}
|
||||
|
||||
reconciler := tailnet.NewReconciler(opts)
|
||||
_, err = reconciler.Reconcile(t.Context(), tc.Request)
|
||||
if tc.ExpectsError && err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
|
||||
if !tc.ExpectsError && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(tc.ExpectedConditions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var tn tsapi.Tailnet
|
||||
if err = fc.Get(t.Context(), tc.Request.NamespacedName, &tn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(tn.Status.Conditions) != len(tc.ExpectedConditions) {
|
||||
t.Fatalf("expected %v condition(s), got %v", len(tc.ExpectedConditions), len(tn.Status.Conditions))
|
||||
}
|
||||
|
||||
for i, expected := range tc.ExpectedConditions {
|
||||
actual := tn.Status.Conditions[i]
|
||||
|
||||
if actual.Type != expected.Type {
|
||||
t.Errorf("expected %v, got %v", expected.Type, actual.Type)
|
||||
}
|
||||
|
||||
if actual.Status != expected.Status {
|
||||
t.Errorf("expected %v, got %v", expected.Status, actual.Status)
|
||||
}
|
||||
|
||||
if actual.Reason != expected.Reason {
|
||||
t.Errorf("expected %v, got %v", expected.Reason, actual.Reason)
|
||||
}
|
||||
|
||||
if actual.Message != expected.Message {
|
||||
t.Errorf("expected %v, got %v", expected.Message, actual.Message)
|
||||
}
|
||||
}
|
||||
|
||||
if err = fc.Delete(t.Context(), &tn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err = reconciler.Reconcile(t.Context(), tc.Request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fc.Get(t.Context(), tc.Request.NamespacedName, &tn)
|
||||
if !apierrors.IsNotFound(err) {
|
||||
t.Fatalf("expected not found error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user