cmd/k8s-operator: reinstate HA Ingress reconciler (#14887)
Some checks are pending
checklocks / checklocks (push) Waiting to run
CodeQL / Analyze (go) (push) Waiting to run
Dockerfile build / deploy (push) Waiting to run
CI / race-root-integration (1/4) (push) Waiting to run
CI / race-root-integration (2/4) (push) Waiting to run
CI / race-root-integration (3/4) (push) Waiting to run
CI / race-root-integration (4/4) (push) Waiting to run
CI / test (-coverprofile=/tmp/coverage.out, amd64) (push) Waiting to run
CI / test (-race, amd64, 1/3) (push) Waiting to run
CI / test (-race, amd64, 2/3) (push) Waiting to run
CI / test (-race, amd64, 3/3) (push) Waiting to run
CI / test (386) (push) Waiting to run
CI / windows (push) Waiting to run
CI / privileged (push) Waiting to run
CI / vm (push) Waiting to run
CI / race-build (push) Waiting to run
CI / cross (386, linux) (push) Waiting to run
CI / cross (amd64, darwin) (push) Waiting to run
CI / cross (amd64, freebsd) (push) Waiting to run
CI / cross (amd64, openbsd) (push) Waiting to run
CI / cross (amd64, windows) (push) Waiting to run
CI / cross (arm, 5, linux) (push) Waiting to run
CI / cross (arm, 7, linux) (push) Waiting to run
CI / cross (arm64, darwin) (push) Waiting to run
CI / cross (arm64, linux) (push) Waiting to run
CI / cross (arm64, windows) (push) Waiting to run
CI / cross (loong64, linux) (push) Waiting to run
CI / ios (push) Waiting to run
CI / crossmin (amd64, illumos) (push) Waiting to run
CI / crossmin (amd64, plan9) (push) Waiting to run
CI / crossmin (amd64, solaris) (push) Waiting to run
CI / crossmin (ppc64, aix) (push) Waiting to run
CI / android (push) Waiting to run
CI / wasm (push) Waiting to run
CI / tailscale_go (push) Waiting to run
CI / fuzz (push) Waiting to run
CI / depaware (push) Waiting to run
CI / go_generate (push) Waiting to run
CI / go_mod_tidy (push) Waiting to run
CI / licenses (push) Waiting to run
CI / staticcheck (386, windows) (push) Waiting to run
CI / staticcheck (amd64, darwin) (push) Waiting to run
CI / staticcheck (amd64, linux) (push) Waiting to run
CI / staticcheck (amd64, windows) (push) Waiting to run
CI / notify_slack (push) Blocked by required conditions
CI / check_mergeability (push) Blocked by required conditions

This change:

- reinstates the HA Ingress controller that was disabled for 1.80 release

- fixes the API calls to manage VIPServices as the API was changed

- triggers the HA Ingress reconciler on ProxyGroup changes

Updates tailscale/tailscale#24795

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
This commit is contained in:
Irbe Krumina 2025-02-04 15:09:43 +02:00 committed by GitHub
parent cfe578870d
commit 5ef934b62d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 149 additions and 44 deletions

View File

@ -103,7 +103,7 @@ spec:
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
type:
description: |-
Type of the ProxyGroup proxies. Currently the only supported type is egress.
Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
type: string
enum:

View File

@ -2860,7 +2860,7 @@ spec:
type: array
type:
description: |-
Type of the ProxyGroup proxies. Currently the only supported type is egress.
Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
enum:
- egress

View File

@ -44,6 +44,8 @@
VIPSvcOwnerRef = "tailscale.com/k8s-operator:owned-by:%s"
// FinalizerNamePG is the finalizer used by the IngressPGReconciler
FinalizerNamePG = "tailscale.com/ingress-pg-finalizer"
indexIngressProxyGroup = ".metadata.annotations.ingress-proxy-group"
)
var gaugePGIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressPGResourceCount)
@ -180,7 +182,8 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
return fmt.Errorf("error determining DNS name base: %w", err)
}
dnsName := hostname + "." + tcd
existingVIPSvc, err := a.tsClient.getVIPServiceByName(ctx, hostname)
serviceName := tailcfg.ServiceName("svc:" + hostname)
existingVIPSvc, err := a.tsClient.getVIPService(ctx, serviceName)
// TODO(irbekrm): here and when creating the VIPService, verify if the error is not terminal (and therefore
// should not be reconciled). For example, if the hostname is already a hostname of a Tailscale node, the GET
// here will fail.
@ -222,7 +225,6 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
},
},
}
serviceName := tailcfg.ServiceName("svc:" + hostname)
var gotCfg *ipn.ServiceConfig
if cfg != nil && cfg.Services != nil {
gotCfg = cfg.Services[serviceName]
@ -247,7 +249,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
}
vipSvc := &VIPService{
Name: hostname,
Name: serviceName,
Tags: tags,
Ports: []string{"443"}, // always 443 for Ingress
Comment: fmt.Sprintf(VIPSvcOwnerRef, ing.UID),
@ -257,7 +259,7 @@ func (a *IngressPGReconciler) maybeProvision(ctx context.Context, hostname strin
}
if existingVIPSvc == nil || !reflect.DeepEqual(vipSvc.Tags, existingVIPSvc.Tags) {
logger.Infof("Ensuring VIPService %q exists and is up to date", hostname)
if err := a.tsClient.createOrUpdateVIPServiceByName(ctx, vipSvc); err != nil {
if err := a.tsClient.createOrUpdateVIPService(ctx, vipSvc); err != nil {
logger.Infof("error creating VIPService: %v", err)
return fmt.Errorf("error creating VIPService: %w", err)
}
@ -305,39 +307,39 @@ func (a *IngressPGReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
}
serveConfigChanged := false
// For each VIPService in serve config...
for vipHostname := range cfg.Services {
for vipServiceName := range cfg.Services {
// ...check if there is currently an Ingress with this hostname
found := false
for _, i := range ingList.Items {
ingressHostname := hostnameForIngress(&i)
if ingressHostname == vipHostname.WithoutPrefix() {
if ingressHostname == vipServiceName.WithoutPrefix() {
found = true
break
}
}
if !found {
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipHostname)
svc, err := a.getVIPService(ctx, vipHostname.WithoutPrefix(), logger)
logger.Infof("VIPService %q is not owned by any Ingress, cleaning up", vipServiceName)
svc, err := a.getVIPService(ctx, vipServiceName, logger)
if err != nil {
errResp := &tailscale.ErrResponse{}
if errors.As(err, &errResp) && errResp.Status == http.StatusNotFound {
delete(cfg.Services, vipHostname)
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
continue
}
return err
}
if isVIPServiceForAnyIngress(svc) {
logger.Infof("cleaning up orphaned VIPService %q", vipHostname)
if err := a.tsClient.deleteVIPServiceByName(ctx, vipHostname.WithoutPrefix()); err != nil {
logger.Infof("cleaning up orphaned VIPService %q", vipServiceName)
if err := a.tsClient.deleteVIPService(ctx, vipServiceName); err != nil {
errResp := &tailscale.ErrResponse{}
if !errors.As(err, &errResp) || errResp.Status != http.StatusNotFound {
return fmt.Errorf("deleting VIPService %q: %w", vipHostname, err)
return fmt.Errorf("deleting VIPService %q: %w", vipServiceName, err)
}
}
}
delete(cfg.Services, vipHostname)
delete(cfg.Services, vipServiceName)
serveConfigChanged = true
}
}
@ -386,7 +388,7 @@ func (a *IngressPGReconciler) maybeCleanup(ctx context.Context, hostname string,
logger.Infof("Ensuring that VIPService %q configuration is cleaned up", hostname)
// 2. Delete the VIPService.
if err := a.deleteVIPServiceIfExists(ctx, hostname, ing, logger); err != nil {
if err := a.deleteVIPServiceIfExists(ctx, serviceName, ing, logger); err != nil {
return fmt.Errorf("error deleting VIPService: %w", err)
}
@ -478,13 +480,13 @@ func (a *IngressPGReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
return isTSIngress && pgAnnot != ""
}
func (a *IngressPGReconciler) getVIPService(ctx context.Context, hostname string, logger *zap.SugaredLogger) (*VIPService, error) {
svc, err := a.tsClient.getVIPServiceByName(ctx, hostname)
func (a *IngressPGReconciler) getVIPService(ctx context.Context, name tailcfg.ServiceName, logger *zap.SugaredLogger) (*VIPService, error) {
svc, err := a.tsClient.getVIPService(ctx, name)
if err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status != http.StatusNotFound {
logger.Infof("error getting VIPService %q: %v", hostname, err)
return nil, fmt.Errorf("error getting VIPService %q: %w", hostname, err)
logger.Infof("error getting VIPService %q: %v", name, err)
return nil, fmt.Errorf("error getting VIPService %q: %w", name, err)
}
}
return svc, nil
@ -550,7 +552,7 @@ func (a *IngressPGReconciler) validateIngress(ing *networkingv1.Ingress, pg *tsa
}
// deleteVIPServiceIfExists attempts to delete the VIPService if it exists and is owned by the given Ingress.
func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name string, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name tailcfg.ServiceName, ing *networkingv1.Ingress, logger *zap.SugaredLogger) error {
svc, err := a.getVIPService(ctx, name, logger)
if err != nil {
return fmt.Errorf("error getting VIPService: %w", err)
@ -562,7 +564,7 @@ func (a *IngressPGReconciler) deleteVIPServiceIfExists(ctx context.Context, name
}
logger.Infof("Deleting VIPService %q", name)
if err = a.tsClient.deleteVIPServiceByName(ctx, name); err != nil {
if err = a.tsClient.deleteVIPService(ctx, name); err != nil {
return fmt.Errorf("error deleting VIPService: %w", err)
}
return nil

View File

@ -142,7 +142,7 @@ func TestIngressPGReconciler(t *testing.T) {
}
// Verify VIPService uses default tags
vipSvc, err := ft.getVIPServiceByName(context.Background(), "my-svc")
vipSvc, err := ft.getVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}
@ -161,7 +161,7 @@ func TestIngressPGReconciler(t *testing.T) {
expectReconciled(t, ingPGR, "default", "test-ingress")
// Verify VIPService uses custom tags
vipSvc, err = ft.getVIPServiceByName(context.Background(), "my-svc")
vipSvc, err = ft.getVIPService(context.Background(), "svc:my-svc")
if err != nil {
t.Fatalf("getting VIPService: %v", err)
}

View File

@ -331,6 +331,33 @@ func runReconcilers(opts reconcilerOpts) {
if err != nil {
startlog.Fatalf("could not create ingress reconciler: %v", err)
}
lc, err := opts.tsServer.LocalClient()
if err != nil {
startlog.Fatalf("could not get local client: %v", err)
}
ingressProxyGroupFilter := handler.EnqueueRequestsFromMapFunc(ingressesFromIngressProxyGroup(mgr.GetClient(), opts.log))
err = builder.
ControllerManagedBy(mgr).
For(&networkingv1.Ingress{}).
Named("ingress-pg-reconciler").
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
Complete(&IngressPGReconciler{
recorder: eventRecorder,
tsClient: opts.tsClient,
tsnetServer: opts.tsServer,
defaultTags: strings.Split(opts.proxyTags, ","),
Client: mgr.GetClient(),
logger: opts.log.Named("ingress-pg-reconciler"),
lc: lc,
tsNamespace: opts.tailscaleNamespace,
})
if err != nil {
startlog.Fatalf("could not create ingress-pg-reconciler: %v", err)
}
if err := mgr.GetFieldIndexer().IndexField(context.Background(), new(networkingv1.Ingress), indexIngressProxyGroup, indexPGIngresses); err != nil {
startlog.Fatalf("failed setting up indexer for HA Ingresses: %v", err)
}
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
// If a ProxyClassChanges, enqueue all Connectors that have
@ -1036,6 +1063,36 @@ func egressSvcsFromEgressProxyGroup(cl client.Client, logger *zap.SugaredLogger)
}
}
// ingressesFromIngressProxyGroup is an event handler for ingress ProxyGroups. It returns reconcile requests for all
// user-created Ingresses that should be exposed on this ProxyGroup.
func ingressesFromIngressProxyGroup(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
pg, ok := o.(*tsapi.ProxyGroup)
if !ok {
logger.Infof("[unexpected] ProxyGroup handler triggered for an object that is not a ProxyGroup")
return nil
}
if pg.Spec.Type != tsapi.ProxyGroupTypeIngress {
return nil
}
ingList := &networkingv1.IngressList{}
if err := cl.List(ctx, ingList, client.MatchingFields{indexIngressProxyGroup: pg.Name}); err != nil {
logger.Infof("error listing Ingresses: %v, skipping a reconcile for event on ProxyGroup %s", err, pg.Name)
return nil
}
reqs := make([]reconcile.Request, 0)
for _, svc := range ingList.Items {
reqs = append(reqs, reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: svc.Namespace,
Name: svc.Name,
},
})
}
return reqs
}
}
// epsFromExternalNameService is an event handler for ExternalName Services that define a Tailscale egress service that
// should be exposed on a ProxyGroup. It returns reconcile requests for EndpointSlices created for this Service.
func epsFromExternalNameService(cl client.Client, logger *zap.SugaredLogger, ns string) handler.MapFunc {
@ -1156,6 +1213,51 @@ func indexEgressServices(o client.Object) []string {
return []string{o.GetAnnotations()[AnnotationProxyGroup]}
}
// indexPGIngresses adds a local index to a cached Tailscale Ingresses meant to be exposed on a ProxyGroup. The index is
// used a list filter.
func indexPGIngresses(o client.Object) []string {
if !hasProxyGroupAnnotation(o) {
return nil
}
return []string{o.GetAnnotations()[AnnotationProxyGroup]}
}
// serviceHandlerForIngressPG returns a handler for Service events that ensures that if the Service
// associated with an event is a backend Service for a tailscale Ingress with ProxyGroup annotation,
// the associated Ingress gets reconciled.
func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
return func(ctx context.Context, o client.Object) []reconcile.Request {
ingList := networkingv1.IngressList{}
if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
logger.Debugf("error listing Ingresses: %v", err)
return nil
}
reqs := make([]reconcile.Request, 0)
for _, ing := range ingList.Items {
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
continue
}
if !hasProxyGroupAnnotation(&ing) {
continue
}
if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
}
for _, rule := range ing.Spec.Rules {
if rule.HTTP == nil {
continue
}
for _, path := range rule.HTTP.Paths {
if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
}
}
}
}
return reqs
}
}
func hasProxyGroupAnnotation(obj client.Object) bool {
ing := obj.(*networkingv1.Ingress)
return ing.Annotations[AnnotationProxyGroup] != ""

View File

@ -32,6 +32,7 @@
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tailcfg"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
)
@ -767,7 +768,7 @@ type fakeTSClient struct {
sync.Mutex
keyRequests []tailscale.KeyCapabilities
deleted []string
vipServices map[string]*VIPService
vipServices map[tailcfg.ServiceName]*VIPService
}
type fakeTSNetServer struct {
certDomains []string
@ -874,7 +875,7 @@ func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
}
}
func (c *fakeTSClient) getVIPServiceByName(ctx context.Context, name string) (*VIPService, error) {
func (c *fakeTSClient) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) {
c.Lock()
defer c.Unlock()
if c.vipServices == nil {
@ -887,17 +888,17 @@ func (c *fakeTSClient) getVIPServiceByName(ctx context.Context, name string) (*V
return svc, nil
}
func (c *fakeTSClient) createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
func (c *fakeTSClient) createOrUpdateVIPService(ctx context.Context, svc *VIPService) error {
c.Lock()
defer c.Unlock()
if c.vipServices == nil {
c.vipServices = make(map[string]*VIPService)
c.vipServices = make(map[tailcfg.ServiceName]*VIPService)
}
c.vipServices[svc.Name] = svc
return nil
}
func (c *fakeTSClient) deleteVIPServiceByName(ctx context.Context, name string) error {
func (c *fakeTSClient) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
c.Lock()
defer c.Unlock()
if c.vipServices != nil {

View File

@ -17,6 +17,7 @@
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
)
@ -56,9 +57,9 @@ type tsClient interface {
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
DeleteDevice(ctx context.Context, nodeStableID string) error
getVIPServiceByName(ctx context.Context, name string) (*VIPService, error)
createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error
deleteVIPServiceByName(ctx context.Context, name string) error
getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error)
createOrUpdateVIPService(ctx context.Context, svc *VIPService) error
deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
}
type tsClientImpl struct {
@ -69,9 +70,8 @@ type tsClientImpl struct {
// VIPService is a Tailscale VIPService with Tailscale API JSON representation.
type VIPService struct {
// Name is the leftmost label of the DNS name of the VIP service.
// Name is required.
Name string `json:"name,omitempty"`
// Name is a VIPService name in form svc:<leftmost-label-of-service-DNS-name>.
Name tailcfg.ServiceName `json:"name,omitempty"`
// Addrs are the IP addresses of the VIP Service. There are two addresses:
// the first is IPv4 and the second is IPv6.
// When creating a new VIP Service, the IP addresses are optional: if no
@ -89,8 +89,8 @@ type VIPService struct {
}
// GetVIPServiceByName retrieves a VIPService by its name. It returns 404 if the VIPService is not found.
func (c *tsClientImpl) getVIPServiceByName(ctx context.Context, name string) (*VIPService, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
func (c *tsClientImpl) getVIPService(ctx context.Context, name tailcfg.ServiceName) (*VIPService, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String()))
req, err := http.NewRequestWithContext(ctx, httpm.GET, path, nil)
if err != nil {
return nil, fmt.Errorf("error creating new HTTP request: %w", err)
@ -111,16 +111,16 @@ func (c *tsClientImpl) getVIPServiceByName(ctx context.Context, name string) (*V
return svc, nil
}
// CreateOrUpdateVIPServiceByName creates or updates a VIPService by its name. Caller must ensure that, if the
// createOrUpdateVIPService creates or updates a VIPService by its name. Caller must ensure that, if the
// VIPService already exists, the VIPService is fetched first to ensure that any auto-allocated IP addresses are not
// lost during the update. If the VIPService was created without any IP addresses explicitly set (so that they were
// auto-allocated by Tailscale) any subsequent request to this function that does not set any IP addresses will error.
func (c *tsClientImpl) createOrUpdateVIPServiceByName(ctx context.Context, svc *VIPService) error {
func (c *tsClientImpl) createOrUpdateVIPService(ctx context.Context, svc *VIPService) error {
data, err := json.Marshal(svc)
if err != nil {
return err
}
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name))
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(svc.Name.String()))
req, err := http.NewRequestWithContext(ctx, httpm.PUT, path, bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("error creating new HTTP request: %w", err)
@ -139,8 +139,8 @@ func (c *tsClientImpl) createOrUpdateVIPServiceByName(ctx context.Context, svc *
// DeleteVIPServiceByName deletes a VIPService by its name. It returns an error if the VIPService
// does not exist or if the deletion fails.
func (c *tsClientImpl) deleteVIPServiceByName(ctx context.Context, name string) error {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/by-name/%s", c.baseURL, c.tailnet, url.PathEscape(name))
func (c *tsClientImpl) deleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/vip-services/%s", c.baseURL, c.tailnet, url.PathEscape(name.String()))
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
if err != nil {
return fmt.Errorf("error creating new HTTP request: %w", err)

View File

@ -599,7 +599,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Currently the only supported type is egress.<br />Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress] <br />Type: string <br /> |
| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Supported types are egress and ingress.<br />Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress] <br />Type: string <br /> |
| `tags` _[Tags](#tags)_ | Tags that the Tailscale devices 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 a ProxyGroup device 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 /> |
| `replicas` _integer_ | Replicas specifies how many replicas to create the StatefulSet with.<br />Defaults to 2. | | Minimum: 0 <br /> |
| `hostnamePrefix` _[HostnamePrefix](#hostnameprefix)_ | HostnamePrefix is the hostname prefix to use for tailnet devices created<br />by the ProxyGroup. Each device will have the integer number from its<br />StatefulSet pod appended to this prefix to form the full hostname.<br />HostnamePrefix can contain lower case letters, numbers and dashes, it<br />must not start with a dash and must be between 1 and 62 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}$` <br />Type: string <br /> |

View File

@ -48,7 +48,7 @@ type ProxyGroupList struct {
}
type ProxyGroupSpec struct {
// Type of the ProxyGroup proxies. Currently the only supported type is egress.
// Type of the ProxyGroup proxies. Supported types are egress and ingress.
// Type is immutable once a ProxyGroup is created.
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ProxyGroup type is immutable"
Type ProxyGroupType `json:"type"`