mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-28 14:53:44 +00:00
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>
This commit is contained in:
parent
e300a00058
commit
6ed86a0251
@ -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"]
|
||||
|
1747
cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml
Normal file
1747
cmd/k8s-operator/deploy/crds/tailscale.com_idps.yaml
Normal file
File diff suppressed because it is too large
Load Diff
16
cmd/k8s-operator/deploy/examples/idp.yaml
Normal file
16
cmd/k8s-operator/deploy/examples/idp.yaml
Normal file
@ -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"
|
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
540
cmd/k8s-operator/idp.go
Normal file
540
cmd/k8s-operator/idp.go
Normal file
@ -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)
|
||||
}
|
325
cmd/k8s-operator/idp_specs.go
Normal file
325
cmd/k8s-operator/idp_specs.go
Normal file
@ -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"))}
|
||||
}
|
606
cmd/k8s-operator/idp_test.go
Normal file
606
cmd/k8s-operator/idp_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
91
cmd/k8s-operator/kubestore_utils.go
Normal file
91
cmd/k8s-operator/kubestore_utils.go
Normal file
@ -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
|
||||
}
|
@ -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).
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -67,6 +67,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
&RecorderList{},
|
||||
&ProxyGroup{},
|
||||
&ProxyGroupList{},
|
||||
&IDP{},
|
||||
&IDPList{},
|
||||
)
|
||||
|
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||
|
@ -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.
|
||||
|
242
k8s-operator/apis/v1alpha1/types_idp.go
Normal file
242
k8s-operator/apis/v1alpha1/types_idp.go
Normal file
@ -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"`
|
||||
}
|
@ -316,6 +316,256 @@ func (in *Env) DeepCopy() *Env {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
|
@ -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),
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user