diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml index e101c201f..86e74e441 100644 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_proxygroups.yaml +++ b/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: diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index 54b32bef0..e966ef559 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/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 diff --git a/cmd/k8s-operator/ingress-for-pg.go b/cmd/k8s-operator/ingress-for-pg.go index e90187d58..5a67a891f 100644 --- a/cmd/k8s-operator/ingress-for-pg.go +++ b/cmd/k8s-operator/ingress-for-pg.go @@ -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 diff --git a/cmd/k8s-operator/ingress-for-pg_test.go b/cmd/k8s-operator/ingress-for-pg_test.go index 9ef36f696..9317a44d4 100644 --- a/cmd/k8s-operator/ingress-for-pg_test.go +++ b/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) } diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 8fcd1342c..37e37a96e 100644 --- a/cmd/k8s-operator/operator.go +++ b/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] != "" diff --git a/cmd/k8s-operator/testutils_test.go b/cmd/k8s-operator/testutils_test.go index 83c42cb76..386005b1f 100644 --- a/cmd/k8s-operator/testutils_test.go +++ b/cmd/k8s-operator/testutils_test.go @@ -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 { diff --git a/cmd/k8s-operator/tsclient.go b/cmd/k8s-operator/tsclient.go index 5352629de..2381438b2 100644 --- a/cmd/k8s-operator/tsclient.go +++ b/cmd/k8s-operator/tsclient.go @@ -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:. + 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) diff --git a/k8s-operator/api.md b/k8s-operator/api.md index 64756c8f1..fae25b1f6 100644 --- a/k8s-operator/api.md +++ b/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.
Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress]
Type: string
| +| `type` _[ProxyGroupType](#proxygrouptype)_ | Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created. | | Enum: [egress ingress]
Type: string
| | `tags` _[Tags](#tags)_ | Tags that the Tailscale devices 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 a ProxyGroup device has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$. | | Pattern: `^tag:[a-zA-Z][a-zA-Z0-9-]*$`
Type: string
| | `replicas` _integer_ | Replicas specifies how many replicas to create the StatefulSet with.
Defaults to 2. | | Minimum: 0
| | `hostnamePrefix` _[HostnamePrefix](#hostnameprefix)_ | HostnamePrefix is the hostname prefix to use for tailnet devices created
by the ProxyGroup. Each device will have the integer number from its
StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long. | | Pattern: `^[a-z0-9][a-z0-9-]{0,61}$`
Type: string
| diff --git a/k8s-operator/apis/v1alpha1/types_proxygroup.go b/k8s-operator/apis/v1alpha1/types_proxygroup.go index cb9f678f8..f95fc58d0 100644 --- a/k8s-operator/apis/v1alpha1/types_proxygroup.go +++ b/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"`