mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 07:13: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"]
|
- apiGroups: ["tailscale.com"]
|
||||||
resources: ["recorders", "recorders/status"]
|
resources: ["recorders", "recorders/status"]
|
||||||
verbs: ["get", "list", "watch", "update"]
|
verbs: ["get", "list", "watch", "update"]
|
||||||
|
- apiGroups: ["tailscale.com"]
|
||||||
|
resources: ["idps", "idps/status"]
|
||||||
|
verbs: ["get", "list", "watch", "update"]
|
||||||
- apiGroups: ["apiextensions.k8s.io"]
|
- apiGroups: ["apiextensions.k8s.io"]
|
||||||
resources: ["customresourcedefinitions"]
|
resources: ["customresourcedefinitions"]
|
||||||
verbs: ["get", "list", "watch"]
|
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"
|
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
|
||||||
recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml"
|
recorderCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_recorders.yaml"
|
||||||
proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml"
|
proxyGroupCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxygroups.yaml"
|
||||||
|
idpCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_idps.yaml"
|
||||||
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
|
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
|
||||||
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
|
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
|
||||||
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
|
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
|
||||||
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
|
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
|
||||||
recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml"
|
recorderCRDHelmTemplatePath = helmTemplatesPath + "/recorder.yaml"
|
||||||
proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml"
|
proxyGroupCRDHelmTemplatePath = helmTemplatesPath + "/proxygroup.yaml"
|
||||||
|
idpCRDHelmTemplatePath = helmTemplatesPath + "/idp.yaml"
|
||||||
|
|
||||||
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
|
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
|
||||||
helmConditionalEnd = "{{- end -}}"
|
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
|
// the Helm chart templates behind .Values.installCRDs=true condition (true by
|
||||||
// default).
|
// default).
|
||||||
func generate(baseDir string) error {
|
func generate(baseDir string) error {
|
||||||
@ -149,6 +151,7 @@ func generate(baseDir string) error {
|
|||||||
{dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath},
|
{dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath},
|
||||||
{recorderCRDPath, recorderCRDHelmTemplatePath},
|
{recorderCRDPath, recorderCRDHelmTemplatePath},
|
||||||
{proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath},
|
{proxyGroupCRDPath, proxyGroupCRDHelmTemplatePath},
|
||||||
|
{idpCRDPath, idpCRDHelmTemplatePath},
|
||||||
} {
|
} {
|
||||||
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
|
if err := addCRDToHelm(crd.crdPath, crd.templatePath); err != nil {
|
||||||
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
|
return fmt.Errorf("error adding %s CRD to Helm templates: %w", crd.crdPath, err)
|
||||||
@ -165,6 +168,7 @@ func cleanup(baseDir string) error {
|
|||||||
dnsConfigCRDHelmTemplatePath,
|
dnsConfigCRDHelmTemplatePath,
|
||||||
recorderCRDHelmTemplatePath,
|
recorderCRDHelmTemplatePath,
|
||||||
proxyGroupCRDHelmTemplatePath,
|
proxyGroupCRDHelmTemplatePath,
|
||||||
|
idpCRDHelmTemplatePath,
|
||||||
} {
|
} {
|
||||||
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(filepath.Join(baseDir, path)); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("error cleaning up %s: %w", path, 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)
|
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.
|
// kube-apiserver's Tailscale Service reconciler.
|
||||||
err = builder.
|
err = builder.
|
||||||
ControllerManagedBy(mgr).
|
ControllerManagedBy(mgr).
|
||||||
|
@ -7,7 +7,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -33,7 +32,6 @@ import (
|
|||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
"tailscale.com/util/set"
|
"tailscale.com/util/set"
|
||||||
@ -44,8 +42,6 @@ const (
|
|||||||
reasonRecorderCreating = "RecorderCreating"
|
reasonRecorderCreating = "RecorderCreating"
|
||||||
reasonRecorderCreated = "RecorderCreated"
|
reasonRecorderCreated = "RecorderCreated"
|
||||||
reasonRecorderInvalid = "RecorderInvalid"
|
reasonRecorderInvalid = "RecorderInvalid"
|
||||||
|
|
||||||
currentProfileKey = "_current-profile"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var gaugeRecorderResources = clientmetric.NewGauge(kubetypes.MetricRecorderCount)
|
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) {
|
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder) (bool, error) {
|
||||||
logger := r.logger(tsr.Name)
|
logger := r.logger(tsr.Name)
|
||||||
|
|
||||||
prefs, ok, err := r.getDevicePrefs(ctx, tsr.Name)
|
prefs, ok, err := r.getDevicePrefsFromKubestore(ctx, tsr.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@ -412,7 +408,7 @@ func (r *RecorderReconciler) getStateSecret(ctx context.Context, tsrName string)
|
|||||||
return secret, nil
|
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)
|
secret, err := r.getStateSecret(ctx, tsrName)
|
||||||
if err != nil || secret == nil {
|
if err != nil || secret == nil {
|
||||||
return prefs, false, err
|
return prefs, false, err
|
||||||
@ -421,33 +417,13 @@ func (r *RecorderReconciler) getDevicePrefs(ctx context.Context, tsrName string)
|
|||||||
return getDevicePrefs(secret)
|
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) {
|
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
|
||||||
secret, err := r.getStateSecret(ctx, tsrName)
|
secret, err := r.getStateSecret(ctx, tsrName)
|
||||||
if err != nil || secret == nil {
|
if err != nil || secret == nil {
|
||||||
return tsapi.RecorderTailnetDevice{}, false, err
|
return tsapi.RecorderTailnetDevice{}, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs, ok, err := getDevicePrefs(secret)
|
prefs, ok, err := getDevicePrefsFromKubestore(secret)
|
||||||
if !ok || err != nil {
|
if !ok || err != nil {
|
||||||
return tsapi.RecorderTailnetDevice{}, false, err
|
return tsapi.RecorderTailnetDevice{}, false, err
|
||||||
}
|
}
|
||||||
@ -471,19 +447,6 @@ func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsrName string)
|
|||||||
return d, true, nil
|
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 {
|
func markedForDeletion(obj metav1.Object) bool {
|
||||||
return !obj.GetDeletionTimestamp().IsZero()
|
return !obj.GetDeletionTimestamp().IsZero()
|
||||||
|
@ -33,8 +33,23 @@ docker run -d \
|
|||||||
-p 443:443 \
|
-p 443:443 \
|
||||||
-e TS_AUTHKEY=YOUR_TAILSCALE_AUTHKEY \
|
-e TS_AUTHKEY=YOUR_TAILSCALE_AUTHKEY \
|
||||||
-e TAILSCALE_USE_WIP_CODE=1 \
|
-e TAILSCALE_USE_WIP_CODE=1 \
|
||||||
|
-e TS_HOSTNAME=idp \
|
||||||
|
-e TS_STATE_DIR=/var/lib/tsidp \
|
||||||
-v tsidp-data:/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
|
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)
|
- `--port`: Port to listen on (default: 443)
|
||||||
- `--local-port`: Allow requests from localhost
|
- `--local-port`: Allow requests from localhost
|
||||||
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
|
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
|
||||||
- `--hostname`: tsnet hostname
|
- `--funnel`: Use Tailscale Funnel to make tsidp available on the public internet
|
||||||
- `--dir`: tsnet state directory
|
- `--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
|
## Environment Variables
|
||||||
|
|
||||||
- `TS_AUTHKEY`: Your Tailscale authentication key (required)
|
All command-line flags can also be set via environment variables:
|
||||||
- `TS_HOSTNAME`: Hostname for the `tsidp` server (default: "idp", Docker only)
|
|
||||||
- `TS_STATE_DIR`: State directory (default: "/var/lib/tsidp", Docker only)
|
- `TSIDP_VERBOSE`: Enable verbose logging (same as `--verbose`)
|
||||||
- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (default: "1")
|
- `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
|
## 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/policy from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/ipn/store 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/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+
|
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
|
||||||
L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
|
tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
|
||||||
L tailscale.com/kube/kubeclient 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/kube/kubetypes from tailscale.com/envknob+
|
||||||
tailscale.com/licenses from tailscale.com/client/web
|
tailscale.com/licenses from tailscale.com/client/web
|
||||||
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
||||||
|
@ -42,12 +42,17 @@ import (
|
|||||||
"tailscale.com/hostinfo"
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"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/tailcfg"
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/types/lazy"
|
"tailscale.com/types/lazy"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
|
"tailscale.com/util/multierr"
|
||||||
"tailscale.com/util/must"
|
"tailscale.com/util/must"
|
||||||
"tailscale.com/util/rands"
|
"tailscale.com/util/rands"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
@ -60,14 +65,20 @@ type ctxConn struct{}
|
|||||||
// accessing the IDP over Funnel are persisted.
|
// accessing the IDP over Funnel are persisted.
|
||||||
const funnelClientsFile = "oidc-funnel-clients.json"
|
const funnelClientsFile = "oidc-funnel-clients.json"
|
||||||
|
|
||||||
|
// funnelClientsSecretKey is the key in Kubernetes secrets where funnel clients are stored.
|
||||||
|
const funnelClientsSecretKey = "funnel-clients"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
flagVerbose = flag.Bool("verbose", false, "be verbose")
|
flagVerbose = flag.Bool("verbose", defaultBool("TSIDP_VERBOSE", false), "be verbose. Alternatively can be set via TSIDP_VERBOSE env var.")
|
||||||
flagPort = flag.Int("port", 443, "port to listen on")
|
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", -1, "allow requests from localhost")
|
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", false, "use local tailscaled instead of tsnet")
|
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", false, "use Tailscale Funnel to make tsidp available on the public internet")
|
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", "idp", "tsnet hostname to use instead of idp")
|
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", "", "tsnet state directory; a default one will be created if not provided")
|
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() {
|
func main() {
|
||||||
@ -124,12 +135,32 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
hostinfo.SetApp("tsidp")
|
hostinfo.SetApp("tsidp")
|
||||||
ts := &tsnet.Server{
|
ts := &tsnet.Server{
|
||||||
Hostname: *flagHostname,
|
Hostname: *flagHostname,
|
||||||
Dir: *flagDir,
|
ControlURL: *flagLoginServer,
|
||||||
}
|
}
|
||||||
if *flagVerbose {
|
if *flagVerbose {
|
||||||
ts.Logf = log.Printf
|
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)
|
st, err = ts.Up(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@ -153,10 +184,27 @@ func main() {
|
|||||||
lns = append(lns, ln)
|
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{
|
srv := &idpServer{
|
||||||
lc: lc,
|
lc: lc,
|
||||||
funnel: *flagFunnel,
|
funnel: *flagFunnel,
|
||||||
localTSMode: *flagUseLocalTailscaled,
|
localTSMode: *flagUseLocalTailscaled,
|
||||||
|
funnelStore: funnelStore,
|
||||||
}
|
}
|
||||||
if *flagPort != 443 {
|
if *flagPort != 443 {
|
||||||
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
|
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, "."))
|
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
|
// This ensures OIDC clients persist across restarts
|
||||||
f, err := os.Open(funnelClientsFile)
|
if clients, err := srv.funnelStore.load(); err != nil {
|
||||||
if err == nil {
|
log.Fatalf("could not load funnel clients: %v", err)
|
||||||
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
|
} else {
|
||||||
log.Fatalf("could not parse %s: %v", funnelClientsFile, err)
|
srv.funnelClients = clients
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
log.Fatalf("could not open %s: %v", funnelClientsFile, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Running tsidp at %s ...", srv.serverURL)
|
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
|
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 {
|
type idpServer struct {
|
||||||
lc *local.Client
|
lc *local.Client
|
||||||
loopbackURL string
|
loopbackURL string
|
||||||
@ -290,6 +440,8 @@ type idpServer struct {
|
|||||||
lazySigningKey lazy.SyncValue[*signingKey]
|
lazySigningKey lazy.SyncValue[*signingKey]
|
||||||
lazySigner lazy.SyncValue[jose.Signer]
|
lazySigner lazy.SyncValue[jose.Signer]
|
||||||
|
|
||||||
|
funnelStore funnelClientsStore
|
||||||
|
|
||||||
mu sync.Mutex // guards the fields below
|
mu sync.Mutex // guards the fields below
|
||||||
code map[string]*authRequest // keyed by random hex
|
code map[string]*authRequest // keyed by random hex
|
||||||
accessToken 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
|
// pairs for RPs that access the IDP over funnel. s.mu must be held while
|
||||||
// calling this.
|
// calling this.
|
||||||
func (s *idpServer) storeFunnelClientsLocked() error {
|
func (s *idpServer) storeFunnelClientsLocked() error {
|
||||||
var buf bytes.Buffer
|
return s.funnelStore.store(s.funnelClients)
|
||||||
if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -1240,3 +1388,121 @@ func isFunnelRequest(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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)
|
- [ConnectorList](#connectorlist)
|
||||||
- [DNSConfig](#dnsconfig)
|
- [DNSConfig](#dnsconfig)
|
||||||
- [DNSConfigList](#dnsconfiglist)
|
- [DNSConfigList](#dnsconfiglist)
|
||||||
|
- [IDP](#idp)
|
||||||
|
- [IDPList](#idplist)
|
||||||
- [ProxyClass](#proxyclass)
|
- [ProxyClass](#proxyclass)
|
||||||
- [ProxyClassList](#proxyclasslist)
|
- [ProxyClassList](#proxyclasslist)
|
||||||
- [ProxyGroup](#proxygroup)
|
- [ProxyGroup](#proxygroup)
|
||||||
@ -290,6 +292,7 @@ _Appears in:_
|
|||||||
|
|
||||||
_Appears in:_
|
_Appears in:_
|
||||||
- [Container](#container)
|
- [Container](#container)
|
||||||
|
- [IDPContainer](#idpcontainer)
|
||||||
- [RecorderContainer](#recordercontainer)
|
- [RecorderContainer](#recordercontainer)
|
||||||
|
|
||||||
| Field | Description | Default | Validation |
|
| 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
|
#### KubeAPIServerConfig
|
||||||
|
|
||||||
|
|
||||||
@ -1107,6 +1273,7 @@ _Validation:_
|
|||||||
|
|
||||||
_Appears in:_
|
_Appears in:_
|
||||||
- [ConnectorSpec](#connectorspec)
|
- [ConnectorSpec](#connectorspec)
|
||||||
|
- [IDPSpec](#idpspec)
|
||||||
- [ProxyGroupSpec](#proxygroupspec)
|
- [ProxyGroupSpec](#proxygroupspec)
|
||||||
- [RecorderSpec](#recorderspec)
|
- [RecorderSpec](#recorderspec)
|
||||||
|
|
||||||
|
@ -67,6 +67,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
|
|||||||
&RecorderList{},
|
&RecorderList{},
|
||||||
&ProxyGroup{},
|
&ProxyGroup{},
|
||||||
&ProxyGroupList{},
|
&ProxyGroupList{},
|
||||||
|
&IDP{},
|
||||||
|
&IDPList{},
|
||||||
)
|
)
|
||||||
|
|
||||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||||
|
@ -211,6 +211,7 @@ const (
|
|||||||
ProxyGroupAvailable ConditionType = `ProxyGroupAvailable` // At least one proxy Pod running.
|
ProxyGroupAvailable ConditionType = `ProxyGroupAvailable` // At least one proxy Pod running.
|
||||||
ProxyReady ConditionType = `TailscaleProxyReady` // a Tailscale-specific condition type for corev1.Service
|
ProxyReady ConditionType = `TailscaleProxyReady` // a Tailscale-specific condition type for corev1.Service
|
||||||
RecorderReady ConditionType = `RecorderReady`
|
RecorderReady ConditionType = `RecorderReady`
|
||||||
|
IDPReady ConditionType = `IDPReady`
|
||||||
// EgressSvcValid gets set on a user configured ExternalName Service that defines a tailnet target to be exposed
|
// EgressSvcValid gets set on a user configured ExternalName Service that defines a tailnet target to be exposed
|
||||||
// on a ProxyGroup.
|
// on a ProxyGroup.
|
||||||
// Set to true if the user provided configuration is valid.
|
// 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
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *KubeAPIServerConfig) DeepCopyInto(out *KubeAPIServerConfig) {
|
func (in *KubeAPIServerConfig) DeepCopyInto(out *KubeAPIServerConfig) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
@ -91,6 +91,14 @@ func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionT
|
|||||||
pg.Status.Conditions = conds
|
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 {
|
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{
|
newCondition := metav1.Condition{
|
||||||
Type: string(conditionType),
|
Type: string(conditionType),
|
||||||
|
@ -29,6 +29,7 @@ const (
|
|||||||
MetricConnectorWithAppConnectorCount = "k8s_connector_appconnector_resources"
|
MetricConnectorWithAppConnectorCount = "k8s_connector_appconnector_resources"
|
||||||
MetricNameserverCount = "k8s_nameserver_resources"
|
MetricNameserverCount = "k8s_nameserver_resources"
|
||||||
MetricRecorderCount = "k8s_recorder_resources"
|
MetricRecorderCount = "k8s_recorder_resources"
|
||||||
|
MetricIDPCount = "k8s_idp_resources"
|
||||||
MetricEgressServiceCount = "k8s_egress_service_resources"
|
MetricEgressServiceCount = "k8s_egress_service_resources"
|
||||||
MetricProxyGroupEgressCount = "k8s_proxygroup_egress_resources"
|
MetricProxyGroupEgressCount = "k8s_proxygroup_egress_resources"
|
||||||
MetricProxyGroupIngressCount = "k8s_proxygroup_ingress_resources"
|
MetricProxyGroupIngressCount = "k8s_proxygroup_ingress_resources"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user