tailscale/k8s-operator/conditions.go
Tom Proctor f421907c38
all-kube: create Tailscale Service for HA kube-apiserver ProxyGroup (#16572)
Adds a new reconciler for ProxyGroups of type kube-apiserver that will
provision a Tailscale Service for each replica to advertise. Adds two
new condition types to the ProxyGroup, TailscaleServiceValid and
TailscaleServiceConfigured, to post updates on the state of that
reconciler in a way that's consistent with the service-pg reconciler.
The created Tailscale Service name is configurable via a new ProxyGroup
field spec.kubeAPISserver.ServiceName, which expects a string of the
form "svc:<dns-label>".

Lots of supporting changes were needed to implement this in a way that's
consistent with other operator workflows, including:

* Pulled containerboot's ensureServicesUnadvertised and certManager into
  kube/ libraries to be shared with k8s-proxy. Use those in k8s-proxy to
  aid Service cert sharing between replicas and graceful Service shutdown.
* For certManager, add an initial wait to the cert loop to wait until
  the domain appears in the devices's netmap to avoid a guaranteed error
  on the first issue attempt when it's quick to start.
* Made several methods in ingress-for-pg.go and svc-for-pg.go into
  functions to share with the new reconciler
* Added a Resource struct to the owner refs stored in Tailscale Service
  annotations to be able to distinguish between Ingress- and ProxyGroup-
  based Services that need cleaning up in the Tailscale API.
* Added a ListVIPServices method to the internal tailscale client to aid
  cleaning up orphaned Services
* Support for reading config from a kube Secret, and partial support for
  config reloading, to prevent us having to force Pod restarts when
  config changes.
* Fixed up the zap logger so it's possible to set debug log level.

Updates #13358

Change-Id: Ia9607441157dd91fb9b6ecbc318eecbef446e116
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-07-21 11:03:21 +01:00

190 lines
7.6 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package kube
import (
"slices"
"time"
"go.uber.org/zap"
xslices "golang.org/x/exp/slices"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstime"
)
// SetConnectorCondition ensures that Connector status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes.
func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(cn.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
cn.Status.Conditions = conds
}
// RemoveConnectorCondition will remove condition of the given type if it exists.
func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConditionType) {
conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(conditionType)
})
}
// SetProxyClassCondition ensures that ProxyClass status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes.
func SetProxyClassCondition(pc *tsapi.ProxyClass, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(pc.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
pc.Status.Conditions = conds
}
// SetDNSConfigCondition ensures that DNSConfig status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes
func SetDNSConfigCondition(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(dnsCfg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
dnsCfg.Status.Conditions = conds
}
// SetServiceCondition ensures that Service status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes.
func SetServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(svc.Status.Conditions, conditionType, status, reason, message, 0, clock, logger)
svc.Status.Conditions = conds
}
// GetServiceCondition returns Service condition with the specified type, if it exists on the Service.
func GetServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType) *metav1.Condition {
idx := xslices.IndexFunc(svc.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(conditionType)
})
if idx == -1 {
return nil
}
return &svc.Status.Conditions[idx]
}
// RemoveServiceCondition will remove condition of the given type if it exists.
func RemoveServiceCondition(svc *corev1.Service, conditionType tsapi.ConditionType) {
svc.Status.Conditions = slices.DeleteFunc(svc.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(conditionType)
})
}
// SetRecorderCondition ensures that Recorder status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes.
func SetRecorderCondition(tsr *tsapi.Recorder, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(tsr.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
tsr.Status.Conditions = conds
}
// SetProxyGroupCondition ensures that ProxyGroup status has a condition with the
// given attributes. LastTransitionTime gets set every time condition's status
// changes.
func SetProxyGroupCondition(pg *tsapi.ProxyGroup, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
conds := updateCondition(pg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
pg.Status.Conditions = conds
}
func updateCondition(conds []metav1.Condition, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []metav1.Condition {
newCondition := metav1.Condition{
Type: string(conditionType),
Status: status,
Reason: reason,
Message: message,
ObservedGeneration: gen,
}
nowTime := metav1.NewTime(clock.Now().Truncate(time.Second))
newCondition.LastTransitionTime = nowTime
idx := xslices.IndexFunc(conds, func(cond metav1.Condition) bool {
return cond.Type == string(conditionType)
})
if idx == -1 {
conds = append(conds, newCondition)
return conds
}
cond := conds[idx] // update the existing condition
// If this update doesn't contain a state transition, don't update last
// transition time.
if cond.Status == status {
newCondition.LastTransitionTime = cond.LastTransitionTime
} else {
logger.Infof("Status change for condition %s from %s to %s", conditionType, cond.Status, status)
}
conds[idx] = newCondition
return conds
}
func ProxyClassIsReady(pc *tsapi.ProxyClass) bool {
idx := xslices.IndexFunc(pc.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(tsapi.ProxyClassReady)
})
if idx == -1 {
return false
}
cond := pc.Status.Conditions[idx]
return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pc.Generation
}
func ProxyGroupIsReady(pg *tsapi.ProxyGroup) bool {
cond := proxyGroupCondition(pg, tsapi.ProxyGroupReady)
return cond != nil && cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pg.Generation
}
func ProxyGroupAvailable(pg *tsapi.ProxyGroup) bool {
cond := proxyGroupCondition(pg, tsapi.ProxyGroupAvailable)
return cond != nil && cond.Status == metav1.ConditionTrue
}
func KubeAPIServerProxyValid(pg *tsapi.ProxyGroup) (valid bool, set bool) {
cond := proxyGroupCondition(pg, tsapi.KubeAPIServerProxyValid)
return cond != nil && cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pg.Generation, cond != nil
}
func KubeAPIServerProxyConfigured(pg *tsapi.ProxyGroup) bool {
cond := proxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured)
return cond != nil && cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pg.Generation
}
func proxyGroupCondition(pg *tsapi.ProxyGroup, condType tsapi.ConditionType) *metav1.Condition {
idx := xslices.IndexFunc(pg.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(condType)
})
if idx == -1 {
return nil
}
return &pg.Status.Conditions[idx]
}
func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool {
idx := xslices.IndexFunc(cfg.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(tsapi.NameserverReady)
})
if idx == -1 {
return false
}
cond := cfg.Status.Conditions[idx]
return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation
}
func SvcIsReady(svc *corev1.Service) bool {
idx := xslices.IndexFunc(svc.Status.Conditions, func(cond metav1.Condition) bool {
return cond.Type == string(tsapi.ProxyReady)
})
if idx == -1 {
return false
}
cond := svc.Status.Conditions[idx]
return cond.Status == metav1.ConditionTrue
}