summaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml3
-rw-r--r--cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml1747
-rw-r--r--cmd/k8s-operator/deploy/examples/idp.yaml16
-rw-r--r--cmd/k8s-operator/deploy/manifests/operator.yaml1758
-rw-r--r--cmd/k8s-operator/generate/main.go6
-rw-r--r--cmd/k8s-operator/idp.go540
-rw-r--r--cmd/k8s-operator/idp_specs.go325
-rw-r--r--cmd/k8s-operator/idp_test.go606
-rw-r--r--cmd/k8s-operator/kubestore_utils.go91
-rw-r--r--cmd/k8s-operator/operator.go24
-rw-r--r--cmd/k8s-operator/tsrecorder.go43
-rw-r--r--cmd/tsidp/README.md111
-rw-r--r--cmd/tsidp/depaware.txt6
-rw-r--r--cmd/tsidp/tsidp.go312
14 files changed, 5514 insertions, 74 deletions
diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
index 5eb920a6f..f16335ee2 100644
--- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
+++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
@@ -40,6 +40,9 @@ rules:
- apiGroups: ["tailscale.com"]
resources: ["recorders", "recorders/status"]
verbs: ["get", "list", "watch", "update"]
+- apiGroups: ["tailscale.com"]
+ resources: ["idps", "idps/status"]
+ verbs: ["get", "list", "watch", "update"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list", "watch"]
diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml
new file mode 100644
index 000000000..ab4f4e705
--- /dev/null
+++ b/cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml
@@ -0,0 +1,1747 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.17.0
+ name: idps.tailscale.com
+spec:
+ group: tailscale.com
+ names:
+ kind: IDP
+ listKind: IDPList
+ plural: idps
+ shortNames:
+ - idp
+ singular: idp
+ scope: Cluster
+ versions:
+ - additionalPrinterColumns:
+ - description: Status of the deployed IDP resources.
+ jsonPath: .status.conditions[?(@.type == "IDPReady")].reason
+ name: Status
+ type: string
+ - description: URL where the OIDC provider is accessible.
+ jsonPath: .status.url
+ name: URL
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ IDP defines a Tailscale OpenID Connect Identity Provider instance.
+ IDP is a cluster-scoped resource.
+ 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:
+ description: Spec describes the desired IDP instance.
+ type: object
+ properties:
+ enableFunnel:
+ description: |-
+ Enable Tailscale Funnel to make IDP available on the public internet.
+ When enabled, the IDP will be accessible via a public HTTPS URL.
+ Requires appropriate ACL configuration in your tailnet.
+ Cannot be used with custom ports.
+ Defaults to false.
+ type: boolean
+ hostname:
+ description: |-
+ Hostname for the IDP instance. Defaults to "idp".
+ This will be used as the MagicDNS hostname.
+ type: string
+ pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
+ localPort:
+ description: |-
+ LocalPort to listen on for HTTP traffic from localhost.
+ This can be useful for debugging or local client access.
+ The IDP will serve unencrypted HTTP on this port, accessible only from
+ the pod itself (localhost/127.0.0.1).
+ If not set, local access is disabled.
+ type: integer
+ format: int32
+ maximum: 65535
+ minimum: 1
+ port:
+ description: |-
+ Port to listen on for HTTPS traffic. Defaults to 443.
+ Must be 443 if EnableFunnel is true.
+ Common values: 443 (standard HTTPS), 8443 (alternative HTTPS).
+ type: integer
+ format: int32
+ maximum: 65535
+ minimum: 1
+ statefulSet:
+ description: |-
+ Configuration parameters for the IDP's StatefulSet. The operator
+ deploys a StatefulSet for each IDP resource.
+ type: object
+ properties:
+ annotations:
+ description: |-
+ Annotations that will be added to the StatefulSet created for IDP.
+ Any Annotations specified here will be merged with the default annotations
+ applied to the StatefulSet by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ additionalProperties:
+ type: string
+ labels:
+ description: |-
+ Labels that will be added to the StatefulSet created for IDP.
+ Any labels specified here will be merged with the default labels applied
+ to the StatefulSet by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
+ type: object
+ additionalProperties:
+ type: string
+ pod:
+ description: Configuration for pods created by the IDP's StatefulSet.
+ type: object
+ properties:
+ affinity:
+ description: |-
+ Affinity rules for IDP Pods. By default, the operator does not
+ apply any affinity rules.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity
+ type: object
+ properties:
+ nodeAffinity:
+ description: Describes node affinity scheduling rules for the pod.
+ type: object
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node matches the corresponding matchExpressions; the
+ node(s) with the highest sum are the most preferred.
+ type: array
+ items:
+ description: |-
+ An empty preferred scheduling term matches all objects with implicit weight 0
+ (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
+ type: object
+ required:
+ - preference
+ - weight
+ properties:
+ preference:
+ description: A node selector term, associated with the corresponding weight.
+ type: object
+ properties:
+ matchExpressions:
+ description: A list of node selector requirements by node's labels.
+ type: array
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchFields:
+ description: A list of node selector requirements by node's fields.
+ type: array
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ x-kubernetes-map-type: atomic
+ weight:
+ description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
+ type: integer
+ format: int32
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to an update), the system
+ may or may not try to eventually evict the pod from its node.
+ type: object
+ required:
+ - nodeSelectorTerms
+ properties:
+ nodeSelectorTerms:
+ description: Required. A list of node selector terms. The terms are ORed.
+ type: array
+ items:
+ description: |-
+ A null or empty node selector term matches no objects. The requirements of
+ them are ANDed.
+ The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
+ type: object
+ properties:
+ matchExpressions:
+ description: A list of node selector requirements by node's labels.
+ type: array
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchFields:
+ description: A list of node selector requirements by node's fields.
+ type: array
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ x-kubernetes-map-type: atomic
+ x-kubernetes-list-type: atomic
+ x-kubernetes-map-type: atomic
+ podAffinity:
+ description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
+ type: object
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
+ node(s) with the highest sum are the most preferred.
+ type: array
+ items:
+ description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
+ type: object
+ required:
+ - podAffinityTerm
+ - weight
+ properties:
+ podAffinityTerm:
+ description: Required. A pod affinity term, associated with the corresponding weight.
+ type: object
+ required:
+ - topologyKey
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ weight:
+ description: |-
+ weight associated with matching the corresponding podAffinityTerm,
+ in the range 1-100.
+ type: integer
+ format: int32
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to a pod label update), the
+ system may or may not try to eventually evict the pod from its node.
+ When there are multiple elements, the lists of nodes corresponding to each
+ podAffinityTerm are intersected, i.e. all terms must be satisfied.
+ type: array
+ items:
+ description: |-
+ Defines a set of pods (namely those matching the labelSelector
+ relative to the given namespace(s)) that this pod should be
+ co-located (affinity) or not co-located (anti-affinity) with,
+ where co-located is defined as running on a node whose value of
+ the label with key <topologyKey> matches that of any node on which
+ a pod of the set of pods is running
+ type: object
+ required:
+ - topologyKey
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ x-kubernetes-list-type: atomic
+ podAntiAffinity:
+ description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
+ type: object
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the anti-affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling anti-affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
+ node(s) with the highest sum are the most preferred.
+ type: array
+ items:
+ description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
+ type: object
+ required:
+ - podAffinityTerm
+ - weight
+ properties:
+ podAffinityTerm:
+ description: Required. A pod affinity term, associated with the corresponding weight.
+ type: object
+ required:
+ - topologyKey
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ weight:
+ description: |-
+ weight associated with matching the corresponding podAffinityTerm,
+ in the range 1-100.
+ type: integer
+ format: int32
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the anti-affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the anti-affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to a pod label update), the
+ system may or may not try to eventually evict the pod from its node.
+ When there are multiple elements, the lists of nodes corresponding to each
+ podAffinityTerm are intersected, i.e. all terms must be satisfied.
+ type: array
+ items:
+ description: |-
+ Defines a set of pods (namely those matching the labelSelector
+ relative to the given namespace(s)) that this pod should be
+ co-located (affinity) or not co-located (anti-affinity) with,
+ where co-located is defined as running on a node whose value of
+ the label with key <topologyKey> matches that of any node on which
+ a pod of the set of pods is running
+ type: object
+ required:
+ - topologyKey
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ type: object
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ type: array
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ type: object
+ required:
+ - key
+ - operator
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ additionalProperties:
+ type: string
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ type: array
+ items:
+ type: string
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ x-kubernetes-list-type: atomic
+ annotations:
+ description: |-
+ Annotations that will be added to IDP Pods. Any annotations
+ specified here will be merged with the default annotations applied to
+ the Pod by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ additionalProperties:
+ type: string
+ container:
+ description: Configuration for the IDP container.
+ type: object
+ properties:
+ env:
+ description: |-
+ List of environment variables to set in the container.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables
+ Note that environment variables provided here will take precedence
+ over Tailscale-specific environment variables set by the operator.
+ type: array
+ items:
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ description: Name of the environment variable. Must be a C_IDENTIFIER.
+ type: string
+ pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
+ value:
+ description: |-
+ Variable references $(VAR_NAME) are expanded using the previously defined
+ environment variables in the container and any service environment
+ variables. If a variable cannot be resolved, the reference in the input
+ string will be unchanged. Double $$ are reduced to a single $, which
+ allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will
+ produce the string literal "$(VAR_NAME)". Escaped references will never
+ be expanded, regardless of whether the variable exists or not. Defaults
+ to "".
+ type: string
+ image:
+ description: |-
+ Container image name including tag. Defaults to the tsidp image
+ from the same source as the operator.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
+ type: string
+ imagePullPolicy:
+ description: |-
+ Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
+ type: string
+ enum:
+ - Always
+ - Never
+ - IfNotPresent
+ resources:
+ description: |-
+ Container resource requirements.
+ By default, the operator does not apply any resource requirements.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
+ type: object
+ properties:
+ claims:
+ description: |-
+ Claims lists the names of resources, defined in spec.resourceClaims,
+ that are used by this container.
+
+ This is an alpha field and requires enabling the
+ DynamicResourceAllocation feature gate.
+
+ This field is immutable. It can only be set for containers.
+ type: array
+ items:
+ description: ResourceClaim references one entry in PodSpec.ResourceClaims.
+ type: object
+ required:
+ - name
+ properties:
+ name:
+ description: |-
+ Name must match the name of one entry in pod.spec.resourceClaims of
+ the Pod where this field is used. It makes that resource available
+ inside a container.
+ type: string
+ request:
+ description: |-
+ Request is the name chosen for a request in the referenced claim.
+ If empty, everything from the claim is made available, otherwise
+ only the result of this request.
+ type: string
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
+ limits:
+ description: |-
+ Limits describes the maximum amount of compute resources allowed.
+ More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
+ type: object
+ additionalProperties:
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ anyOf:
+ - type: integer
+ - type: string
+ x-kubernetes-int-or-string: true
+ requests:
+ description: |-
+ Requests describes the minimum amount of compute resources required.
+ If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
+ otherwise to an implementation-defined value. Requests cannot exceed Limits.
+ More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
+ type: object
+ additionalProperties:
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ anyOf:
+ - type: integer
+ - type: string
+ x-kubernetes-int-or-string: true
+ securityContext:
+ description: |-
+ Container security context. By default, the operator does not apply any
+ container security context.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context
+ type: object
+ properties:
+ allowPrivilegeEscalation:
+ description: |-
+ AllowPrivilegeEscalation controls whether a process can gain more
+ privileges than its parent process. This bool directly controls if
+ the no_new_privs flag will be set on the container process.
+ AllowPrivilegeEscalation is true always when the container is:
+ 1) run as Privileged
+ 2) has CAP_SYS_ADMIN
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ appArmorProfile:
+ description: |-
+ appArmorProfile is the AppArmor options to use by this container. If set, this profile
+ overrides the pod's appArmorProfile.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ required:
+ - type
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile loaded on the node that should be used.
+ The profile must be preconfigured on the node to work.
+ Must match the loaded name of the profile.
+ Must be set if and only if type is "Localhost".
+ type: string
+ type:
+ description: |-
+ type indicates which kind of AppArmor profile will be applied.
+ Valid options are:
+ Localhost - a profile pre-loaded on the node.
+ RuntimeDefault - the container runtime's default profile.
+ Unconfined - no AppArmor enforcement.
+ type: string
+ capabilities:
+ description: |-
+ The capabilities to add/drop when running containers.
+ Defaults to the default set of capabilities granted by the container runtime.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ properties:
+ add:
+ description: Added capabilities
+ type: array
+ items:
+ description: Capability represent POSIX capabilities type
+ type: string
+ x-kubernetes-list-type: atomic
+ drop:
+ description: Removed capabilities
+ type: array
+ items:
+ description: Capability represent POSIX capabilities type
+ type: string
+ x-kubernetes-list-type: atomic
+ privileged:
+ description: |-
+ Run container in privileged mode.
+ Processes in privileged containers are essentially equivalent to root on the host.
+ Defaults to false.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ procMount:
+ description: |-
+ procMount denotes the type of proc mount to use for the containers.
+ The default value is Default which uses the container runtime defaults for
+ readonly paths and masked paths.
+ This requires the ProcMountType feature flag to be enabled.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ readOnlyRootFilesystem:
+ description: |-
+ Whether this container has a read-only root filesystem.
+ Default is false.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ runAsGroup:
+ description: |-
+ The GID to run the entrypoint of the container process.
+ Uses runtime default if unset.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: integer
+ format: int64
+ runAsNonRoot:
+ description: |-
+ Indicates that the container must run as a non-root user.
+ If true, the Kubelet will validate the image at runtime to ensure that it
+ does not run as UID 0 (root) and fail to start the container if it does.
+ If unset or false, no such validation will be performed.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: boolean
+ runAsUser:
+ description: |-
+ The UID to run the entrypoint of the container process.
+ Defaults to user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: integer
+ format: int64
+ seLinuxOptions:
+ description: |-
+ The SELinux context to be applied to the container.
+ If unspecified, the container runtime will allocate a random SELinux context for each
+ container. May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ properties:
+ level:
+ description: Level is SELinux level label that applies to the container.
+ type: string
+ role:
+ description: Role is a SELinux role label that applies to the container.
+ type: string
+ type:
+ description: Type is a SELinux type label that applies to the container.
+ type: string
+ user:
+ description: User is a SELinux user label that applies to the container.
+ type: string
+ seccompProfile:
+ description: |-
+ The seccomp options to use by this container. If seccomp options are
+ provided at both the pod & container level, the container options
+ override the pod options.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ required:
+ - type
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile defined in a file on the node should be used.
+ The profile must be preconfigured on the node to work.
+ Must be a descending path, relative to the kubelet's configured seccomp profile location.
+ Must be set if type is "Localhost". Must NOT be set for any other type.
+ type: string
+ type:
+ description: |-
+ type indicates which kind of seccomp profile will be applied.
+ Valid options are:
+
+ Localhost - a profile defined in a file on the node should be used.
+ RuntimeDefault - the container runtime default profile should be used.
+ Unconfined - no profile should be applied.
+ type: string
+ windowsOptions:
+ description: |-
+ The Windows specific settings applied to all containers.
+ If unspecified, the options from the PodSecurityContext will be used.
+ If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is linux.
+ type: object
+ properties:
+ gmsaCredentialSpec:
+ description: |-
+ GMSACredentialSpec is where the GMSA admission webhook
+ (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+ GMSA credential spec named by the GMSACredentialSpecName field.
+ type: string
+ gmsaCredentialSpecName:
+ description: GMSACredentialSpecName is the name of the GMSA credential spec to use.
+ type: string
+ hostProcess:
+ description: |-
+ HostProcess determines if a container should be run as a 'Host Process' container.
+ All of a Pod's containers must have the same effective HostProcess value
+ (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+ In addition, if HostProcess is true then HostNetwork must also be set to true.
+ type: boolean
+ runAsUserName:
+ description: |-
+ The UserName in Windows to run the entrypoint of the container process.
+ Defaults to the user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: string
+ imagePullSecrets:
+ description: |-
+ Image pull Secrets for IDP Pods.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec
+ type: array
+ items:
+ description: |-
+ LocalObjectReference contains enough information to let you locate the
+ referenced object inside the same namespace.
+ type: object
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ default: ""
+ x-kubernetes-map-type: atomic
+ labels:
+ description: |-
+ Labels that will be added to IDP Pods. Any labels specified here
+ will be merged with the default labels applied to the Pod by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
+ type: object
+ additionalProperties:
+ type: string
+ nodeSelector:
+ description: |-
+ Node selector rules for IDP Pods. By default, the operator does
+ not apply any node selector rules.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling
+ type: object
+ additionalProperties:
+ type: string
+ securityContext:
+ description: |-
+ Security context for IDP Pods. By default, the operator does not
+ apply any Pod security context.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2
+ type: object
+ properties:
+ appArmorProfile:
+ description: |-
+ appArmorProfile is the AppArmor options to use by the containers in this pod.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ required:
+ - type
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile loaded on the node that should be used.
+ The profile must be preconfigured on the node to work.
+ Must match the loaded name of the profile.
+ Must be set if and only if type is "Localhost".
+ type: string
+ type:
+ description: |-
+ type indicates which kind of AppArmor profile will be applied.
+ Valid options are:
+ Localhost - a profile pre-loaded on the node.
+ RuntimeDefault - the container runtime's default profile.
+ Unconfined - no AppArmor enforcement.
+ type: string
+ fsGroup:
+ description: |-
+ A special supplemental group that applies to all containers in a pod.
+ Some volume types allow the Kubelet to change the ownership of that volume
+ to be owned by the pod:
+
+ 1. The owning GID will be the FSGroup
+ 2. The setgid bit is set (new files created in the volume will be owned by FSGroup)
+ 3. The permission bits are OR'd with rw-rw----
+
+ If unset, the Kubelet will not modify the ownership and permissions of any volume.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: integer
+ format: int64
+ fsGroupChangePolicy:
+ description: |-
+ fsGroupChangePolicy defines behavior of changing ownership and permission of the volume
+ before being exposed inside Pod. This field will only apply to
+ volume types which support fsGroup based ownership(and permissions).
+ It will have no effect on ephemeral volume types such as: secret, configmaps
+ and emptydir.
+ Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ runAsGroup:
+ description: |-
+ The GID to run the entrypoint of the container process.
+ Uses runtime default if unset.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence
+ for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: integer
+ format: int64
+ runAsNonRoot:
+ description: |-
+ Indicates that the container must run as a non-root user.
+ If true, the Kubelet will validate the image at runtime to ensure that it
+ does not run as UID 0 (root) and fail to start the container if it does.
+ If unset or false, no such validation will be performed.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: boolean
+ runAsUser:
+ description: |-
+ The UID to run the entrypoint of the container process.
+ Defaults to user specified in image metadata if unspecified.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence
+ for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: integer
+ format: int64
+ seLinuxChangePolicy:
+ description: |-
+ seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
+ It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
+ Valid values are "MountOption" and "Recursive".
+
+ "Recursive" means relabeling of all files on all Pod volumes by the container runtime.
+ This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
+
+ "MountOption" mounts all eligible Pod volumes with `-o context` mount option.
+ This requires all Pods that share the same volume to use the same SELinux label.
+ It is not possible to share the same volume among privileged and unprivileged Pods.
+ Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
+ whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
+ CSIDriver instance. Other volumes are always re-labelled recursively.
+ "MountOption" value is allowed only when SELinuxMount feature gate is enabled.
+
+ If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
+ If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
+ and "Recursive" for all other volumes.
+
+ This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
+
+ All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ seLinuxOptions:
+ description: |-
+ The SELinux context to be applied to all containers.
+ If unspecified, the container runtime will allocate a random SELinux context for each
+ container. May also be set in SecurityContext. If set in
+ both SecurityContext and PodSecurityContext, the value specified in SecurityContext
+ takes precedence for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ properties:
+ level:
+ description: Level is SELinux level label that applies to the container.
+ type: string
+ role:
+ description: Role is a SELinux role label that applies to the container.
+ type: string
+ type:
+ description: Type is a SELinux type label that applies to the container.
+ type: string
+ user:
+ description: User is a SELinux user label that applies to the container.
+ type: string
+ seccompProfile:
+ description: |-
+ The seccomp options to use by the containers in this pod.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: object
+ required:
+ - type
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile defined in a file on the node should be used.
+ The profile must be preconfigured on the node to work.
+ Must be a descending path, relative to the kubelet's configured seccomp profile location.
+ Must be set if type is "Localhost". Must NOT be set for any other type.
+ type: string
+ type:
+ description: |-
+ type indicates which kind of seccomp profile will be applied.
+ Valid options are:
+
+ Localhost - a profile defined in a file on the node should be used.
+ RuntimeDefault - the container runtime default profile should be used.
+ Unconfined - no profile should be applied.
+ type: string
+ supplementalGroups:
+ description: |-
+ A list of groups applied to the first process run in each container, in
+ addition to the container's primary GID and fsGroup (if specified). If
+ the SupplementalGroupsPolicy feature is enabled, the
+ supplementalGroupsPolicy field determines whether these are in addition
+ to or instead of any group memberships defined in the container image.
+ If unspecified, no additional groups are added, though group memberships
+ defined in the container image may still be used, depending on the
+ supplementalGroupsPolicy field.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: array
+ items:
+ type: integer
+ format: int64
+ x-kubernetes-list-type: atomic
+ supplementalGroupsPolicy:
+ description: |-
+ Defines how supplemental groups of the first container processes are calculated.
+ Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
+ (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
+ and the container runtime must implement support for this feature.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ sysctls:
+ description: |-
+ Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
+ sysctls (by the container runtime) might fail to launch.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: array
+ items:
+ description: Sysctl defines a kernel parameter to be set
+ type: object
+ required:
+ - name
+ - value
+ properties:
+ name:
+ description: Name of a property to set
+ type: string
+ value:
+ description: Value of a property to set
+ type: string
+ x-kubernetes-list-type: atomic
+ windowsOptions:
+ description: |-
+ The Windows specific settings applied to all containers.
+ If unspecified, the options within a container's SecurityContext will be used.
+ If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is linux.
+ type: object
+ properties:
+ gmsaCredentialSpec:
+ description: |-
+ GMSACredentialSpec is where the GMSA admission webhook
+ (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+ GMSA credential spec named by the GMSACredentialSpecName field.
+ type: string
+ gmsaCredentialSpecName:
+ description: GMSACredentialSpecName is the name of the GMSA credential spec to use.
+ type: string
+ hostProcess:
+ description: |-
+ HostProcess determines if a container should be run as a 'Host Process' container.
+ All of a Pod's containers must have the same effective HostProcess value
+ (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+ In addition, if HostProcess is true then HostNetwork must also be set to true.
+ type: boolean
+ runAsUserName:
+ description: |-
+ The UserName in Windows to run the entrypoint of the container process.
+ Defaults to the user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: string
+ serviceAccount:
+ description: |-
+ Config for the ServiceAccount to create for the IDP's StatefulSet.
+ By default, the operator will create a ServiceAccount with the same
+ name as the IDP resource.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account
+ type: object
+ properties:
+ annotations:
+ description: |-
+ Annotations to add to the ServiceAccount.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ additionalProperties:
+ type: string
+ name:
+ description: |-
+ Name of the ServiceAccount to create. Defaults to the name of the
+ IDP resource.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account
+ type: string
+ maxLength: 253
+ pattern: ^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$
+ tolerations:
+ description: |-
+ Tolerations for IDP Pods. By default, the operator does not apply
+ any tolerations.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling
+ type: array
+ items:
+ description: |-
+ The pod this Toleration is attached to tolerates any taint that matches
+ the triple <key,value,effect> using the matching operator <operator>.
+ type: object
+ properties:
+ effect:
+ description: |-
+ Effect indicates the taint effect to match. Empty means match all taint effects.
+ When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.
+ type: string
+ key:
+ description: |-
+ Key is the taint key that the toleration applies to. Empty means match all taint keys.
+ If the key is empty, operator must be Exists; this combination means to match all values and all keys.
+ type: string
+ operator:
+ description: |-
+ Operator represents a key's relationship to the value.
+ Valid operators are Exists and Equal. Defaults to Equal.
+ Exists is equivalent to wildcard for value, so that a pod can
+ tolerate all taints of a particular category.
+ type: string
+ tolerationSeconds:
+ description: |-
+ TolerationSeconds represents the period of time the toleration (which must be
+ of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,
+ it is not set, which means tolerate the taint forever (do not evict). Zero and
+ negative values will be treated as 0 (evict immediately) by the system.
+ type: integer
+ format: int64
+ value:
+ description: |-
+ Value is the taint value the toleration matches to.
+ If the operator is Exists, the value should be empty, otherwise just a regular string.
+ type: string
+ tags:
+ description: |-
+ Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s].
+ If you specify custom tags here, make sure you also make the operator
+ an owner of these tags.
+ See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
+ Tags cannot be changed once an IDP node has been created.
+ Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
+ type: array
+ items:
+ type: string
+ pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
+ status:
+ description: |-
+ IDPStatus describes the status of the IDP. This is set
+ and managed by the Tailscale operator.
+ type: object
+ properties:
+ conditions:
+ description: |-
+ List of status conditions to indicate the status of IDP.
+ Known condition types are `IDPReady`.
+ type: array
+ items:
+ description: Condition contains details for one aspect of the current state of this API Resource.
+ type: object
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ type: string
+ format: date-time
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ type: string
+ maxLength: 32768
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ type: integer
+ format: int64
+ minimum: 0
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ type: string
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ type: string
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ type: string
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ hostname:
+ description: |-
+ Hostname is the fully qualified domain name of the IDP device.
+ If MagicDNS is enabled in your tailnet, it is the MagicDNS name.
+ type: string
+ observedGeneration:
+ description: ObservedGeneration is the last observed generation of the IDP resource.
+ type: integer
+ format: int64
+ tailnetIPs:
+ description: |-
+ TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
+ assigned to the IDP device.
+ type: array
+ items:
+ type: string
+ url:
+ description: |-
+ URL where the OIDC provider is accessible.
+ This will be an HTTPS MagicDNS URL, or a public URL if Funnel is enabled.
+ type: string
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/cmd/k8s-operator/deploy/examples/idp.yaml b/cmd/k8s-operator/deploy/examples/idp.yaml
new file mode 100644
index 000000000..6103da8ff
--- /dev/null
+++ b/cmd/k8s-operator/deploy/examples/idp.yaml
@@ -0,0 +1,16 @@
+apiVersion: tailscale.com/v1alpha1
+kind: IDP
+metadata:
+ name: idp-example
+spec:
+ hostname: idp-example
+ enableFunnel: true
+ tags:
+ - tag:k8s
+ statefulSet:
+ pod:
+ container:
+ image: ghcr.io/rajsinghtech/tailscale/tsidp:57
+ env:
+ - name: TAILSCALE_USE_WIP_CODE
+ value: "1" \ No newline at end of file
diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml
index 175f2a7fb..0519b3f7e 100644
--- a/cmd/k8s-operator/deploy/manifests/operator.yaml
+++ b/cmd/k8s-operator/deploy/manifests/operator.yaml
@@ -490,6 +490,1754 @@ kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
+ name: idps.tailscale.com
+spec:
+ group: tailscale.com
+ names:
+ kind: IDP
+ listKind: IDPList
+ plural: idps
+ shortNames:
+ - idp
+ singular: idp
+ scope: Cluster
+ versions:
+ - additionalPrinterColumns:
+ - description: Status of the deployed IDP resources.
+ jsonPath: .status.conditions[?(@.type == "IDPReady")].reason
+ name: Status
+ type: string
+ - description: URL where the OIDC provider is accessible.
+ jsonPath: .status.url
+ name: URL
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ IDP defines a Tailscale OpenID Connect Identity Provider instance.
+ IDP is a cluster-scoped resource.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: Spec describes the desired IDP instance.
+ properties:
+ enableFunnel:
+ description: |-
+ Enable Tailscale Funnel to make IDP available on the public internet.
+ When enabled, the IDP will be accessible via a public HTTPS URL.
+ Requires appropriate ACL configuration in your tailnet.
+ Cannot be used with custom ports.
+ Defaults to false.
+ type: boolean
+ hostname:
+ description: |-
+ Hostname for the IDP instance. Defaults to "idp".
+ This will be used as the MagicDNS hostname.
+ pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
+ type: string
+ localPort:
+ description: |-
+ LocalPort to listen on for HTTP traffic from localhost.
+ This can be useful for debugging or local client access.
+ The IDP will serve unencrypted HTTP on this port, accessible only from
+ the pod itself (localhost/127.0.0.1).
+ If not set, local access is disabled.
+ format: int32
+ maximum: 65535
+ minimum: 1
+ type: integer
+ port:
+ description: |-
+ Port to listen on for HTTPS traffic. Defaults to 443.
+ Must be 443 if EnableFunnel is true.
+ Common values: 443 (standard HTTPS), 8443 (alternative HTTPS).
+ format: int32
+ maximum: 65535
+ minimum: 1
+ type: integer
+ statefulSet:
+ description: |-
+ Configuration parameters for the IDP's StatefulSet. The operator
+ deploys a StatefulSet for each IDP resource.
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ Annotations that will be added to the StatefulSet created for IDP.
+ Any Annotations specified here will be merged with the default annotations
+ applied to the StatefulSet by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ labels:
+ additionalProperties:
+ type: string
+ description: |-
+ Labels that will be added to the StatefulSet created for IDP.
+ Any labels specified here will be merged with the default labels applied
+ to the StatefulSet by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
+ type: object
+ pod:
+ description: Configuration for pods created by the IDP's StatefulSet.
+ properties:
+ affinity:
+ description: |-
+ Affinity rules for IDP Pods. By default, the operator does not
+ apply any affinity rules.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity
+ properties:
+ nodeAffinity:
+ description: Describes node affinity scheduling rules for the pod.
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node matches the corresponding matchExpressions; the
+ node(s) with the highest sum are the most preferred.
+ items:
+ description: |-
+ An empty preferred scheduling term matches all objects with implicit weight 0
+ (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
+ properties:
+ preference:
+ description: A node selector term, associated with the corresponding weight.
+ properties:
+ matchExpressions:
+ description: A list of node selector requirements by node's labels.
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchFields:
+ description: A list of node selector requirements by node's fields.
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ x-kubernetes-map-type: atomic
+ weight:
+ description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
+ format: int32
+ type: integer
+ required:
+ - preference
+ - weight
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to an update), the system
+ may or may not try to eventually evict the pod from its node.
+ properties:
+ nodeSelectorTerms:
+ description: Required. A list of node selector terms. The terms are ORed.
+ items:
+ description: |-
+ A null or empty node selector term matches no objects. The requirements of
+ them are ANDed.
+ The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
+ properties:
+ matchExpressions:
+ description: A list of node selector requirements by node's labels.
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchFields:
+ description: A list of node selector requirements by node's fields.
+ items:
+ description: |-
+ A node selector requirement is a selector that contains values, a key, and an operator
+ that relates the key and values.
+ properties:
+ key:
+ description: The label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ Represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
+ type: string
+ values:
+ description: |-
+ An array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. If the operator is Gt or Lt, the values
+ array must have a single element, which will be interpreted as an integer.
+ This array is replaced during a strategic merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - nodeSelectorTerms
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ podAffinity:
+ description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
+ node(s) with the highest sum are the most preferred.
+ items:
+ description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
+ properties:
+ podAffinityTerm:
+ description: Required. A pod affinity term, associated with the corresponding weight.
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ required:
+ - topologyKey
+ type: object
+ weight:
+ description: |-
+ weight associated with matching the corresponding podAffinityTerm,
+ in the range 1-100.
+ format: int32
+ type: integer
+ required:
+ - podAffinityTerm
+ - weight
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to a pod label update), the
+ system may or may not try to eventually evict the pod from its node.
+ When there are multiple elements, the lists of nodes corresponding to each
+ podAffinityTerm are intersected, i.e. all terms must be satisfied.
+ items:
+ description: |-
+ Defines a set of pods (namely those matching the labelSelector
+ relative to the given namespace(s)) that this pod should be
+ co-located (affinity) or not co-located (anti-affinity) with,
+ where co-located is defined as running on a node whose value of
+ the label with key <topologyKey> matches that of any node on which
+ a pod of the set of pods is running
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ required:
+ - topologyKey
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ podAntiAffinity:
+ description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
+ properties:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ The scheduler will prefer to schedule pods to nodes that satisfy
+ the anti-affinity expressions specified by this field, but it may choose
+ a node that violates one or more of the expressions. The node that is
+ most preferred is the one with the greatest sum of weights, i.e.
+ for each node that meets all of the scheduling requirements (resource
+ request, requiredDuringScheduling anti-affinity expressions, etc.),
+ compute a sum by iterating through the elements of this field and adding
+ "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
+ node(s) with the highest sum are the most preferred.
+ items:
+ description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
+ properties:
+ podAffinityTerm:
+ description: Required. A pod affinity term, associated with the corresponding weight.
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ required:
+ - topologyKey
+ type: object
+ weight:
+ description: |-
+ weight associated with matching the corresponding podAffinityTerm,
+ in the range 1-100.
+ format: int32
+ type: integer
+ required:
+ - podAffinityTerm
+ - weight
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ requiredDuringSchedulingIgnoredDuringExecution:
+ description: |-
+ If the anti-affinity requirements specified by this field are not met at
+ scheduling time, the pod will not be scheduled onto the node.
+ If the anti-affinity requirements specified by this field cease to be met
+ at some point during pod execution (e.g. due to a pod label update), the
+ system may or may not try to eventually evict the pod from its node.
+ When there are multiple elements, the lists of nodes corresponding to each
+ podAffinityTerm are intersected, i.e. all terms must be satisfied.
+ items:
+ description: |-
+ Defines a set of pods (namely those matching the labelSelector
+ relative to the given namespace(s)) that this pod should be
+ co-located (affinity) or not co-located (anti-affinity) with,
+ where co-located is defined as running on a node whose value of
+ the label with key <topologyKey> matches that of any node on which
+ a pod of the set of pods is running
+ properties:
+ labelSelector:
+ description: |-
+ A label query over a set of resources, in this case pods.
+ If it's null, this PodAffinityTerm matches with no Pods.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ matchLabelKeys:
+ description: |-
+ MatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both matchLabelKeys and labelSelector.
+ Also, matchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ mismatchLabelKeys:
+ description: |-
+ MismatchLabelKeys is a set of pod label keys to select which pods will
+ be taken into consideration. The keys are used to lookup values from the
+ incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
+ to select the group of existing pods which pods will be taken into consideration
+ for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
+ pod labels will be ignored. The default value is empty.
+ The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
+ Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
+ This is a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ namespaceSelector:
+ description: |-
+ A label query over the set of namespaces that the term applies to.
+ The term is applied to the union of the namespaces selected by this field
+ and the ones listed in the namespaces field.
+ null selector and null or empty namespaces list means "this pod's namespace".
+ An empty selector ({}) matches all namespaces.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ namespaces:
+ description: |-
+ namespaces specifies a static list of namespace names that the term applies to.
+ The term is applied to the union of the namespaces listed in this field
+ and the ones selected by namespaceSelector.
+ null or empty namespaces list and null namespaceSelector means "this pod's namespace".
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ topologyKey:
+ description: |-
+ This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
+ the labelSelector in the specified namespaces, where co-located is defined as running on a node
+ whose value of the label with key topologyKey matches that of any node on which any of the
+ selected pods is running.
+ Empty topologyKey is not allowed.
+ type: string
+ required:
+ - topologyKey
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ type: object
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ Annotations that will be added to IDP Pods. Any annotations
+ specified here will be merged with the default annotations applied to
+ the Pod by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ container:
+ description: Configuration for the IDP container.
+ properties:
+ env:
+ description: |-
+ List of environment variables to set in the container.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables
+ Note that environment variables provided here will take precedence
+ over Tailscale-specific environment variables set by the operator.
+ items:
+ properties:
+ name:
+ description: Name of the environment variable. Must be a C_IDENTIFIER.
+ pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
+ type: string
+ value:
+ description: |-
+ Variable references $(VAR_NAME) are expanded using the previously defined
+ environment variables in the container and any service environment
+ variables. If a variable cannot be resolved, the reference in the input
+ string will be unchanged. Double $$ are reduced to a single $, which
+ allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will
+ produce the string literal "$(VAR_NAME)". Escaped references will never
+ be expanded, regardless of whether the variable exists or not. Defaults
+ to "".
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ image:
+ description: |-
+ Container image name including tag. Defaults to the tsidp image
+ from the same source as the operator.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
+ type: string
+ imagePullPolicy:
+ description: |-
+ Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
+ enum:
+ - Always
+ - Never
+ - IfNotPresent
+ type: string
+ resources:
+ description: |-
+ Container resource requirements.
+ By default, the operator does not apply any resource requirements.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
+ properties:
+ claims:
+ description: |-
+ Claims lists the names of resources, defined in spec.resourceClaims,
+ that are used by this container.
+
+ This is an alpha field and requires enabling the
+ DynamicResourceAllocation feature gate.
+
+ This field is immutable. It can only be set for containers.
+ items:
+ description: ResourceClaim references one entry in PodSpec.ResourceClaims.
+ properties:
+ name:
+ description: |-
+ Name must match the name of one entry in pod.spec.resourceClaims of
+ the Pod where this field is used. It makes that resource available
+ inside a container.
+ type: string
+ request:
+ description: |-
+ Request is the name chosen for a request in the referenced claim.
+ If empty, everything from the claim is made available, otherwise
+ only the result of this request.
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
+ limits:
+ additionalProperties:
+ anyOf:
+ - type: integer
+ - type: string
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ x-kubernetes-int-or-string: true
+ description: |-
+ Limits describes the maximum amount of compute resources allowed.
+ More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
+ type: object
+ requests:
+ additionalProperties:
+ anyOf:
+ - type: integer
+ - type: string
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ x-kubernetes-int-or-string: true
+ description: |-
+ Requests describes the minimum amount of compute resources required.
+ If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
+ otherwise to an implementation-defined value. Requests cannot exceed Limits.
+ More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
+ type: object
+ type: object
+ securityContext:
+ description: |-
+ Container security context. By default, the operator does not apply any
+ container security context.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context
+ properties:
+ allowPrivilegeEscalation:
+ description: |-
+ AllowPrivilegeEscalation controls whether a process can gain more
+ privileges than its parent process. This bool directly controls if
+ the no_new_privs flag will be set on the container process.
+ AllowPrivilegeEscalation is true always when the container is:
+ 1) run as Privileged
+ 2) has CAP_SYS_ADMIN
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ appArmorProfile:
+ description: |-
+ appArmorProfile is the AppArmor options to use by this container. If set, this profile
+ overrides the pod's appArmorProfile.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile loaded on the node that should be used.
+ The profile must be preconfigured on the node to work.
+ Must match the loaded name of the profile.
+ Must be set if and only if type is "Localhost".
+ type: string
+ type:
+ description: |-
+ type indicates which kind of AppArmor profile will be applied.
+ Valid options are:
+ Localhost - a profile pre-loaded on the node.
+ RuntimeDefault - the container runtime's default profile.
+ Unconfined - no AppArmor enforcement.
+ type: string
+ required:
+ - type
+ type: object
+ capabilities:
+ description: |-
+ The capabilities to add/drop when running containers.
+ Defaults to the default set of capabilities granted by the container runtime.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ add:
+ description: Added capabilities
+ items:
+ description: Capability represent POSIX capabilities type
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ drop:
+ description: Removed capabilities
+ items:
+ description: Capability represent POSIX capabilities type
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ privileged:
+ description: |-
+ Run container in privileged mode.
+ Processes in privileged containers are essentially equivalent to root on the host.
+ Defaults to false.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ procMount:
+ description: |-
+ procMount denotes the type of proc mount to use for the containers.
+ The default value is Default which uses the container runtime defaults for
+ readonly paths and masked paths.
+ This requires the ProcMountType feature flag to be enabled.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ readOnlyRootFilesystem:
+ description: |-
+ Whether this container has a read-only root filesystem.
+ Default is false.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: boolean
+ runAsGroup:
+ description: |-
+ The GID to run the entrypoint of the container process.
+ Uses runtime default if unset.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ format: int64
+ type: integer
+ runAsNonRoot:
+ description: |-
+ Indicates that the container must run as a non-root user.
+ If true, the Kubelet will validate the image at runtime to ensure that it
+ does not run as UID 0 (root) and fail to start the container if it does.
+ If unset or false, no such validation will be performed.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: boolean
+ runAsUser:
+ description: |-
+ The UID to run the entrypoint of the container process.
+ Defaults to user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ format: int64
+ type: integer
+ seLinuxOptions:
+ description: |-
+ The SELinux context to be applied to the container.
+ If unspecified, the container runtime will allocate a random SELinux context for each
+ container. May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ level:
+ description: Level is SELinux level label that applies to the container.
+ type: string
+ role:
+ description: Role is a SELinux role label that applies to the container.
+ type: string
+ type:
+ description: Type is a SELinux type label that applies to the container.
+ type: string
+ user:
+ description: User is a SELinux user label that applies to the container.
+ type: string
+ type: object
+ seccompProfile:
+ description: |-
+ The seccomp options to use by this container. If seccomp options are
+ provided at both the pod & container level, the container options
+ override the pod options.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile defined in a file on the node should be used.
+ The profile must be preconfigured on the node to work.
+ Must be a descending path, relative to the kubelet's configured seccomp profile location.
+ Must be set if type is "Localhost". Must NOT be set for any other type.
+ type: string
+ type:
+ description: |-
+ type indicates which kind of seccomp profile will be applied.
+ Valid options are:
+
+ Localhost - a profile defined in a file on the node should be used.
+ RuntimeDefault - the container runtime default profile should be used.
+ Unconfined - no profile should be applied.
+ type: string
+ required:
+ - type
+ type: object
+ windowsOptions:
+ description: |-
+ The Windows specific settings applied to all containers.
+ If unspecified, the options from the PodSecurityContext will be used.
+ If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is linux.
+ properties:
+ gmsaCredentialSpec:
+ description: |-
+ GMSACredentialSpec is where the GMSA admission webhook
+ (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+ GMSA credential spec named by the GMSACredentialSpecName field.
+ type: string
+ gmsaCredentialSpecName:
+ description: GMSACredentialSpecName is the name of the GMSA credential spec to use.
+ type: string
+ hostProcess:
+ description: |-
+ HostProcess determines if a container should be run as a 'Host Process' container.
+ All of a Pod's containers must have the same effective HostProcess value
+ (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+ In addition, if HostProcess is true then HostNetwork must also be set to true.
+ type: boolean
+ runAsUserName:
+ description: |-
+ The UserName in Windows to run the entrypoint of the container process.
+ Defaults to the user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: string
+ type: object
+ type: object
+ type: object
+ imagePullSecrets:
+ description: |-
+ Image pull Secrets for IDP Pods.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec
+ items:
+ description: |-
+ LocalObjectReference contains enough information to let you locate the
+ referenced object inside the same namespace.
+ properties:
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
+ labels:
+ additionalProperties:
+ type: string
+ description: |-
+ Labels that will be added to IDP Pods. Any labels specified here
+ will be merged with the default labels applied to the Pod by the operator.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
+ type: object
+ nodeSelector:
+ additionalProperties:
+ type: string
+ description: |-
+ Node selector rules for IDP Pods. By default, the operator does
+ not apply any node selector rules.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling
+ type: object
+ securityContext:
+ description: |-
+ Security context for IDP Pods. By default, the operator does not
+ apply any Pod security context.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2
+ properties:
+ appArmorProfile:
+ description: |-
+ appArmorProfile is the AppArmor options to use by the containers in this pod.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile loaded on the node that should be used.
+ The profile must be preconfigured on the node to work.
+ Must match the loaded name of the profile.
+ Must be set if and only if type is "Localhost".
+ type: string
+ type:
+ description: |-
+ type indicates which kind of AppArmor profile will be applied.
+ Valid options are:
+ Localhost - a profile pre-loaded on the node.
+ RuntimeDefault - the container runtime's default profile.
+ Unconfined - no AppArmor enforcement.
+ type: string
+ required:
+ - type
+ type: object
+ fsGroup:
+ description: |-
+ A special supplemental group that applies to all containers in a pod.
+ Some volume types allow the Kubelet to change the ownership of that volume
+ to be owned by the pod:
+
+ 1. The owning GID will be the FSGroup
+ 2. The setgid bit is set (new files created in the volume will be owned by FSGroup)
+ 3. The permission bits are OR'd with rw-rw----
+
+ If unset, the Kubelet will not modify the ownership and permissions of any volume.
+ Note that this field cannot be set when spec.os.name is windows.
+ format: int64
+ type: integer
+ fsGroupChangePolicy:
+ description: |-
+ fsGroupChangePolicy defines behavior of changing ownership and permission of the volume
+ before being exposed inside Pod. This field will only apply to
+ volume types which support fsGroup based ownership(and permissions).
+ It will have no effect on ephemeral volume types such as: secret, configmaps
+ and emptydir.
+ Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ runAsGroup:
+ description: |-
+ The GID to run the entrypoint of the container process.
+ Uses runtime default if unset.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence
+ for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ format: int64
+ type: integer
+ runAsNonRoot:
+ description: |-
+ Indicates that the container must run as a non-root user.
+ If true, the Kubelet will validate the image at runtime to ensure that it
+ does not run as UID 0 (root) and fail to start the container if it does.
+ If unset or false, no such validation will be performed.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: boolean
+ runAsUser:
+ description: |-
+ The UID to run the entrypoint of the container process.
+ Defaults to user specified in image metadata if unspecified.
+ May also be set in SecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence
+ for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ format: int64
+ type: integer
+ seLinuxChangePolicy:
+ description: |-
+ seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
+ It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
+ Valid values are "MountOption" and "Recursive".
+
+ "Recursive" means relabeling of all files on all Pod volumes by the container runtime.
+ This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
+
+ "MountOption" mounts all eligible Pod volumes with `-o context` mount option.
+ This requires all Pods that share the same volume to use the same SELinux label.
+ It is not possible to share the same volume among privileged and unprivileged Pods.
+ Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
+ whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
+ CSIDriver instance. Other volumes are always re-labelled recursively.
+ "MountOption" value is allowed only when SELinuxMount feature gate is enabled.
+
+ If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
+ If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
+ and "Recursive" for all other volumes.
+
+ This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
+
+ All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ seLinuxOptions:
+ description: |-
+ The SELinux context to be applied to all containers.
+ If unspecified, the container runtime will allocate a random SELinux context for each
+ container. May also be set in SecurityContext. If set in
+ both SecurityContext and PodSecurityContext, the value specified in SecurityContext
+ takes precedence for that container.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ level:
+ description: Level is SELinux level label that applies to the container.
+ type: string
+ role:
+ description: Role is a SELinux role label that applies to the container.
+ type: string
+ type:
+ description: Type is a SELinux type label that applies to the container.
+ type: string
+ user:
+ description: User is a SELinux user label that applies to the container.
+ type: string
+ type: object
+ seccompProfile:
+ description: |-
+ The seccomp options to use by the containers in this pod.
+ Note that this field cannot be set when spec.os.name is windows.
+ properties:
+ localhostProfile:
+ description: |-
+ localhostProfile indicates a profile defined in a file on the node should be used.
+ The profile must be preconfigured on the node to work.
+ Must be a descending path, relative to the kubelet's configured seccomp profile location.
+ Must be set if type is "Localhost". Must NOT be set for any other type.
+ type: string
+ type:
+ description: |-
+ type indicates which kind of seccomp profile will be applied.
+ Valid options are:
+
+ Localhost - a profile defined in a file on the node should be used.
+ RuntimeDefault - the container runtime default profile should be used.
+ Unconfined - no profile should be applied.
+ type: string
+ required:
+ - type
+ type: object
+ supplementalGroups:
+ description: |-
+ A list of groups applied to the first process run in each container, in
+ addition to the container's primary GID and fsGroup (if specified). If
+ the SupplementalGroupsPolicy feature is enabled, the
+ supplementalGroupsPolicy field determines whether these are in addition
+ to or instead of any group memberships defined in the container image.
+ If unspecified, no additional groups are added, though group memberships
+ defined in the container image may still be used, depending on the
+ supplementalGroupsPolicy field.
+ Note that this field cannot be set when spec.os.name is windows.
+ items:
+ format: int64
+ type: integer
+ type: array
+ x-kubernetes-list-type: atomic
+ supplementalGroupsPolicy:
+ description: |-
+ Defines how supplemental groups of the first container processes are calculated.
+ Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
+ (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
+ and the container runtime must implement support for this feature.
+ Note that this field cannot be set when spec.os.name is windows.
+ type: string
+ sysctls:
+ description: |-
+ Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
+ sysctls (by the container runtime) might fail to launch.
+ Note that this field cannot be set when spec.os.name is windows.
+ items:
+ description: Sysctl defines a kernel parameter to be set
+ properties:
+ name:
+ description: Name of a property to set
+ type: string
+ value:
+ description: Value of a property to set
+ type: string
+ required:
+ - name
+ - value
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ windowsOptions:
+ description: |-
+ The Windows specific settings applied to all containers.
+ If unspecified, the options within a container's SecurityContext will be used.
+ If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+ Note that this field cannot be set when spec.os.name is linux.
+ properties:
+ gmsaCredentialSpec:
+ description: |-
+ GMSACredentialSpec is where the GMSA admission webhook
+ (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+ GMSA credential spec named by the GMSACredentialSpecName field.
+ type: string
+ gmsaCredentialSpecName:
+ description: GMSACredentialSpecName is the name of the GMSA credential spec to use.
+ type: string
+ hostProcess:
+ description: |-
+ HostProcess determines if a container should be run as a 'Host Process' container.
+ All of a Pod's containers must have the same effective HostProcess value
+ (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+ In addition, if HostProcess is true then HostNetwork must also be set to true.
+ type: boolean
+ runAsUserName:
+ description: |-
+ The UserName in Windows to run the entrypoint of the container process.
+ Defaults to the user specified in image metadata if unspecified.
+ May also be set in PodSecurityContext. If set in both SecurityContext and
+ PodSecurityContext, the value specified in SecurityContext takes precedence.
+ type: string
+ type: object
+ type: object
+ serviceAccount:
+ description: |-
+ Config for the ServiceAccount to create for the IDP's StatefulSet.
+ By default, the operator will create a ServiceAccount with the same
+ name as the IDP resource.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ Annotations to add to the ServiceAccount.
+ https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
+ type: object
+ name:
+ description: |-
+ Name of the ServiceAccount to create. Defaults to the name of the
+ IDP resource.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account
+ maxLength: 253
+ pattern: ^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$
+ type: string
+ type: object
+ tolerations:
+ description: |-
+ Tolerations for IDP Pods. By default, the operator does not apply
+ any tolerations.
+ https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling
+ items:
+ description: |-
+ The pod this Toleration is attached to tolerates any taint that matches
+ the triple <key,value,effect> using the matching operator <operator>.
+ properties:
+ effect:
+ description: |-
+ Effect indicates the taint effect to match. Empty means match all taint effects.
+ When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.
+ type: string
+ key:
+ description: |-
+ Key is the taint key that the toleration applies to. Empty means match all taint keys.
+ If the key is empty, operator must be Exists; this combination means to match all values and all keys.
+ type: string
+ operator:
+ description: |-
+ Operator represents a key's relationship to the value.
+ Valid operators are Exists and Equal. Defaults to Equal.
+ Exists is equivalent to wildcard for value, so that a pod can
+ tolerate all taints of a particular category.
+ type: string
+ tolerationSeconds:
+ description: |-
+ TolerationSeconds represents the period of time the toleration (which must be
+ of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,
+ it is not set, which means tolerate the taint forever (do not evict). Zero and
+ negative values will be treated as 0 (evict immediately) by the system.
+ format: int64
+ type: integer
+ value:
+ description: |-
+ Value is the taint value the toleration matches to.
+ If the operator is Exists, the value should be empty, otherwise just a regular string.
+ type: string
+ type: object
+ type: array
+ type: object
+ type: object
+ tags:
+ description: |-
+ Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s].
+ If you specify custom tags here, make sure you also make the operator
+ an owner of these tags.
+ See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
+ Tags cannot be changed once an IDP node has been created.
+ Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
+ items:
+ pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
+ type: string
+ type: array
+ type: object
+ status:
+ description: |-
+ IDPStatus describes the status of the IDP. This is set
+ and managed by the Tailscale operator.
+ properties:
+ conditions:
+ description: |-
+ List of status conditions to indicate the status of IDP.
+ Known condition types are `IDPReady`.
+ items:
+ description: Condition contains details for one aspect of the current state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ hostname:
+ description: |-
+ Hostname is the fully qualified domain name of the IDP device.
+ If MagicDNS is enabled in your tailnet, it is the MagicDNS name.
+ type: string
+ observedGeneration:
+ description: ObservedGeneration is the last observed generation of the IDP resource.
+ format: int64
+ type: integer
+ tailnetIPs:
+ description: |-
+ TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
+ assigned to the IDP device.
+ items:
+ type: string
+ type: array
+ url:
+ description: |-
+ URL where the OIDC provider is accessible.
+ This will be an HTTPS MagicDNS URL, or a public URL if Funnel is enabled.
+ type: string
+ 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.17.0
name: proxyclasses.tailscale.com
spec:
group: tailscale.com
@@ -5010,6 +6758,16 @@ rules:
- watch
- update
- apiGroups:
+ - tailscale.com
+ resources:
+ - idps
+ - idps/status
+ verbs:
+ - get
+ - list
+ - watch
+ - update
+ - apiGroups:
- apiextensions.k8s.io
resourceNames:
- servicemonitors.monitoring.coreos.com
diff --git a/cmd/k8s-operator/generate/main.go b/cmd/k8s-operator/generate/main.go
index 25435a47c..6f5145b0d 100644
--- a/cmd/k8s-operator/generate/main.go
+++ b/cmd/k8s-operator/generate/main.go
@@ -26,12 +26,14 @@ const (
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml"
proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml"
+ idpCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_idps.yaml"
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml"
proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml"
+ idpCRDHelmTemplatePath = helmTemplatesPath + "/idp.yaml"
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
helmConditionalEnd = "{{- end -}}"
@@ -115,7 +117,7 @@ func main() {
}
}
-// generate places tailscale.com CRDs (currently Connector, ProxyClass, DNSConfig, Recorder) into
+// generate places tailscale.com CRDs (currently Connector, ProxyClass, DNSConfig, Recorder, ProxyGroup, TSIDP) into
// the Helm chart templates behind .Values.installCRDs=true condition (true by
// default).
func generate(baseDir string) error {
@@ -149,6 +151,7 @@ func generate(baseDir string) error {
{dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath},
{recorderCRDPath, recorderCRDHelmTemplatePath},
{proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath},
+ {idpCRDPath, idpCRDHelmTemplatePath},
} {
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
@@ -165,6 +168,7 @@ func cleanup(baseDir string) error {
dnsConfigCRDHelmTemplatePath,
recorderCRDHelmTemplatePath,
proxyGroupCRDHelmTemplatePath,
+ idpCRDHelmTemplatePath,
} {
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up %s: %w", path, err)
diff --git a/cmd/k8s-operator/idp.go b/cmd/k8s-operator/idp.go
new file mode 100644
index 000000000..f4d5b04ba
--- /dev/null
+++ b/cmd/k8s-operator/idp.go
@@ -0,0 +1,540 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !plan9
+
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "slices"
+ "strings"
+ "sync"
+
+ "go.uber.org/zap"
+ xslices "golang.org/x/exp/slices"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ rbacv1 "k8s.io/api/rbac/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"
+
+ "tailscale.com/client/tailscale"
+ tsoperator "tailscale.com/k8s-operator"
+ tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/kube/kubetypes"
+ "tailscale.com/tailcfg"
+ "tailscale.com/tstime"
+ "tailscale.com/util/clientmetric"
+ "tailscale.com/util/set"
+)
+
+const (
+ reasonIDPCreationFailed = "IDPCreationFailed"
+ reasonIDPCreating = "IDPCreating"
+ reasonIDPCreated = "IDPCreated"
+ reasonIDPInvalid = "IDPInvalid"
+
+ // emptyJSONObject is the initial value for funnel clients secret data
+ emptyJSONObject = "{}"
+
+ // Network constants
+ minPort = 1
+ maxPort = 65535
+)
+
+var (
+ // dnsLabelRegex validates DNS labels according to RFC 1123
+ dnsLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
+)
+
+var gaugeIDPResources = clientmetric.NewGauge(kubetypes.MetricIDPCount)
+
+// IDPReconciler syncs IDP statefulsets with their definition in
+// IDP CRs.
+type IDPReconciler struct {
+ client.Client
+ l *zap.SugaredLogger
+ recorder record.EventRecorder
+ clock tstime.Clock
+ tsNamespace string
+ tsClient tsClient
+ loginServer string // optional URL of the control server
+
+ mu sync.Mutex // protects following
+ idps set.Slice[types.UID] // for idps gauge
+}
+
+func (r *IDPReconciler) logger(name string) *zap.SugaredLogger {
+ return r.l.With("IDP", name)
+}
+
+func (r *IDPReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
+ logger := r.logger(req.Name)
+ logger.Debugf("starting reconcile")
+ defer func() {
+ if err != nil {
+ logger.Errorf("reconcile finished with error: %v", err)
+ } else {
+ logger.Debugf("reconcile finished")
+ }
+ }()
+
+ idp := new(tsapi.IDP)
+ err = r.Get(ctx, req.NamespacedName, idp)
+ if apierrors.IsNotFound(err) {
+ logger.Debugf("IDP not found, assuming it was deleted")
+ return reconcile.Result{}, nil
+ } else if err != nil {
+ return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com IDP: %w", err)
+ }
+ if markedForDeletion(idp) {
+ logger.Debugf("IDP is being deleted, cleaning up resources")
+ ix := xslices.Index(idp.Finalizers, FinalizerName)
+ if ix < 0 {
+ logger.Debugf("no finalizer, nothing to do")
+ return reconcile.Result{}, nil
+ }
+
+ if done, err := r.maybeCleanup(ctx, idp); err != nil {
+ if strings.Contains(err.Error(), optimisticLockErrorMsg) {
+ logger.Debugf("optimistic lock error during cleanup, retrying: %v", err)
+ return reconcile.Result{RequeueAfter: shortRequeue}, nil
+ }
+ return reconcile.Result{}, err
+ } else if !done {
+ logger.Debugf("IDP resource cleanup not yet finished, will retry...")
+ return reconcile.Result{RequeueAfter: shortRequeue}, nil
+ }
+
+ idp.Finalizers = slices.Delete(idp.Finalizers, ix, ix+1)
+ if err := r.Update(ctx, idp); err != nil {
+ return reconcile.Result{}, err
+ }
+ return reconcile.Result{}, nil
+ }
+
+ oldIDPStatus := idp.Status.DeepCopy()
+ setStatusReady := func(idp *tsapi.IDP, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
+ tsoperator.SetIDPCondition(idp, tsapi.IDPReady, status, reason, message, idp.Generation, r.clock, logger)
+ if !apiequality.Semantic.DeepEqual(oldIDPStatus, &idp.Status) {
+ // An error encountered here should get returned by the Reconcile function.
+ if updateErr := r.Client.Status().Update(ctx, idp); updateErr != nil {
+ err = errors.Join(err, updateErr)
+ }
+ }
+ return reconcile.Result{}, err
+ }
+
+ if !slices.Contains(idp.Finalizers, FinalizerName) {
+ // Log once during initial provisioning when finalizer is added.
+ logger.Infof("ensuring IDP is set up")
+ idp.Finalizers = append(idp.Finalizers, FinalizerName)
+ if err := r.Update(ctx, idp); err != nil {
+ return setStatusReady(idp, metav1.ConditionFalse, reasonIDPCreationFailed, fmt.Sprintf("failed to add finalizer: %v", err))
+ }
+ }
+
+ if err := r.validate(ctx, idp); err != nil {
+ message := fmt.Sprintf("IDP is invalid: %s", err)
+ r.recorder.Eventf(idp, corev1.EventTypeWarning, reasonIDPInvalid, message)
+ return setStatusReady(idp, metav1.ConditionFalse, reasonIDPInvalid, message)
+ }
+
+ if err = r.maybeProvision(ctx, idp); err != nil {
+ reason := reasonIDPCreationFailed
+ message := fmt.Sprintf("failed creating IDP: %s", err)
+ if strings.Contains(err.Error(), optimisticLockErrorMsg) {
+ reason = reasonIDPCreating
+ message = fmt.Sprintf("optimistic lock error, retrying: %s", err)
+ err = nil
+ logger.Info(message)
+ } else {
+ r.recorder.Eventf(idp, corev1.EventTypeWarning, reasonIDPCreationFailed, message)
+ }
+ return setStatusReady(idp, metav1.ConditionFalse, reason, message)
+ }
+
+ logger.Info("IDP resources synced")
+
+ // Update status with device information, similar to how Recorder does it
+ if err = r.updateStatus(ctx, idp); err != nil {
+ return setStatusReady(idp, metav1.ConditionFalse, reasonIDPCreationFailed, fmt.Sprintf("failed updating status: %s", err))
+ }
+
+ // Update the status after successful provisioning.
+ // Note: oldIDPStatus was captured before maybeProvision, so any status
+ // updates made during provisioning will be included in the update.
+ return setStatusReady(idp, metav1.ConditionTrue, reasonIDPCreated, reasonIDPCreated)
+}
+
+// validate validates the IDP spec.
+func (r *IDPReconciler) validate(_ context.Context, idp *tsapi.IDP) error {
+ // Validate tags using the standard CheckTag function
+ for _, tag := range idp.Spec.Tags {
+ if err := tailcfg.CheckTag(string(tag)); err != nil {
+ return fmt.Errorf("invalid tag %q: %w", tag, err)
+ }
+ }
+
+ // Validate hostname
+ if idp.Spec.Hostname != "" {
+ if len(idp.Spec.Hostname) > 63 {
+ return fmt.Errorf("hostname %q must be 63 characters or less", idp.Spec.Hostname)
+ }
+ // Validate hostname format (DNS label)
+ if !isValidDNSLabel(idp.Spec.Hostname) {
+ return fmt.Errorf("hostname %q must be a valid DNS label (lowercase letters, numbers, and hyphens only; cannot start or end with hyphen)", idp.Spec.Hostname)
+ }
+ }
+
+ // Validate port
+ if idp.Spec.Port != 0 {
+ if idp.Spec.Port < minPort || idp.Spec.Port > maxPort {
+ return fmt.Errorf("port %d is out of valid range (%d-%d)", idp.Spec.Port, minPort, maxPort)
+ }
+ }
+
+ // Validate local port
+ if idp.Spec.LocalPort != nil {
+ if *idp.Spec.LocalPort < minPort || *idp.Spec.LocalPort > maxPort {
+ return fmt.Errorf("localPort %d is out of valid range (%d-%d)", *idp.Spec.LocalPort, minPort, maxPort)
+ }
+ }
+
+ // Validate funnel with port
+ if idp.Spec.EnableFunnel && idp.Spec.Port != 0 && idp.Spec.Port != 443 {
+ return fmt.Errorf("when enableFunnel is true, port must be 443 or unset")
+ }
+
+ return nil
+}
+
+// maybeProvision ensures that all IDP resources are created as needed.
+func (r *IDPReconciler) maybeProvision(ctx context.Context, idp *tsapi.IDP) error {
+ logger := r.logger(idp.Name)
+
+ // Ensure ServiceAccount exists
+ logger.Debugf("ensuring ServiceAccount %s exists", idp.Name)
+ sa := idpServiceAccount(idp, r.tsNamespace)
+ if _, err := createOrMaybeUpdate(ctx, r.Client, r.tsNamespace, sa, func(existing *corev1.ServiceAccount) error {
+ // Check that we don't clobber a pre-existing ServiceAccount not owned by this IDP
+ if sa.Name != idp.Name && !apiequality.Semantic.DeepEqual(existing.OwnerReferences, idpOwnerReference(idp)) {
+ return fmt.Errorf("custom ServiceAccount name %q specified but conflicts with a pre-existing ServiceAccount in the %s namespace", sa.Name, sa.Namespace)
+ }
+
+ existing.Annotations = sa.Annotations
+ existing.Labels = sa.Labels
+ return nil
+ }); err != nil {
+ return fmt.Errorf("failed to create or update ServiceAccount: %w", err)
+ }
+
+ // Clean up any old ServiceAccounts if the name changed
+ if err := r.maybeCleanupServiceAccounts(ctx, idp, sa.Name); err != nil {
+ return fmt.Errorf("failed to cleanup old ServiceAccounts: %w", err)
+ }
+ logger.Debugf("ServiceAccount synced")
+
+ // Ensure Role exists
+ role := idpRole(idp, r.tsNamespace)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(existing *rbacv1.Role) {
+ existing.Rules = role.Rules
+ existing.Labels = role.Labels
+ existing.Annotations = role.Annotations
+ }); err != nil {
+ return fmt.Errorf("failed to create or update Role: %w", err)
+ }
+ logger.Debugf("Role synced")
+
+ // Ensure RoleBinding exists
+ roleBinding := idpRoleBinding(idp, r.tsNamespace)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, roleBinding, func(existing *rbacv1.RoleBinding) {
+ existing.RoleRef = roleBinding.RoleRef
+ existing.Subjects = roleBinding.Subjects
+ existing.Labels = roleBinding.Labels
+ existing.Annotations = roleBinding.Annotations
+ }); err != nil {
+ return fmt.Errorf("failed to create or update RoleBinding: %w", err)
+ }
+ logger.Debugf("RoleBinding synced")
+
+ // Create auth secret
+ logger.Debugf("ensuring auth secret exists")
+ authSecret, err := r.authSecret(ctx, idp)
+ if err != nil {
+ return fmt.Errorf("failed to create auth secret: %w", err)
+ }
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, authSecret, func(existing *corev1.Secret) {
+ existing.StringData = authSecret.StringData
+ }); err != nil {
+ return fmt.Errorf("failed to create or update auth Secret: %w", err)
+ }
+ logger.Debugf("Auth Secret synced")
+
+ // State Secret is precreated so we can use the IDP CR as its owner ref.
+ // This follows the same pattern as the Recorder reconciler.
+ stateSecret := idpStateSecret(idp, r.tsNamespace)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, stateSecret, func(s *corev1.Secret) {
+ s.ObjectMeta.Labels = stateSecret.ObjectMeta.Labels
+ s.ObjectMeta.Annotations = stateSecret.ObjectMeta.Annotations
+ }); err != nil {
+ return fmt.Errorf("error creating state Secret: %w", err)
+ }
+ logger.Debugf("State Secret synced")
+
+ // Ensure funnel clients secret exists with proper owner reference.
+ // This secret stores state for the IDP when running with funnel enabled.
+ funnelClientsSecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: fmt.Sprintf("%s-funnel-clients", idp.Name),
+ Namespace: r.tsNamespace,
+ Labels: map[string]string{"app": "idp", "idp": idp.Name},
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ Data: map[string][]byte{
+ "funnel-clients": []byte(emptyJSONObject),
+ },
+ }
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, funnelClientsSecret, func(existing *corev1.Secret) {
+ existing.Labels = funnelClientsSecret.Labels
+ // Initialize data if it doesn't exist, but don't overwrite existing data
+ if existing.Data == nil {
+ existing.Data = map[string][]byte{}
+ }
+ if _, exists := existing.Data["funnel-clients"]; !exists {
+ existing.Data["funnel-clients"] = []byte(emptyJSONObject)
+ }
+ }); err != nil {
+ return fmt.Errorf("failed to create or update funnel clients Secret: %w", err)
+ }
+ logger.Debugf("Funnel clients Secret synced")
+
+ // Ensure StatefulSet exists
+ sts := idpStatefulSet(idp, r.tsNamespace, r.loginServer)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sts, func(existing *appsv1.StatefulSet) {
+ existing.Spec.Replicas = sts.Spec.Replicas
+ existing.Spec.Template = sts.Spec.Template
+ existing.Labels = sts.Labels
+ existing.Annotations = sts.Annotations
+ }); err != nil {
+ return fmt.Errorf("failed to create or update StatefulSet: %w", err)
+ }
+ logger.Debugf("StatefulSet synced")
+
+ // Create Service for OIDC endpoints
+ svc := idpService(idp, r.tsNamespace)
+ if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, svc, func(existing *corev1.Service) {
+ existing.Spec.Selector = svc.Spec.Selector
+ existing.Spec.Ports = svc.Spec.Ports
+ existing.Spec.Type = svc.Spec.Type
+ }); err != nil {
+ return fmt.Errorf("failed to create or update Service: %w", err)
+ }
+ logger.Debugf("Service synced")
+
+ // Update gauge metrics
+ r.mu.Lock()
+ r.idps.Add(idp.UID)
+ gaugeIDPResources.Set(int64(r.idps.Len()))
+ r.mu.Unlock()
+ logger.Debugf("updated metrics, total IDPs: %d", r.idps.Len())
+
+ // Don't update status here - it will be updated in the main reconcile loop
+ // after provisioning is complete, similar to how Recorder works
+ return nil
+}
+
+// updateStatus updates the IDP status with current device information.
+func (r *IDPReconciler) updateStatus(ctx context.Context, idp *tsapi.IDP) error {
+ logger := r.logger(idp.Name)
+
+ // Update basic status fields
+ idp.Status.ObservedGeneration = idp.Generation
+
+ // Set hostname
+ if idp.Spec.Hostname != "" {
+ idp.Status.Hostname = idp.Spec.Hostname
+ } else {
+ idp.Status.Hostname = "idp"
+ }
+
+ // Check kubestore state secret for device info.
+ stateSecretName := fmt.Sprintf("%s-state", idp.Name)
+ stateSecret := &corev1.Secret{}
+ if err := r.Get(ctx, client.ObjectKey{
+ Name: stateSecretName,
+ Namespace: r.tsNamespace,
+ }, stateSecret); err != nil {
+ // Device not ready yet, don't set URL
+ logger.Debugf("state secret not found yet, device may still be initializing")
+ return nil
+ }
+
+ // Extract device info from kubestore state
+ prefs, ok, err := getDevicePrefs(stateSecret)
+ if err != nil {
+ return fmt.Errorf("error parsing state secret: %w", err)
+ }
+ if !ok || prefs.Config == nil || prefs.Config.NodeID == "" {
+ // Device not fully registered yet
+ logger.Debugf("device not fully registered yet")
+ return nil
+ }
+
+ // Get device details from API
+ device, err := r.tsClient.Device(ctx, string(prefs.Config.NodeID), nil)
+ if err != nil {
+ logger.Debugf("failed to get device info: %v", err)
+ // Don't fail on API errors, device exists but we can't get details
+ return nil
+ }
+
+ // Update status with actual device information
+ if device.Hostname != "" {
+ idp.Status.Hostname = device.Hostname
+ }
+
+ if len(device.Addresses) > 0 {
+ idp.Status.TailnetIPs = device.Addresses
+ }
+
+ // Set URL based on LoginName from prefs (MagicDNS name)
+ if dnsName := prefs.Config.UserProfile.LoginName; dnsName != "" {
+ idp.Status.URL = fmt.Sprintf("https://%s", dnsName)
+ }
+
+ logger.Debugf("updated status with device info from API")
+ return nil
+}
+
+// maybeCleanupServiceAccounts deletes any dangling ServiceAccounts owned by the IDP
+// if the ServiceAccount name has been changed. This is a no-op if the name hasn't changed.
+func (r *IDPReconciler) maybeCleanupServiceAccounts(ctx context.Context, idp *tsapi.IDP, currentName string) error {
+ logger := r.logger(idp.Name)
+
+ // List all ServiceAccounts owned by this IDP
+ sas := &corev1.ServiceAccountList{}
+ if err := r.List(ctx, sas, client.InNamespace(r.tsNamespace), client.MatchingLabels(map[string]string{
+ "app": "idp",
+ "idp": idp.Name,
+ })); err != nil {
+ return fmt.Errorf("error listing ServiceAccounts for cleanup: %w", err)
+ }
+
+ for _, sa := range sas.Items {
+ if sa.Name == currentName {
+ continue
+ }
+ if err := r.Delete(ctx, &sa); err != nil {
+ if apierrors.IsNotFound(err) {
+ logger.Debugf("ServiceAccount %s not found, likely already deleted", sa.Name)
+ } else {
+ return fmt.Errorf("error deleting ServiceAccount %s: %w", sa.Name, err)
+ }
+ } else {
+ logger.Debugf("deleted old ServiceAccount %s", sa.Name)
+ }
+ }
+
+ return nil
+}
+
+// maybeCleanup just deletes the device from the tailnet. All the kubernetes
+// resources linked to an IDP will get cleaned up via owner references
+// (which we can use because they are all in the same namespace).
+func (r *IDPReconciler) maybeCleanup(ctx context.Context, idp *tsapi.IDP) (bool, error) {
+ logger := r.logger(idp.Name)
+
+ // Get the state secret
+ stateSecretName := fmt.Sprintf("%s-state", idp.Name)
+ stateSecret := &corev1.Secret{}
+ err := r.Get(ctx, client.ObjectKey{
+ Name: stateSecretName,
+ Namespace: r.tsNamespace,
+ }, stateSecret)
+
+ if apierrors.IsNotFound(err) {
+ logger.Debugf("state Secret %s not found, device may not have been registered, continuing cleanup", stateSecretName)
+ r.mu.Lock()
+ r.idps.Remove(idp.UID)
+ gaugeIDPResources.Set(int64(r.idps.Len()))
+ r.mu.Unlock()
+ return true, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("error getting state Secret: %w", err)
+ }
+
+ // Extract device info from kubestore state secret
+ prefs, ok, err := getDevicePrefs(stateSecret)
+ if err != nil {
+ return false, fmt.Errorf("error parsing state Secret: %w", err)
+ }
+ if !ok || prefs.Config == nil {
+ logger.Debugf("state Secret %s does not contain node ID, continuing cleanup", stateSecretName)
+ r.mu.Lock()
+ r.idps.Remove(idp.UID)
+ gaugeIDPResources.Set(int64(r.idps.Len()))
+ r.mu.Unlock()
+ return true, nil
+ }
+
+ // Delete device from tailnet
+ nodeID := string(prefs.Config.NodeID)
+ logger.Debugf("deleting device %s from control", nodeID)
+ if err := r.tsClient.DeleteDevice(ctx, nodeID); err != nil {
+ errResp := &tailscale.ErrResponse{}
+ if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
+ logger.Debugf("device %s not found, likely because it has already been deleted from control", nodeID)
+ } else {
+ return false, fmt.Errorf("error deleting device: %w", err)
+ }
+ } else {
+ logger.Debugf("device %s deleted from control", nodeID)
+ }
+
+ // Log final cleanup completion before removing finalizer.
+ logger.Infof("cleaned up IDP resources")
+ r.mu.Lock()
+ r.idps.Remove(idp.UID)
+ gaugeIDPResources.Set(int64(r.idps.Len()))
+ r.mu.Unlock()
+ return true, nil
+}
+
+// authSecret creates a secret containing the auth key for the IDP.
+func (r *IDPReconciler) authSecret(ctx context.Context, idp *tsapi.IDP) (*corev1.Secret, error) {
+ logger := r.logger(idp.Name)
+
+ tags := idp.Spec.Tags
+ if len(tags) == 0 {
+ tags = tsapi.Tags{"tag:k8s"}
+ }
+
+ tagsSlice := make([]string, len(tags))
+ for i, tag := range tags {
+ tagsSlice[i] = string(tag)
+ }
+ authKey, err := newAuthKey(ctx, r.tsClient, tagsSlice)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create auth key: %w", err)
+ }
+ logger.Debugf("created auth key for tags %v", tags)
+
+ return idpAuthSecret(idp, r.tsNamespace, authKey), nil
+}
+
+// isValidDNSLabel checks if a string is a valid DNS label according to RFC 1123
+func isValidDNSLabel(label string) bool {
+ return dnsLabelRegex.MatchString(label)
+}
diff --git a/cmd/k8s-operator/idp_specs.go b/cmd/k8s-operator/idp_specs.go
new file mode 100644
index 000000000..8d617c648
--- /dev/null
+++ b/cmd/k8s-operator/idp_specs.go
@@ -0,0 +1,325 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !plan9
+
+package main
+
+import (
+ "fmt"
+ "strconv"
+
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ rbacv1 "k8s.io/api/rbac/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/intstr"
+
+ tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/types/ptr"
+)
+
+func idpStatefulSet(idp *tsapi.IDP, namespace string, loginServer string) *appsv1.StatefulSet {
+ return &appsv1.StatefulSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idp.Name,
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, idp.Spec.StatefulSet.Labels),
+ OwnerReferences: idpOwnerReference(idp),
+ Annotations: idp.Spec.StatefulSet.Annotations,
+ },
+ Spec: appsv1.StatefulSetSpec{
+ Replicas: ptr.To[int32](1),
+ Selector: &metav1.LabelSelector{
+ MatchLabels: labels("idp", idp.Name, idp.Spec.StatefulSet.Pod.Labels),
+ },
+ ServiceName: idp.Name,
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idp.Name,
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, idp.Spec.StatefulSet.Pod.Labels),
+ Annotations: idp.Spec.StatefulSet.Pod.Annotations,
+ },
+ Spec: corev1.PodSpec{
+ ServiceAccountName: idpServiceAccountName(idp),
+ Affinity: idp.Spec.StatefulSet.Pod.Affinity,
+ SecurityContext: idp.Spec.StatefulSet.Pod.SecurityContext,
+ ImagePullSecrets: idp.Spec.StatefulSet.Pod.ImagePullSecrets,
+ NodeSelector: idp.Spec.StatefulSet.Pod.NodeSelector,
+ Tolerations: idp.Spec.StatefulSet.Pod.Tolerations,
+ Containers: []corev1.Container{
+ {
+ Name: "idp",
+ Image: func() string {
+ image := idp.Spec.StatefulSet.Pod.Container.Image
+ if image == "" {
+ image = fmt.Sprintf("tailscale/tsidp:%s", selfVersionImageTag())
+ }
+ return image
+ }(),
+ ImagePullPolicy: idp.Spec.StatefulSet.Pod.Container.ImagePullPolicy,
+ Resources: idp.Spec.StatefulSet.Pod.Container.Resources,
+ SecurityContext: idp.Spec.StatefulSet.Pod.Container.SecurityContext,
+ Env: idpEnv(idp, loginServer),
+ Command: []string{"/usr/local/bin/tsidp"},
+ WorkingDir: "/data",
+ Ports: []corev1.ContainerPort{
+ {
+ Name: "https",
+ ContainerPort: idpPort(idp),
+ Protocol: corev1.ProtocolTCP,
+ },
+ },
+ VolumeMounts: []corev1.VolumeMount{
+ {
+ Name: "data",
+ MountPath: "/data",
+ ReadOnly: false,
+ },
+ },
+ },
+ },
+ Volumes: []corev1.Volume{
+ {
+ Name: "data",
+ VolumeSource: corev1.VolumeSource{
+ EmptyDir: &corev1.EmptyDirVolumeSource{},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func idpServiceAccount(idp *tsapi.IDP, namespace string) *corev1.ServiceAccount {
+ return &corev1.ServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idpServiceAccountName(idp),
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ Annotations: idp.Spec.StatefulSet.Pod.ServiceAccount.Annotations,
+ },
+ }
+}
+
+func idpServiceAccountName(idp *tsapi.IDP) string {
+ sa := idp.Spec.StatefulSet.Pod.ServiceAccount
+ name := idp.Name
+ if sa.Name != "" {
+ name = sa.Name
+ }
+ return name
+}
+
+func idpRole(idp *tsapi.IDP, namespace string) *rbacv1.Role {
+ return &rbacv1.Role{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idp.Name,
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{""},
+ Resources: []string{"secrets"},
+ Verbs: []string{"get", "patch", "update", "create"},
+ // IDP needs create permission for dynamic kubestore secrets
+ },
+ {
+ APIGroups: []string{""},
+ Resources: []string{"events"},
+ Verbs: []string{"get", "create", "patch"},
+ },
+ },
+ }
+}
+
+func idpRoleBinding(idp *tsapi.IDP, namespace string) *rbacv1.RoleBinding {
+ return &rbacv1.RoleBinding{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idp.Name,
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ Subjects: []rbacv1.Subject{
+ {
+ Kind: "ServiceAccount",
+ Name: idpServiceAccountName(idp),
+ Namespace: namespace,
+ },
+ },
+ RoleRef: rbacv1.RoleRef{
+ Kind: "Role",
+ Name: idp.Name,
+ },
+ }
+}
+
+func idpService(idp *tsapi.IDP, namespace string) *corev1.Service {
+ port := idpPort(idp)
+
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: idp.Name,
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ Spec: corev1.ServiceSpec{
+ Type: corev1.ServiceTypeClusterIP,
+ Selector: map[string]string{
+ "app.kubernetes.io/name": "idp",
+ "app.kubernetes.io/instance": idp.Name,
+ },
+ Ports: []corev1.ServicePort{
+ {
+ Name: "https",
+ Port: port,
+ TargetPort: intstr.FromInt(int(port)),
+ Protocol: corev1.ProtocolTCP,
+ },
+ },
+ },
+ }
+}
+
+func idpAuthSecret(idp *tsapi.IDP, namespace string, authKey string) *corev1.Secret {
+ return &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: namespace,
+ Name: idp.Name,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ StringData: map[string]string{
+ "authkey": authKey,
+ },
+ }
+}
+
+func idpEnv(idp *tsapi.IDP, loginServer string) []corev1.EnvVar {
+ env := []corev1.EnvVar{
+ {
+ Name: "TS_AUTHKEY",
+ ValueFrom: &corev1.EnvVarSource{
+ SecretKeyRef: &corev1.SecretKeySelector{
+ LocalObjectReference: corev1.LocalObjectReference{
+ Name: idp.Name,
+ },
+ Key: "authkey",
+ },
+ },
+ },
+ {
+ Name: "POD_NAME",
+ ValueFrom: &corev1.EnvVarSource{
+ FieldRef: &corev1.ObjectFieldSelector{
+ FieldPath: "metadata.name",
+ },
+ },
+ },
+ {
+ Name: "POD_UID",
+ ValueFrom: &corev1.EnvVarSource{
+ FieldRef: &corev1.ObjectFieldSelector{
+ FieldPath: "metadata.uid",
+ },
+ },
+ },
+ }
+
+ // Add TS_STATE to use Kubernetes secret for state storage
+ env = append(env, corev1.EnvVar{
+ Name: "TS_STATE",
+ Value: fmt.Sprintf("kube:%s-state", idp.Name),
+ })
+
+ // TSIDP configuration via environment variables
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_VERBOSE",
+ Value: "true",
+ })
+
+ env = append(env, corev1.EnvVar{
+ Name: "TS_HOSTNAME",
+ Value: idpHostname(idp),
+ })
+
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_PORT",
+ Value: strconv.Itoa(int(idpPort(idp))),
+ })
+
+ if idp.Spec.EnableFunnel {
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_FUNNEL",
+ Value: "true",
+ })
+ }
+
+ if idp.Spec.LocalPort != nil {
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_LOCAL_PORT",
+ Value: strconv.Itoa(int(*idp.Spec.LocalPort)),
+ })
+ }
+
+ // Add TSIDP_FUNNEL_CLIENTS_STORE for funnel client storage
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_FUNNEL_CLIENTS_STORE",
+ Value: fmt.Sprintf("kube:%s-funnel-clients", idp.Name),
+ })
+
+ // Add TSIDP_LOGIN_SERVER if loginServer is set
+ if loginServer != "" {
+ env = append(env, corev1.EnvVar{
+ Name: "TSIDP_LOGIN_SERVER",
+ Value: loginServer,
+ })
+ }
+
+ // Add custom environment variables
+ for _, customEnv := range idp.Spec.StatefulSet.Pod.Container.Env {
+ env = append(env, corev1.EnvVar{
+ Name: string(customEnv.Name),
+ Value: customEnv.Value,
+ })
+ }
+
+ return env
+}
+
+func idpHostname(idp *tsapi.IDP) string {
+ if idp.Spec.Hostname != "" {
+ return idp.Spec.Hostname
+ }
+ return "idp"
+}
+
+func idpPort(idp *tsapi.IDP) int32 {
+ if idp.Spec.Port != 0 {
+ return idp.Spec.Port
+ }
+ return 443
+}
+
+func idpStateSecret(idp *tsapi.IDP, namespace string) *corev1.Secret {
+ return &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: fmt.Sprintf("%s-state", idp.Name),
+ Namespace: namespace,
+ Labels: labels("idp", idp.Name, nil),
+ OwnerReferences: idpOwnerReference(idp),
+ },
+ }
+}
+
+func idpOwnerReference(owner metav1.Object) []metav1.OwnerReference {
+ return []metav1.OwnerReference{*metav1.NewControllerRef(owner, tsapi.SchemeGroupVersion.WithKind("IDP"))}
+}
diff --git a/cmd/k8s-operator/idp_test.go b/cmd/k8s-operator/idp_test.go
new file mode 100644
index 000000000..70b7cf90f
--- /dev/null
+++ b/cmd/k8s-operator/idp_test.go
@@ -0,0 +1,606 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !plan9
+
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "go.uber.org/zap"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ rbacv1 "k8s.io/api/rbac/v1"
+ 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/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/tstime"
+)
+
+func TestIDPReconciler_BasicFlow(t *testing.T) {
+ // Test basic creation flow similar to Recorder
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:k8s"},
+ },
+ }
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatalf("failed to create IDP: %v", err)
+ }
+
+ req := reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ }
+
+ _, err := r.Reconcile(context.Background(), req)
+ if err != nil {
+ t.Fatalf("reconciliation failed: %v", err)
+ }
+
+ // Verify resources were created
+ verifyResourcesCreated(t, fc, "test-idp", "tailscale")
+}
+
+func TestTSIDPEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ idp *tsapi.IDP
+ wantEnv map[string]string
+ }{
+ {
+ name: "basic",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 443,
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-test",
+ "TSIDP_PORT": "443",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ },
+ },
+ {
+ name: "with-funnel-and-local-port",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-mcp",
+ Port: 8443,
+ EnableFunnel: true,
+ LocalPort: &[]int32{9080}[0],
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-mcp",
+ "TSIDP_PORT": "8443",
+ "TSIDP_FUNNEL": "true",
+ "TSIDP_LOCAL_PORT": "9080",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ },
+ },
+ {
+ name: "with-custom-env",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-mcp",
+ Port: 8443,
+ EnableFunnel: true,
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ Container: tsapi.IDPContainer{
+ Env: []tsapi.Env{
+ {Name: tsapi.Name("CUSTOM_VAR"), Value: "custom-value"},
+ },
+ },
+ },
+ },
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-mcp",
+ "TSIDP_PORT": "8443",
+ "TSIDP_FUNNEL": "true",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ "CUSTOM_VAR": "custom-value",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env := idpEnv(tt.idp, "")
+
+ envMap := make(map[string]string)
+ for _, e := range env {
+ if e.Value != "" {
+ envMap[e.Name] = e.Value
+ }
+ }
+
+ for key, expected := range tt.wantEnv {
+ if got, exists := envMap[key]; !exists {
+ t.Errorf("expected env var %s not found", key)
+ } else if got != expected {
+ t.Errorf("env var %s: expected %q, got %q", key, expected, got)
+ }
+ }
+
+ var hasAuthKey bool
+ for _, e := range env {
+ if e.Name == "TS_AUTHKEY" && e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
+ hasAuthKey = true
+ break
+ }
+ }
+ if !hasAuthKey {
+ t.Error("expected TS_AUTHKEY to be set via secret reference")
+ }
+ })
+ }
+}
+
+func TestIDPStatusConditions(t *testing.T) {
+ // Test that invalid specs produce proper status conditions
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ Finalizers: []string{FinalizerName},
+ },
+ Spec: tsapi.IDPSpec{
+ Tags: tsapi.Tags{"invalid-tag"}, // Missing tag: prefix
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithObjects(idp).
+ WithStatusSubresource(idp).
+ Build()
+
+ fr := record.NewFakeRecorder(10)
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: fr,
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ updatedIDP := &tsapi.IDP{}
+ if err := fc.Get(context.Background(), client.ObjectKey{Name: idp.Name, Namespace: idp.Namespace}, updatedIDP); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(updatedIDP.Status.Conditions) != 1 {
+ t.Fatalf("expected 1 condition, got %d", len(updatedIDP.Status.Conditions))
+ }
+
+ cond := updatedIDP.Status.Conditions[0]
+ if cond.Type != string(tsapi.IDPReady) || cond.Status != metav1.ConditionFalse || cond.Reason != reasonIDPInvalid {
+ t.Fatalf("expected condition IDPReady false with reason IDPInvalid, got %v", cond)
+ }
+
+ if !strings.Contains(cond.Message, "must start with 'tag:'") {
+ t.Errorf("expected validation error in condition message, got %q", cond.Message)
+ }
+
+ select {
+ case event := <-fr.Events:
+ if !strings.Contains(event, "IDPInvalid") {
+ t.Errorf("expected IDPInvalid event, got %q", event)
+ }
+ default:
+ t.Error("expected event to be recorded")
+ }
+}
+
+func TestIDPValidation(t *testing.T) {
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ }
+
+ tests := []struct {
+ name string
+ idp *tsapi.IDP
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:k8s", "tag:mcp"},
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid-tag-missing-prefix",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"invalid-tag"},
+ },
+ },
+ wantErr: true,
+ errMsg: "must start with 'tag:'",
+ },
+ {
+ name: "invalid-tag-empty-name",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:"},
+ },
+ },
+ wantErr: true,
+ errMsg: "tag names must not be empty",
+ },
+ {
+ name: "invalid-tag-special-chars",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:test@123"},
+ },
+ },
+ wantErr: true,
+ errMsg: "tag names can only contain numbers, letters, or dashes",
+ },
+ {
+ name: "hostname-too-long",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "this-hostname-is-way-too-long-and-exceeds-the-63-character-limit-for-dns-names",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be 63 characters or less",
+ },
+ {
+ name: "hostname-invalid-chars",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp_test",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be a valid DNS label",
+ },
+ {
+ name: "hostname-starts-with-dash",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "-idp-test",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be a valid DNS label",
+ },
+ {
+ name: "invalid-port-zero",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 0,
+ },
+ },
+ wantErr: false, // Port 0 means default (443)
+ },
+ {
+ name: "invalid-port-too-high",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 65536,
+ },
+ },
+ wantErr: true,
+ errMsg: "out of valid range",
+ },
+ {
+ name: "funnel-with-non-443-port",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ EnableFunnel: true,
+ Port: 8443,
+ },
+ },
+ wantErr: true,
+ errMsg: "port must be 443 or unset",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := r.validate(context.Background(), tt.idp)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("validate() error = %v, expected to contain %q", err, tt.errMsg)
+ }
+ })
+ }
+}
+
+func TestIDPServiceAccountHandling(t *testing.T) {
+ // Test custom ServiceAccount name works
+ t.Run("custom_service_account_name", func(t *testing.T) {
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ ServiceAccount: tsapi.IDPServiceAccount{
+ Name: "custom-sa",
+ },
+ },
+ },
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ // Verify custom ServiceAccount was created
+ sa := &corev1.ServiceAccount{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: "custom-sa",
+ Namespace: "tailscale",
+ }, sa); err != nil {
+ t.Errorf("expected custom ServiceAccount to be created: %v", err)
+ }
+ })
+
+ // Test ServiceAccount conflict detection
+ t.Run("service_account_conflict", func(t *testing.T) {
+ existingSA := &corev1.ServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "existing-sa",
+ Namespace: "tailscale",
+ OwnerReferences: []metav1.OwnerReference{
+ {
+ APIVersion: "v1",
+ Kind: "Pod",
+ Name: "other-pod",
+ UID: "12345",
+ },
+ },
+ },
+ }
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ ServiceAccount: tsapi.IDPServiceAccount{
+ Name: "existing-sa",
+ },
+ },
+ },
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ WithObjects(existingSA).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ req := reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: idp.Name,
+ Namespace: idp.Namespace,
+ },
+ }
+
+ _, err := r.Reconcile(context.Background(), req)
+ if err == nil {
+ t.Error("expected error for ServiceAccount conflict")
+ }
+ })
+}
+
+func TestIDPDeletion(t *testing.T) {
+ // Test deletion flow - similar to Recorder
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ Finalizers: []string{FinalizerName},
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ },
+ }
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create resources
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ // Delete IDP
+ if err := fc.Delete(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ // Reconcile deletion
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+}
+
+func verifyResourcesCreated(t *testing.T, fc client.Client, name, namespace string) {
+ t.Helper()
+
+ sa := &corev1.ServiceAccount{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, sa); err != nil {
+ t.Errorf("expected ServiceAccount to be created: %v", err)
+ }
+
+ role := &rbacv1.Role{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, role); err != nil {
+ t.Errorf("expected Role to be created: %v", err)
+ }
+
+ rb := &rbacv1.RoleBinding{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, rb); err != nil {
+ t.Errorf("expected RoleBinding to be created: %v", err)
+ }
+
+ sts := &appsv1.StatefulSet{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, sts); err != nil {
+ t.Errorf("expected StatefulSet to be created: %v", err)
+ }
+
+ svc := &corev1.Service{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, svc); err != nil {
+ t.Errorf("expected Service to be created: %v", err)
+ }
+
+ authSecret := &corev1.Secret{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, authSecret); err != nil {
+ t.Errorf("expected auth Secret to be created: %v", err)
+ }
+
+ funnelSecret := &corev1.Secret{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name + "-funnel-clients",
+ Namespace: namespace,
+ }, funnelSecret); err != nil {
+ t.Errorf("expected funnel clients Secret to be created: %v", err)
+ } else {
+ if data, ok := funnelSecret.Data["funnel-clients"]; !ok {
+ t.Error("expected funnel-clients data key in secret")
+ } else if string(data) != "{}" {
+ t.Errorf("expected funnel-clients data to be '{}', got '%s'", string(data))
+ }
+ }
+}
diff --git a/cmd/k8s-operator/kubestore_utils.go b/cmd/k8s-operator/kubestore_utils.go
new file mode 100644
index 000000000..fedb5b16c
--- /dev/null
+++ b/cmd/k8s-operator/kubestore_utils.go
@@ -0,0 +1,91 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !plan9
+
+// Package main contains shared utilities for working with kubestore secrets.
+// Kubestore is Tailscale's Kubernetes-backed state storage mechanism that
+// stores device state in pod-named secrets for StatefulSet workloads.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "tailscale.com/tailcfg"
+)
+
+const (
+ // currentProfileKey is the key in kubestore secrets that contains the current profile name
+ currentProfileKey = "_current-profile"
+)
+
+// kubeclientPrefs is a partial definition of ipn.Prefs, with just the fields we need.
+type kubeclientPrefs struct {
+ Config *kubeclientConfig `json:"Config"`
+}
+
+type kubeclientConfig struct {
+ NodeID tailcfg.StableNodeID `json:"NodeID"`
+ UserProfile tailcfg.UserProfile `json:"UserProfile"`
+}
+
+// nodePrefs is the legacy type used by existing code
+type nodePrefs struct {
+ Config *nodeConfig `json:"Config"`
+ AdvertiseServices []string `json:"AdvertiseServices"`
+}
+
+type nodeConfig struct {
+ NodeID tailcfg.StableNodeID `json:"NodeID"`
+ UserProfile tailcfg.UserProfile `json:"UserProfile"`
+}
+
+// getDevicePrefsFromKubestore extracts device preferences from a kubestore state secret.
+// kubestore secrets have a different format than traditional state secrets.
+// Returns the preferences, whether they were found, and any error.
+func getDevicePrefsFromKubestore(secret *corev1.Secret) (prefs kubeclientPrefs, ok bool, err error) {
+ // kubestore stores the current profile key
+ currentProfile, ok := secret.Data[currentProfileKey]
+ if !ok {
+ return prefs, false, nil
+ }
+
+ // Get the profile data
+ profileBytes, ok := secret.Data[string(currentProfile)]
+ if !ok {
+ return prefs, false, nil
+ }
+
+ if err := json.Unmarshal(profileBytes, &prefs); err != nil {
+ return prefs, false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err)
+ }
+
+ ok = prefs.Config != nil && prefs.Config.NodeID != ""
+ return prefs, ok, nil
+}
+
+// getDevicePrefs is a backward-compatible wrapper for getDevicePrefsFromKubestore
+// that returns prefs in the format expected by existing code.
+func getDevicePrefs(secret *corev1.Secret) (prefs nodePrefs, ok bool, err error) {
+ kubePrefs, ok, err := getDevicePrefsFromKubestore(secret)
+ if err != nil || !ok {
+ return prefs, ok, err
+ }
+
+ prefs.Config = &nodeConfig{
+ NodeID: kubePrefs.Config.NodeID,
+ UserProfile: kubePrefs.Config.UserProfile,
+ }
+
+ // Try to extract AdvertiseServices if available
+ if profileBytes, ok := secret.Data[string(secret.Data[currentProfileKey])]; ok {
+ var fullPrefs nodePrefs
+ if json.Unmarshal(profileBytes, &fullPrefs) == nil {
+ prefs.AdvertiseServices = fullPrefs.AdvertiseServices
+ }
+ }
+
+ return prefs, true, nil
+}
diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go
index 76d2df51d..de6e7a590 100644
--- a/cmd/k8s-operator/operator.go
+++ b/cmd/k8s-operator/operator.go
@@ -633,6 +633,30 @@ func runReconcilers(opts reconcilerOpts) {
startlog.Fatalf("could not create Recorder reconciler: %v", err)
}
+ // IDP reconciler.
+ idpFilter := handler.EnqueueRequestForOwner(mgr.GetScheme(), mgr.GetRESTMapper(), &tsapi.IDP{})
+ err = builder.ControllerManagedBy(mgr).
+ For(&tsapi.IDP{}).
+ Named("idp-reconciler").
+ Watches(&appsv1.StatefulSet{}, idpFilter).
+ Watches(&corev1.ServiceAccount{}, idpFilter).
+ Watches(&corev1.Secret{}, idpFilter).
+ Watches(&corev1.Service{}, idpFilter).
+ Watches(&rbacv1.Role{}, idpFilter).
+ Watches(&rbacv1.RoleBinding{}, idpFilter).
+ Complete(&IDPReconciler{
+ recorder: eventRecorder,
+ tsNamespace: opts.tailscaleNamespace,
+ Client: mgr.GetClient(),
+ l: opts.log.Named("idp-reconciler"),
+ clock: tstime.DefaultClock{},
+ tsClient: opts.tsClient,
+ loginServer: opts.loginServer,
+ })
+ if err != nil {
+ startlog.Fatalf("could not create IDP reconciler: %v", err)
+ }
+
// kube-apiserver's Tailscale Service reconciler.
err = builder.
ControllerManagedBy(mgr).
diff --git a/cmd/k8s-operator/tsrecorder.go b/cmd/k8s-operator/tsrecorder.go
index ec95ecf40..d29d3b73d 100644
--- a/cmd/k8s-operator/tsrecorder.go
+++ b/cmd/k8s-operator/tsrecorder.go
@@ -7,7 +7,6 @@ package main
import (
"context"
- "encoding/json"
"errors"
"fmt"
"net/http"
@@ -33,7 +32,6 @@ import (
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
- "tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -44,8 +42,6 @@ const (
reasonRecorderCreating = "RecorderCreating"
reasonRecorderCreated = "RecorderCreated"
reasonRecorderInvalid = "RecorderInvalid"
-
- currentProfileKey = "_current-profile"
)
var gaugeRecorderResources = clientmetric.NewGauge(kubetypes.MetricRecorderCount)
@@ -284,7 +280,7 @@ func (r *RecorderReconciler) maybeCleanupServiceAccounts(ctx context.Context, ts
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder) (bool, error) {
logger := r.logger(tsr.Name)
- prefs, ok, err := r.getDevicePrefs(ctx, tsr.Name)
+ prefs, ok, err := r.getDevicePrefsFromKubestore(ctx, tsr.Name)
if err != nil {
return false, err
}
@@ -412,7 +408,7 @@ func (r *RecorderReconciler) getStateSecret(ctx context.Context, tsrName string)
return secret, nil
}
-func (r *RecorderReconciler) getDevicePrefs(ctx context.Context, tsrName string) (prefs prefs, ok bool, err error) {
+func (r *RecorderReconciler) getDevicePrefsFromKubestore(ctx context.Context, tsrName string) (prefs nodePrefs, ok bool, err error) {
secret, err := r.getStateSecret(ctx, tsrName)
if err != nil || secret == nil {
return prefs, false, err
@@ -421,33 +417,13 @@ func (r *RecorderReconciler) getDevicePrefs(ctx context.Context, tsrName string)
return getDevicePrefs(secret)
}
-// getDevicePrefs returns 'ok == true' iff the node ID is found. The dnsName
-// is expected to always be non-empty if the node ID is, but not required.
-func getDevicePrefs(secret *corev1.Secret) (prefs prefs, ok bool, err error) {
- // TODO(tomhjp): Should maybe use ipn to parse the following info instead.
- currentProfile, ok := secret.Data[currentProfileKey]
- if !ok {
- return prefs, false, nil
- }
- profileBytes, ok := secret.Data[string(currentProfile)]
- if !ok {
- return prefs, false, nil
- }
- if err := json.Unmarshal(profileBytes, &prefs); err != nil {
- return prefs, false, fmt.Errorf("failed to extract node profile info from state Secret %s: %w", secret.Name, err)
- }
-
- ok = prefs.Config.NodeID != ""
- return prefs, ok, nil
-}
-
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
secret, err := r.getStateSecret(ctx, tsrName)
if err != nil || secret == nil {
return tsapi.RecorderTailnetDevice{}, false, err
}
- prefs, ok, err := getDevicePrefs(secret)
+ prefs, ok, err := getDevicePrefsFromKubestore(secret)
if !ok || err != nil {
return tsapi.RecorderTailnetDevice{}, false, err
}
@@ -471,19 +447,6 @@ func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string)
return d, true, nil
}
-// [prefs] is a subset of the ipn.Prefs struct used for extracting information
-// from the state Secret of Tailscale devices.
-type prefs struct {
- Config struct {
- NodeID tailcfg.StableNodeID `json:"NodeID"`
- UserProfile struct {
- // LoginName is the MagicDNS name of the device, e.g. foo.tail-scale.ts.net.
- LoginName string `json:"LoginName"`
- } `json:"UserProfile"`
- } `json:"Config"`
-
- AdvertiseServices []string `json:"AdvertiseServices"`
-}
func markedForDeletion(obj metav1.Object) bool {
return !obj.GetDeletionTimestamp().IsZero()
diff --git a/cmd/tsidp/README.md b/cmd/tsidp/README.md
index 780d9ab95..610fc9590 100644
--- a/cmd/tsidp/README.md
+++ b/cmd/tsidp/README.md
@@ -33,8 +33,23 @@ docker run -d \
-p 443:443 \
-e TS_AUTHKEY=YOUR_TAILSCALE_AUTHKEY \
-e TAILSCALE_USE_WIP_CODE=1 \
+ -e TS_HOSTNAME=idp \
+ -e TS_STATE_DIR=/var/lib/tsidp \
-v tsidp-data:/var/lib/tsidp \
- ghcr.io/yourusername/tsidp:v0.0.1 \
+ tailscale/tsidp:unstable \
+ tsidp
+```
+
+Or if you prefer command-line flags:
+
+```bash
+docker run -d \
+ --name tsidp \
+ -p 443:443 \
+ -e TS_AUTHKEY=YOUR_TAILSCALE_AUTHKEY \
+ -e TAILSCALE_USE_WIP_CODE=1 \
+ -v tsidp-data:/var/lib/tsidp \
+ tailscale/tsidp:unstable \
tsidp --hostname=idp --dir=/var/lib/tsidp
```
@@ -77,15 +92,97 @@ The `tsidp` server supports several command-line flags:
- `--port`: Port to listen on (default: 443)
- `--local-port`: Allow requests from localhost
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
-- `--hostname`: tsnet hostname
-- `--dir`: tsnet state directory
+- `--funnel`: Use Tailscale Funnel to make tsidp available on the public internet
+- `--hostname`: tsnet hostname (default: "idp")
+- `--dir`: tsnet state directory; a default one will be created if not provided
+- `--state`: Path to tailscale state file. Can also be set to use a Kubernetes Secret with the format `kube:<secret-name>`. If unset, `dir` is used for file-based state, or tsnet default if `dir` is also unset.
+- `--funnel-clients-store`: Storage for funnel clients: 'file' (default) or 'kube:<secret-name>'
+- `--login-server`: Optionally specifies the coordination server URL. If unset, the Tailscale default is used
## Environment Variables
-- `TS_AUTHKEY`: Your Tailscale authentication key (required)
-- `TS_HOSTNAME`: Hostname for the `tsidp` server (default: "idp", Docker only)
-- `TS_STATE_DIR`: State directory (default: "/var/lib/tsidp", Docker only)
-- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (default: "1")
+All command-line flags can also be set via environment variables:
+
+- `TSIDP_VERBOSE`: Enable verbose logging (same as `--verbose`)
+- `TSIDP_PORT`: Port to listen on (same as `--port`)
+- `TSIDP_LOCAL_PORT`: Allow requests from localhost (same as `--local-port`)
+- `TSIDP_USE_LOCAL_TAILSCALED`: Use local tailscaled instead of tsnet (same as `--use-local-tailscaled`)
+- `TSIDP_FUNNEL`: Use Tailscale Funnel (same as `--funnel`)
+- `TSIDP_FUNNEL_CLIENTS_STORE`: Storage for funnel clients (same as `--funnel-clients-store`)
+- `TSIDP_LOGIN_SERVER`: Coordination server URL (same as `--login-server`)
+- `TS_HOSTNAME`: tsnet hostname (same as `--hostname`)
+- `TS_STATE_DIR`: tsnet state directory (same as `--dir`)
+- `TS_STATE`: Path to tailscale state file or `kube:<secret-name>` (same as `--state`)
+- `TS_AUTHKEY`: Your Tailscale authentication key (required when using tsnet)
+- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (required, set to "1")
+
+## Storing State in Kubernetes Secrets
+
+When running `tsidp` in a Kubernetes environment, you can configure it to store its state in a Kubernetes Secret. This is achieved by setting the `--state` flag (or `TS_STATE` environment variable) to `kube:<your-secret-name>`. The Secret will be created by `tsidp` if it doesn't already exist, and will be created in the same namespace where `tsidp` is running.
+
+**Important**: Each Pod must use its own unique Secret. Multiple Pods cannot share the same Secret for state storage.
+
+For example:
+`./tsidp --state kube:my-tsidp-state-secret`
+
+Or using the environment variable:
+`TS_STATE=kube:my-tsidp-state-secret ./tsidp`
+
+### StatefulSet Example for Multiple Pods
+
+When deploying multiple `tsidp` instances, use a StatefulSet to ensure each Pod gets its own unique Secret:
+
+```yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: tsidp
+spec:
+ replicas: 1
+ serviceName: tsidp
+ selector:
+ matchLabels:
+ app: tsidp
+ template:
+ metadata:
+ labels:
+ app: tsidp
+ spec:
+ serviceAccountName: tsidp
+ containers:
+ - name: tsidp
+ image: tailscale/tsidp:unstable
+ env:
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: metadata.name
+ - name: TS_STATE
+ value: kube:$(POD_NAME)
+ - name: TS_AUTHKEY
+ valueFrom:
+ secretKeyRef:
+ name: tsidp-auth
+ key: authkey
+ - name: TAILSCALE_USE_WIP_CODE
+ value: "1"
+```
+
+### Required RBAC Permissions
+
+If you use Kubernetes Secret storage, the service account under which `tsidp` runs needs the following permissions on Secrets in the same namespace:
+- `get`
+- `patch` (primary mechanism for writing state)
+- `create` (if the Secret does not already exist)
+- `update` (for backwards compatibility, though patch is preferred)
+
+Additionally, the service account needs the following permissions on Events (for debugging purposes when Secret operations fail):
+- `create`
+- `patch`
+- `get`
+
+Ensure that appropriate Role and RoleBinding are configured in your Kubernetes cluster.
## Support
diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt
index b28460352..66db8ad09 100644
--- a/cmd/tsidp/depaware.txt
+++ b/cmd/tsidp/depaware.txt
@@ -254,10 +254,10 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
- L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
+ tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store+
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
- L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
- L tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore
+ tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
+ tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore+
tailscale.com/kube/kubetypes from tailscale.com/envknob+
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
diff --git a/cmd/tsidp/tsidp.go b/cmd/tsidp/tsidp.go
index 8df68cd74..beef20a1a 100644
--- a/cmd/tsidp/tsidp.go
+++ b/cmd/tsidp/tsidp.go
@@ -42,12 +42,17 @@ import (
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
+ "tailscale.com/ipn/store"
+ _ "tailscale.com/ipn/store/kubestore"
+ "tailscale.com/kube/kubeapi"
+ "tailscale.com/kube/kubeclient"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/key"
"tailscale.com/types/lazy"
"tailscale.com/types/views"
"tailscale.com/util/mak"
+ "tailscale.com/util/multierr"
"tailscale.com/util/must"
"tailscale.com/util/rands"
"tailscale.com/version"
@@ -60,14 +65,20 @@ type ctxConn struct{}
// accessing the IDP over Funnel are persisted.
const funnelClientsFile = "oidc-funnel-clients.json"
+// funnelClientsSecretKey is the key in Kubernetes secrets where funnel clients are stored.
+const funnelClientsSecretKey = "funnel-clients"
+
var (
- flagVerbose = flag.Bool("verbose", false, "be verbose")
- flagPort = flag.Int("port", 443, "port to listen on")
- flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost")
- flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet")
- flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
- flagHostname = flag.String("hostname", "idp", "tsnet hostname to use instead of idp")
- flagDir = flag.String("dir", "", "tsnet state directory; a default one will be created if not provided")
+ flagVerbose = flag.Bool("verbose", defaultBool("TSIDP_VERBOSE", false), "be verbose. Alternatively can be set via TSIDP_VERBOSE env var.")
+ flagPort = flag.Int("port", defaultInt("TSIDP_PORT", 443), "port to listen on. Alternatively can be set via TSIDP_PORT env var.")
+ flagLocalPort = flag.Int("local-port", defaultInt("TSIDP_LOCAL_PORT", -1), "allow requests from localhost. Alternatively can be set via TSIDP_LOCAL_PORT env var.")
+ flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", defaultBool("TSIDP_USE_LOCAL_TAILSCALED", false), "use local tailscaled instead of tsnet. Alternatively can be set via TSIDP_USE_LOCAL_TAILSCALED env var.")
+ flagFunnel = flag.Bool("funnel", defaultBool("TSIDP_FUNNEL", false), "use Tailscale Funnel to make tsidp available on the public internet. Alternatively can be set via TSIDP_FUNNEL env var.")
+ flagHostname = flag.String("hostname", defaultEnv("TS_HOSTNAME", "idp"), `tsnet hostname to use instead of idp. Alternatively can be set via TS_HOSTNAME env var.`)
+ flagDir = flag.String("dir", os.Getenv("TS_STATE_DIR"), `tsnet state directory; a default one will be created if not provided. Alternatively can be set via TS_STATE_DIR env var.`)
+ flagState = flag.String("state", os.Getenv("TS_STATE"), `path to tailscale state file or 'kube:<secret-name>' to use Kubernetes secret; if unset, 'dir' is used. Alternatively can be set via TS_STATE env var.`)
+ flagFunnelClientsStore = flag.String("funnel-clients-store", os.Getenv("TSIDP_FUNNEL_CLIENTS_STORE"), `storage for funnel clients: 'file' (default) or 'kube:<secret-name>'. Alternatively can be set via TSIDP_FUNNEL_CLIENTS_STORE env var.`)
+ flagLoginServer = flag.String("login-server", os.Getenv("TSIDP_LOGIN_SERVER"), `optionally specifies the coordination server URL. If unset, the Tailscale default is used. Alternatively can be set via TSIDP_LOGIN_SERVER env var.`)
)
func main() {
@@ -124,12 +135,32 @@ func main() {
} else {
hostinfo.SetApp("tsidp")
ts := &tsnet.Server{
- Hostname: *flagHostname,
- Dir: *flagDir,
+ Hostname: *flagHostname,
+ ControlURL: *flagLoginServer,
}
if *flagVerbose {
ts.Logf = log.Printf
}
+
+ if *flagDir != "" {
+ ts.Dir = *flagDir
+ }
+
+ if *flagState != "" {
+ if isKubeStatePath(*flagState) {
+ if err := validateKubePermissions(ctx, *flagState); err != nil {
+ log.Fatalf("tsidp: state is set to be stored in a Kubernetes Secret, but kube permissions validation for the Secret failed: %v", err)
+ }
+ }
+ s, err := store.New(ts.Logf, *flagState)
+ if err != nil {
+ log.Fatalf("Failed to create state store: %v", err)
+ }
+ ts.Store = s
+ // If flagDir is not set, tsnet will use its own OS-dependent default directory
+ // for its persistent state (like node keys), which is the desired behavior.
+ }
+
st, err = ts.Up(ctx)
if err != nil {
log.Fatal(err)
@@ -153,10 +184,27 @@ func main() {
lns = append(lns, ln)
}
+ // Initialize funnel clients storage
+ var funnelStore funnelClientsStore
+ if *flagFunnelClientsStore != "" && isKubeStatePath(*flagFunnelClientsStore) {
+ secretName, ok := strings.CutPrefix(*flagFunnelClientsStore, "kube:")
+ if !ok || secretName == "" {
+ log.Fatalf("invalid kube funnel clients store path: %s", *flagFunnelClientsStore)
+ }
+ funnelStore = &kubeFunnelClientsStore{
+ secretName: secretName,
+ }
+ } else {
+ funnelStore = &fileFunnelClientsStore{
+ filename: funnelClientsFile,
+ }
+ }
+
srv := &idpServer{
lc: lc,
funnel: *flagFunnel,
localTSMode: *flagUseLocalTailscaled,
+ funnelStore: funnelStore,
}
if *flagPort != 443 {
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
@@ -164,16 +212,12 @@ func main() {
srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, "."))
}
- // Load funnel clients from disk if they exist, regardless of whether funnel is enabled
+ // Load funnel clients from storage if they exist, regardless of whether funnel is enabled
// This ensures OIDC clients persist across restarts
- f, err := os.Open(funnelClientsFile)
- if err == nil {
- if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
- log.Fatalf("could not parse %s: %v", funnelClientsFile, err)
- }
- f.Close()
- } else if !errors.Is(err, os.ErrNotExist) {
- log.Fatalf("could not open %s: %v", funnelClientsFile, err)
+ if clients, err := srv.funnelStore.load(); err != nil {
+ log.Fatalf("could not load funnel clients: %v", err)
+ } else {
+ srv.funnelClients = clients
}
log.Printf("Running tsidp at %s ...", srv.serverURL)
@@ -279,6 +323,112 @@ func serveOnLocalTailscaled(ctx context.Context, lc *local.Client, st *ipnstate.
return func() { watcher.Close() }, watcherChan, nil
}
+// funnelClientsStore interface for storing funnel client credentials
+type funnelClientsStore interface {
+ load() (map[string]*funnelClient, error)
+ store(clients map[string]*funnelClient) error
+}
+
+// fileFunnelClientsStore stores funnel clients in a local JSON file
+type fileFunnelClientsStore struct {
+ filename string
+}
+
+func (f *fileFunnelClientsStore) load() (map[string]*funnelClient, error) {
+ file, err := os.Open(f.filename)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return make(map[string]*funnelClient), nil
+ }
+ return nil, err
+ }
+ defer file.Close()
+
+ var clients map[string]*funnelClient
+ if err := json.NewDecoder(file).Decode(&clients); err != nil {
+ return nil, err
+ }
+ if clients == nil {
+ clients = make(map[string]*funnelClient)
+ }
+ return clients, nil
+}
+
+func (f *fileFunnelClientsStore) store(clients map[string]*funnelClient) error {
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(clients); err != nil {
+ return err
+ }
+ return os.WriteFile(f.filename, buf.Bytes(), 0600)
+}
+
+// kubeFunnelClientsStore stores funnel clients in a Kubernetes secret
+type kubeFunnelClientsStore struct {
+ secretName string
+}
+
+func (k *kubeFunnelClientsStore) load() (map[string]*funnelClient, error) {
+ kc, err := kubeclient.New("tailscale-tsidp")
+ if err != nil {
+ return nil, fmt.Errorf("error initializing kube client: %w", err)
+ }
+
+ url, err := kubeAPIServerAddress()
+ if err != nil {
+ return nil, fmt.Errorf("error getting kube API server address: %w", err)
+ }
+ kc.SetURL(url)
+
+ ctx := context.Background()
+ secret, err := kc.GetSecret(ctx, k.secretName)
+ if err != nil {
+ if kubeclient.IsNotFoundErr(err) {
+ return make(map[string]*funnelClient), nil
+ }
+ return nil, fmt.Errorf("error getting funnel clients secret: %w", err)
+ }
+
+ data, ok := secret.Data[funnelClientsSecretKey]
+ if !ok {
+ return make(map[string]*funnelClient), nil
+ }
+
+ var clients map[string]*funnelClient
+ if err := json.Unmarshal(data, &clients); err != nil {
+ return nil, fmt.Errorf("error unmarshaling funnel clients: %w", err)
+ }
+ if clients == nil {
+ clients = make(map[string]*funnelClient)
+ }
+ return clients, nil
+}
+
+func (k *kubeFunnelClientsStore) store(clients map[string]*funnelClient) error {
+ data, err := json.Marshal(clients)
+ if err != nil {
+ return fmt.Errorf("error marshaling funnel clients: %w", err)
+ }
+
+ kc, err := kubeclient.New("tailscale-tsidp")
+ if err != nil {
+ return fmt.Errorf("error initializing kube client: %w", err)
+ }
+
+ url, err := kubeAPIServerAddress()
+ if err != nil {
+ return fmt.Errorf("error getting kube API server address: %w", err)
+ }
+ kc.SetURL(url)
+
+ ctx := context.Background()
+ secret := &kubeapi.Secret{
+ Data: map[string][]byte{
+ funnelClientsSecretKey: data,
+ },
+ }
+ return kc.StrategicMergePatchSecret(ctx, k.secretName, secret, "tailscale-tsidp")
+}
+
type idpServer struct {
lc *local.Client
loopbackURL string
@@ -290,6 +440,8 @@ type idpServer struct {
lazySigningKey lazy.SyncValue[*signingKey]
lazySigner lazy.SyncValue[jose.Signer]
+ funnelStore funnelClientsStore
+
mu sync.Mutex // guards the fields below
code map[string]*authRequest // keyed by random hex
accessToken map[string]*authRequest // keyed by random hex
@@ -1123,11 +1275,7 @@ func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, cl
// pairs for RPs that access the IDP over funnel. s.mu must be held while
// calling this.
func (s *idpServer) storeFunnelClientsLocked() error {
- var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil {
- return err
- }
- return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600)
+ return s.funnelStore.store(s.funnelClients)
}
const (
@@ -1240,3 +1388,121 @@ func isFunnelRequest(r *http.Request) bool {
}
return false
}
+
+// isKubeStatePath evaluates whether the provided state path indicates that
+// tailscaled state should be stored in a Kubernetes Secret.
+func isKubeStatePath(statePath string) bool {
+ return strings.HasPrefix(statePath, "kube:")
+}
+
+// validateKubePermissions validates that a tsidp instance has the right
+// permissions to modify its state Secret.
+// It needs to have permissions to get and update the Secret.
+// If the Secret does not already exist, it also needs to have permissions to create it.
+// patch permission is beneficial but not strictly required by kubestore's default operations.
+func validateKubePermissions(ctx context.Context, state string) error {
+ secretName, ok := strings.CutPrefix(state, "kube:")
+ if !ok || secretName == "" {
+ return fmt.Errorf("unable to retrieve valid Kubernetes Secret name from %q", state)
+ }
+
+ kc, err := kubeclient.New("tailscale-tsidp")
+ if err != nil {
+ return fmt.Errorf("error initializing kube client: %w", err)
+ }
+
+ // Our kube client connects to kube API server via the kubernetes
+ // Service in the default namespace, which is not the default client-go
+ // etc behaviour and causes issues to some users. The client defaults
+ // probably cannot be changed for backwards compatibility reasons, but
+ // we can do the right thing here at the same time as adding support for
+ // tsidp to be deployed to kube.
+ url, err := kubeAPIServerAddress()
+ if err != nil {
+ return fmt.Errorf("error initiating kube client: %w", err)
+ }
+ kc.SetURL(url)
+
+ // CheckSecretPermissions returns an error if the permissions to get or update
+ // the Secret are missing. It also returns bools for canPatch and canCreate.
+ // kubestore primarily uses patch.
+ canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, secretName)
+ if err != nil { // This err means get or update failed, or other auth issue
+ return fmt.Errorf("error checking required permissions (get/update) for Kubernetes Secret %q: %w", secretName, err)
+ }
+
+ // Check if secret exists if we don't have create permissions.
+ // If it doesn't exist and we can't create, it's an error.
+ // If it doesn't exist and we *can* create, that's fine, kubestore will create it.
+ // If it exists, we're good (Get permission was implicitly checked by CheckSecretPermissions).
+ secretExistsErr := func() error { _, err := kc.GetSecret(ctx, secretName); return err }()
+ if kubeclient.IsNotFoundErr(secretExistsErr) {
+ if !canCreate {
+ return fmt.Errorf("kube state Kubernetes Secret %q does not exist and tsidp lacks permissions to create it. Ensure RBAC allows 'create' for Secrets", secretName)
+ }
+ // It's okay if it doesn't exist and we can create it.
+ } else if secretExistsErr != nil {
+ // Any other error while trying to GetSecret (besides NotFound) is a problem.
+ return fmt.Errorf("error attempting to get kube state Kubernetes Secret %q: %w", secretName, secretExistsErr)
+ }
+
+ // At this point, we know we can get and update the secret (or create if it didn't exist).
+ // Log if patch is not available, as it's preferred for conflict handling, but not essential.
+ if !canPatch {
+ log.Printf("Warning: patch permission for Kubernetes Secret %q is missing; kubestore will rely on update. This is always fine.", secretName)
+ }
+ return nil
+}
+
+// kubeAPIServerAddress determines the address of the kube API server. It uses
+// the standard environment variables set by kube that are expected to be found
+// on any Pod- this is the same logic as used by client-go.
+// https://github.com/kubernetes/client-go/blob/v0.29.5/rest/config.go#L516-L536
+func kubeAPIServerAddress() (_ string, err error) {
+ host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
+ if host == "" {
+ err = errors.New("[unexpected] tsidp seems to be running in a Kubernetes environment with KUBERNETES_SERVICE_HOST unset")
+ }
+ if port == "" {
+ err = multierr.New(err, errors.New("[unexpected] tsidp appears to be running in a Kubernetes environment with KUBERNETES_SERVICE_PORT unset"))
+ }
+ if err != nil {
+ return "", err
+ }
+ return "https://" + net.JoinHostPort(host, port), nil
+}
+
+// TODO (rajsingh): defaultEnv, defaultBool, defaultInt were originally defined in
+// https://github.com/tailscale/tailscale/blob/v1.64.2/cmd/containerboot/main.go#L996-L1045
+// Consume them from a single place instead of copying.
+
+// defaultEnv returns the value of the named env var, or
+// defaultVal if unset.
+func defaultEnv(name, defaultVal string) string {
+ if val, ok := os.LookupEnv(name); ok {
+ return val
+ }
+ return defaultVal
+}
+
+// defaultBool returns the boolean value of the named env var, or
+// defaultVal if unset or not a bool.
+func defaultBool(name string, defaultVal bool) bool {
+ v := os.Getenv(name)
+ ret, err := strconv.ParseBool(v)
+ if err != nil {
+ return defaultVal
+ }
+ return ret
+}
+
+// defaultInt returns the integer value of the named env var, or
+// defaultVal if unset or not an int.
+func defaultInt(name string, defaultVal int) int {
+ v := os.Getenv(name)
+ ret, err := strconv.Atoi(v)
+ if err != nil {
+ return defaultVal
+ }
+ return ret
+}