Browse Source

cmd/k8s-operator: reinstate HA Ingress reconciler (#14887)

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 <[email protected]>
Irbe Krumina 1 year ago
parent
commit
5ef934b62d

+ 1 - 1
cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml

@@ -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:

+ 1 - 1
cmd/k8s-operator/deploy/manifests/operator.yaml

@@ -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

+ 22 - 20
cmd/k8s-operator/ingress-for-pg.go

@@ -44,6 +44,8 @@ const (
 	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

+ 2 - 2
cmd/k8s-operator/ingress-for-pg_test.go

@@ -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)
 	}

+ 102 - 0
cmd/k8s-operator/operator.go

@@ -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] != ""

+ 6 - 5
cmd/k8s-operator/testutils_test.go

@@ -32,6 +32,7 @@ import (
 	"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 {

+ 13 - 13
cmd/k8s-operator/tsclient.go

@@ -17,6 +17,7 @@ import (
 
 	"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)

+ 1 - 1
k8s-operator/api.md

@@ -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 /> |

+ 1 - 1
k8s-operator/apis/v1alpha1/types_proxygroup.go

@@ -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"`