tailscale/cmd/k8s-operator/idp_specs.go
Raj Singh 6ed86a0251 cmd/k8s-operator: add IDP CRD for OpenID Connect identity provider
Adds a new IDP (Identity Provider) Custom Resource Definition to the Tailscale Kubernetes operator. This allows users to deploy and manage   tsidp instances as Kubernetes resources.

Updates #16666

Signed-off-by: Raj Singh <raj@tailscale.com>
2025-07-27 12:19:02 -05:00

326 lines
8.2 KiB
Go

// 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"))}
}