diff options
| -rw-r--r-- | cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml | 3 | ||||
| -rw-r--r-- | cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml | 1747 | ||||
| -rw-r--r-- | cmd/k8s-operator/deploy/examples/idp.yaml | 16 | ||||
| -rw-r--r-- | cmd/k8s-operator/deploy/manifests/operator.yaml | 1758 | ||||
| -rw-r--r-- | cmd/k8s-operator/generate/main.go | 6 | ||||
| -rw-r--r-- | cmd/k8s-operator/idp.go | 540 | ||||
| -rw-r--r-- | cmd/k8s-operator/idp_specs.go | 325 | ||||
| -rw-r--r-- | cmd/k8s-operator/idp_test.go | 606 | ||||
| -rw-r--r-- | cmd/k8s-operator/kubestore_utils.go | 91 | ||||
| -rw-r--r-- | cmd/k8s-operator/operator.go | 24 | ||||
| -rw-r--r-- | cmd/k8s-operator/tsrecorder.go | 43 | ||||
| -rw-r--r-- | cmd/tsidp/README.md | 111 | ||||
| -rw-r--r-- | cmd/tsidp/depaware.txt | 6 | ||||
| -rw-r--r-- | cmd/tsidp/tsidp.go | 312 | ||||
| -rw-r--r-- | k8s-operator/api.md | 167 | ||||
| -rw-r--r-- | k8s-operator/apis/v1alpha1/register.go | 2 | ||||
| -rw-r--r-- | k8s-operator/apis/v1alpha1/types_connector.go | 1 | ||||
| -rw-r--r-- | k8s-operator/apis/v1alpha1/types_idp.go | 242 | ||||
| -rw-r--r-- | k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go | 250 | ||||
| -rw-r--r-- | k8s-operator/conditions.go | 8 | ||||
| -rw-r--r-- | kube/kubetypes/types.go | 1 |
21 files changed, 6185 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 +} diff --git a/k8s-operator/api.md b/k8s-operator/api.md index 564c87f50..e017e9d5e 100644 --- a/k8s-operator/api.md +++ b/k8s-operator/api.md @@ -12,6 +12,8 @@ - [ConnectorList](#connectorlist) - [DNSConfig](#dnsconfig) - [DNSConfigList](#dnsconfiglist) +- [IDP](#idp) +- [IDPList](#idplist) - [ProxyClass](#proxyclass) - [ProxyClassList](#proxyclasslist) - [ProxyGroup](#proxygroup) @@ -290,6 +292,7 @@ _Appears in:_ _Appears in:_ - [Container](#container) +- [IDPContainer](#idpcontainer) - [RecorderContainer](#recordercontainer) | Field | Description | Default | Validation | @@ -328,6 +331,169 @@ _Appears in:_ +#### IDP + + + +IDP defines a Tailscale OpenID Connect Identity Provider instance. +IDP is a cluster-scoped resource. + + + +_Appears in:_ +- [IDPList](#idplist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | +| `kind` _string_ | `IDP` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[IDPSpec](#idpspec)_ | Spec describes the desired IDP instance. | | | +| `status` _[IDPStatus](#idpstatus)_ | IDPStatus describes the status of the IDP. This is set<br />and managed by the Tailscale operator. | | | + + +#### IDPContainer + + + + + + + +_Appears in:_ +- [IDPPod](#idppod) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `env` _[Env](#env) array_ | List of environment variables to set in the container.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables<br />Note that environment variables provided here will take precedence<br />over Tailscale-specific environment variables set by the operator. | | | +| `image` _string_ | Container image name including tag. Defaults to the tsidp image<br />from the same source as the operator.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | | +| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#pullpolicy-v1-core)_ | Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image | | Enum: [Always Never IfNotPresent] <br /> | +| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#resourcerequirements-v1-core)_ | Container resource requirements.<br />By default, the operator does not apply any resource requirements.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources | | | +| `securityContext` _[SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#securitycontext-v1-core)_ | Container security context. By default, the operator does not apply any<br />container security context.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context | | | + + +#### IDPList + + + + + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `tailscale.com/v1alpha1` | | | +| `kind` _string_ | `IDPList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | | +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[IDP](#idp) array_ | | | | + + +#### IDPPod + + + + + + + +_Appears in:_ +- [IDPStatefulSet](#idpstatefulset) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `labels` _object (keys:string, values:string)_ | Labels that will be added to IDP Pods. Any labels specified here<br />will be merged with the default labels applied to the Pod by the operator.<br />https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | +| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to IDP Pods. Any annotations<br />specified here will be merged with the default annotations applied to<br />the Pod by the operator.<br />https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | +| `affinity` _[Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#affinity-v1-core)_ | Affinity rules for IDP Pods. By default, the operator does not<br />apply any affinity rules.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity | | | +| `container` _[IDPContainer](#idpcontainer)_ | Configuration for the IDP container. | | | +| `securityContext` _[PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#podsecuritycontext-v1-core)_ | Security context for IDP Pods. By default, the operator does not<br />apply any Pod security context.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context-2 | | | +| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#localobjectreference-v1-core) array_ | Image pull Secrets for IDP Pods.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec | | | +| `nodeSelector` _object (keys:string, values:string)_ | Node selector rules for IDP Pods. By default, the operator does<br />not apply any node selector rules.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | +| `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#toleration-v1-core) array_ | Tolerations for IDP Pods. By default, the operator does not apply<br />any tolerations.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#scheduling | | | +| `serviceAccount` _[IDPServiceAccount](#idpserviceaccount)_ | Config for the ServiceAccount to create for the IDP's StatefulSet.<br />By default, the operator will create a ServiceAccount with the same<br />name as the IDP resource.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account | | | + + +#### IDPServiceAccount + + + + + + + +_Appears in:_ +- [IDPPod](#idppod) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the ServiceAccount to create. Defaults to the name of the<br />IDP resource.<br />https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#service-account | | MaxLength: 253 <br />Pattern: `^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$` <br />Type: string <br /> | +| `annotations` _object (keys:string, values:string)_ | Annotations to add to the ServiceAccount.<br />https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | + + +#### IDPSpec + + + + + + + +_Appears in:_ +- [IDP](#idp) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `statefulSet` _[IDPStatefulSet](#idpstatefulset)_ | Configuration parameters for the IDP's StatefulSet. The operator<br />deploys a StatefulSet for each IDP resource. | | | +| `tags` _[Tags](#tags)_ | Tags that the Tailscale device will be tagged with. Defaults to [tag:k8s].<br />If you specify custom tags here, make sure you also make the operator<br />an owner of these tags.<br />See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.<br />Tags cannot be changed once an IDP node has been created.<br />Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$` <br />Type: string <br /> | +| `hostname` _string_ | Hostname for the IDP instance. Defaults to "idp".<br />This will be used as the MagicDNS hostname. | | Pattern: `^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$` <br /> | +| `enableFunnel` _boolean_ | Enable Tailscale Funnel to make IDP available on the public internet.<br />When enabled, the IDP will be accessible via a public HTTPS URL.<br />Requires appropriate ACL configuration in your tailnet.<br />Cannot be used with custom ports.<br />Defaults to false. | | | +| `port` _integer_ | Port to listen on for HTTPS traffic. Defaults to 443.<br />Must be 443 if EnableFunnel is true.<br />Common values: 443 (standard HTTPS), 8443 (alternative HTTPS). | | Maximum: 65535 <br />Minimum: 1 <br /> | +| `localPort` _integer_ | LocalPort to listen on for HTTP traffic from localhost.<br />This can be useful for debugging or local client access.<br />The IDP will serve unencrypted HTTP on this port, accessible only from<br />the pod itself (localhost/127.0.0.1).<br />If not set, local access is disabled. | | Maximum: 65535 <br />Minimum: 1 <br /> | + + +#### IDPStatefulSet + + + + + + + +_Appears in:_ +- [IDPSpec](#idpspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `labels` _object (keys:string, values:string)_ | Labels that will be added to the StatefulSet created for IDP.<br />Any labels specified here will be merged with the default labels applied<br />to the StatefulSet by the operator.<br />https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set | | | +| `annotations` _object (keys:string, values:string)_ | Annotations that will be added to the StatefulSet created for IDP.<br />Any Annotations specified here will be merged with the default annotations<br />applied to the StatefulSet by the operator.<br />https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set | | | +| `pod` _[IDPPod](#idppod)_ | Configuration for pods created by the IDP's StatefulSet. | | | + + +#### IDPStatus + + + + + + + +_Appears in:_ +- [IDP](#idp) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.3/#condition-v1-meta) array_ | List of status conditions to indicate the status of IDP.<br />Known condition types are `IDPReady`. | | | +| `url` _string_ | URL where the OIDC provider is accessible.<br />This will be an HTTPS MagicDNS URL, or a public URL if Funnel is enabled. | | | +| `hostname` _string_ | Hostname is the fully qualified domain name of the IDP device.<br />If MagicDNS is enabled in your tailnet, it is the MagicDNS name. | | | +| `tailnetIPs` _string array_ | TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)<br />assigned to the IDP device. | | | +| `observedGeneration` _integer_ | ObservedGeneration is the last observed generation of the IDP resource. | | | + + #### KubeAPIServerConfig @@ -1107,6 +1273,7 @@ _Validation:_ _Appears in:_ - [ConnectorSpec](#connectorspec) +- [IDPSpec](#idpspec) - [ProxyGroupSpec](#proxygroupspec) - [RecorderSpec](#recorderspec) diff --git a/k8s-operator/apis/v1alpha1/register.go b/k8s-operator/apis/v1alpha1/register.go index 0880ac975..0644228a9 100644 --- a/k8s-operator/apis/v1alpha1/register.go +++ b/k8s-operator/apis/v1alpha1/register.go @@ -67,6 +67,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &RecorderList{}, &ProxyGroup{}, &ProxyGroupList{}, + &IDP{}, + &IDPList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) diff --git a/k8s-operator/apis/v1alpha1/types_connector.go b/k8s-operator/apis/v1alpha1/types_connector.go index ce6a1411b..57548f132 100644 --- a/k8s-operator/apis/v1alpha1/types_connector.go +++ b/k8s-operator/apis/v1alpha1/types_connector.go @@ -211,6 +211,7 @@ const ( ProxyGroupAvailable ConditionType = `ProxyGroupAvailable` // At least one proxy Pod running. ProxyReady ConditionType = `TailscaleProxyReady` // a Tailscale-specific condition type for corev1.Service RecorderReady ConditionType = `RecorderReady` + IDPReady ConditionType = `IDPReady` // EgressSvcValid gets set on a user configured ExternalName Service that defines a tailnet target to be exposed // on a ProxyGroup. // Set to true if the user provided configuration is valid. diff --git a/k8s-operator/apis/v1alpha1/types_idp.go b/k8s-operator/apis/v1alpha1/types_idp.go new file mode 100644 index 000000000..ebb94fe30 --- /dev/null +++ b/k8s-operator/apis/v1alpha1/types_idp.go @@ -0,0 +1,242 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=idp +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "IDPReady")].reason`,description="Status of the deployed IDP resources." +// +kubebuilder:printcolumn:name="URL",type="string",JSONPath=`.status.url`,description="URL where the OIDC provider is accessible." +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// IDP defines a Tailscale OpenID Connect Identity Provider instance. +// IDP is a cluster-scoped resource. +type IDP struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec describes the desired IDP instance. + Spec IDPSpec `json:"spec"` + + // IDPStatus describes the status of the IDP. This is set + // and managed by the Tailscale operator. + // +optional + Status IDPStatus `json:"status"` +} + +// +kubebuilder:object:root=true + +type IDPList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []IDP `json:"items"` +} + +type IDPSpec struct { + // Configuration parameters for the IDP's StatefulSet. The operator + // deploys a StatefulSet for each IDP resource. + // +optional + StatefulSet IDPStatefulSet `json:"statefulSet"` + + // 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-]*$. + // +optional + Tags Tags `json:"tags,omitempty"` + + // Hostname for the IDP instance. Defaults to "idp". + // This will be used as the MagicDNS hostname. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$` + // +optional + Hostname string `json:"hostname,omitempty"` + + // 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. + // +optional + EnableFunnel bool `json:"enableFunnel,omitempty"` + + // 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). + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + Port int32 `json:"port,omitempty"` + + // 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. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + LocalPort *int32 `json:"localPort,omitempty"` +} + +type IDPStatefulSet struct { + // 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 + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // 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 + // +optional + Annotations map[string]string `json:"annotations,omitempty"` + + // Configuration for pods created by the IDP's StatefulSet. + // +optional + Pod IDPPod `json:"pod,omitempty"` +} + +type IDPPod struct { + // 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 + // +optional + Labels map[string]string `json:"labels,omitempty"` + + // 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 + // +optional + Annotations map[string]string `json:"annotations,omitempty"` + + // 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 + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` + + // Configuration for the IDP container. + // +optional + Container IDPContainer `json:"container,omitempty"` + + // 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 + // +optional + SecurityContext *corev1.PodSecurityContext `json:"securityContext,omitempty"` + + // Image pull Secrets for IDP Pods. + // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodSpec + // +optional + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + + // 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 + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // 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 + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // 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 + // +optional + ServiceAccount IDPServiceAccount `json:"serviceAccount,omitempty"` +} + +type IDPServiceAccount struct { + // 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 + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=253 + // +optional + Name string `json:"name,omitempty"` + + // Annotations to add to the ServiceAccount. + // https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +type IDPContainer struct { + // 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. + // +optional + Env []Env `json:"env,omitempty"` + + // 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 + // +optional + Image string `json:"image,omitempty"` + + // Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. + // https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image + // +kubebuilder:validation:Enum=Always;Never;IfNotPresent + // +optional + ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` + + // 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 + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // 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 + // +optional + SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"` +} + +type IDPStatus struct { + // List of status conditions to indicate the status of IDP. + // Known condition types are `IDPReady`. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // URL where the OIDC provider is accessible. + // This will be an HTTPS MagicDNS URL, or a public URL if Funnel is enabled. + // +optional + URL string `json:"url,omitempty"` + + // Hostname is the fully qualified domain name of the IDP device. + // If MagicDNS is enabled in your tailnet, it is the MagicDNS name. + // +optional + Hostname string `json:"hostname,omitempty"` + + // TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) + // assigned to the IDP device. + // +optional + TailnetIPs []string `json:"tailnetIPs,omitempty"` + + // ObservedGeneration is the last observed generation of the IDP resource. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} diff --git a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go index 6586c1354..f0f179351 100644 --- a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go +++ b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go @@ -317,6 +317,256 @@ func (in *Env) DeepCopy() *Env { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDP) DeepCopyInto(out *IDP) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDP. +func (in *IDP) DeepCopy() *IDP { + if in == nil { + return nil + } + out := new(IDP) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IDP) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPContainer) DeepCopyInto(out *IDPContainer) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]Env, len(*in)) + copy(*out, *in) + } + in.Resources.DeepCopyInto(&out.Resources) + if in.SecurityContext != nil { + in, out := &in.SecurityContext, &out.SecurityContext + *out = new(corev1.SecurityContext) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPContainer. +func (in *IDPContainer) DeepCopy() *IDPContainer { + if in == nil { + return nil + } + out := new(IDPContainer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPList) DeepCopyInto(out *IDPList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IDP, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPList. +func (in *IDPList) DeepCopy() *IDPList { + if in == nil { + return nil + } + out := new(IDPList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IDPList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPPod) DeepCopyInto(out *IDPPod) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } + in.Container.DeepCopyInto(&out.Container) + if in.SecurityContext != nil { + in, out := &in.SecurityContext, &out.SecurityContext + *out = new(corev1.PodSecurityContext) + (*in).DeepCopyInto(*out) + } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.ServiceAccount.DeepCopyInto(&out.ServiceAccount) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPPod. +func (in *IDPPod) DeepCopy() *IDPPod { + if in == nil { + return nil + } + out := new(IDPPod) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPServiceAccount) DeepCopyInto(out *IDPServiceAccount) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPServiceAccount. +func (in *IDPServiceAccount) DeepCopy() *IDPServiceAccount { + if in == nil { + return nil + } + out := new(IDPServiceAccount) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPSpec) DeepCopyInto(out *IDPSpec) { + *out = *in + in.StatefulSet.DeepCopyInto(&out.StatefulSet) + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(Tags, len(*in)) + copy(*out, *in) + } + if in.LocalPort != nil { + in, out := &in.LocalPort, &out.LocalPort + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPSpec. +func (in *IDPSpec) DeepCopy() *IDPSpec { + if in == nil { + return nil + } + out := new(IDPSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPStatefulSet) DeepCopyInto(out *IDPStatefulSet) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.Pod.DeepCopyInto(&out.Pod) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPStatefulSet. +func (in *IDPStatefulSet) DeepCopy() *IDPStatefulSet { + if in == nil { + return nil + } + out := new(IDPStatefulSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IDPStatus) DeepCopyInto(out *IDPStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.TailnetIPs != nil { + in, out := &in.TailnetIPs, &out.TailnetIPs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IDPStatus. +func (in *IDPStatus) DeepCopy() *IDPStatus { + if in == nil { + return nil + } + out := new(IDPStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeAPIServerConfig) DeepCopyInto(out *KubeAPIServerConfig) { *out = *in if in.Mode != nil { diff --git a/k8s-operator/conditions.go b/k8s-operator/conditions.go index ae465a728..9ec610828 100644 --- a/k8s-operator/conditions.go +++ b/k8s-operator/conditions.go @@ -91,6 +91,14 @@ func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionT pg.Status.Conditions = conds } +// SetIDPCondition ensures that IDP status has a condition with the +// given attributes. LastTransitionTime gets set every time condition's status +// changes. +func SetIDPCondition(tsidp *tsapi.IDP, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { + conds := updateCondition(tsidp.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) + tsidp.Status.Conditions = conds +} + func updateCondition(conds []metav1.Condition, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []metav1.Condition { newCondition := metav1.Condition{ Type: string(conditionType), diff --git a/kube/kubetypes/types.go b/kube/kubetypes/types.go index 44b01fe1a..654c9de7d 100644 --- a/kube/kubetypes/types.go +++ b/kube/kubetypes/types.go @@ -29,6 +29,7 @@ const ( MetricConnectorWithAppConnectorCount = "k8s_connector_appconnector_resources" MetricNameserverCount = "k8s_nameserver_resources" MetricRecorderCount = "k8s_recorder_resources" + MetricIDPCount = "k8s_idp_resources" MetricEgressServiceCount = "k8s_egress_service_resources" MetricProxyGroupEgressCount = "k8s_proxygroup_egress_resources" MetricProxyGroupIngressCount = "k8s_proxygroup_ingress_resources" |
