Repository: zalando-incubator/kube-ingress-aws-controller
Pull request #474 (2022-02-07 20:24:05)

fabricgateway resource create cloud lbs
Similar to RouteGroups, FabricGateways should trigger a load balancer creation.
If one of these types do not exist as CRD in the cluster or rbac error, nothing should break.
Controller would now need a restart in case permissions are fixed.

szuecs: Successfully tested in dev cluster.
AlexanderYastrebov: :+1: 

Patch:
diff --git a/README.md b/README.md
index 79feb9e..b6e895d 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ This information is used to manage AWS resources for each ingress objects of the
 - [Support Multiple TLS Certificates per ALB (SNI)](https://aws.amazon.com/blogs/aws/new-application-load-balancer-sni/).
 - Support for AWS WAF and WAFv2
 - Support for AWS CNI pod direct access
+- Support for Kubernetes CRD [RouteGroup](https://opensource.zalando.com/skipper/kubernetes/routegroups/)
+- Support for Kubernetes CRD [FabricGateway](https://opensource.zalando.com/skipper/kubernetes/fabricgateways/)
 
 ## Upgrade
 
@@ -48,7 +50,7 @@ work the controller needs to have permissions to `list` `ingresses` and
 [See deployment example](deploy/ingress-serviceaccount.yaml). To fallback to
 the old behavior you can set the apiVersion via the `--ingress-api-version`
 flag. Value must be `extensions/v1beta1` or `networking.k8s.io/v1beta1`
-(default).
+(default) or `networking.k8s.io/v1`.
 
 ### <v0.9.0 to >=v0.9.0
 
@@ -271,7 +273,7 @@ When the controller learns about new ingress resources, it uses the hosts specif
 the most specific, valid certificates to use. The certificates has to be valid for at least 7 days. An example ingress:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: test-app
@@ -281,8 +283,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 The Application Load Balancer created by the controller will have both an HTTP listener and an HTTPS listener. The
@@ -307,7 +313,7 @@ As a second option you can specify the [Amazon Resource Name](https://docs.aws.a
 of the desired certificate with an annotation like the one shown here:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -319,8 +325,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 #### Create an internal Load Balancer
@@ -329,7 +339,7 @@ You can select the [Application Load Balancer Scheme](http://docs.aws.amazon.com
 with an annotation like the one shown here:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -341,8 +351,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 You can only select from `internet-facing` (default) and `internal`
@@ -364,7 +378,7 @@ that have the `.cluster.local` and the controller will not create an
 ALB for this.
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -374,8 +388,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 If you pass `--cluster-local-domain=".cluster.local"`, you can change
@@ -436,7 +454,7 @@ values will be checked by the controller.
 Example:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -448,8 +466,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 #### Create Load Balancer with SecurityGroup
@@ -467,7 +489,7 @@ SecurityGroup of your choice with the
 shown here:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -479,8 +501,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 #### Create Load Balancers with WAF associations
@@ -508,7 +534,7 @@ kube-ingress-aws-controller --aws-waf-web-acl-id=arn:aws:wafv2:eu-central-1:1234
 ##### Setting ingress specicif WAF association:
 
 ```yaml
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: myingress
@@ -520,8 +546,12 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: test-app-service
-          servicePort: main-port
+          service:
+            name: test-app-service
+            port:
+              name: main-port
+        path: /
+        pathType: ImplementationSpecific
 ```
 
 
diff --git a/aws/adapter.go b/aws/adapter.go
index 704fa7d..c9d52dd 100644
--- a/aws/adapter.go
+++ b/aws/adapter.go
@@ -594,10 +594,6 @@ func (a *Adapter) UpdateTargetGroupsAndAutoScalingGroups(stacks []*Stack, proble
 
 	// remove the IP TGs from the list keeping all other TGs including problematic #127 and nonexistent #436
 	targetGroupARNs := difference(allTargetGroupARNs, targetTypesARNs[elbv2.TargetTypeEnumIp])
-	// don't do anything if there are no target groups
-	if len(targetGroupARNs) == 0 {
-		return
-	}
 
 	ownerTags := map[string]string{
 		clusterIDTagPrefix + a.ClusterID(): resourceLifecycleOwned,
diff --git a/deploy/examples/sample-app-v1.yaml b/deploy/examples/sample-app-v1.yaml
index 32d6ef1..6edb509 100644
--- a/deploy/examples/sample-app-v1.yaml
+++ b/deploy/examples/sample-app-v1.yaml
@@ -1,4 +1,4 @@
-apiVersion: apps/v1beta1
+apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: demo-app-v1
diff --git a/deploy/examples/sample-app-v2.yaml b/deploy/examples/sample-app-v2.yaml
index 5aea3ca..aaf8b1d 100644
--- a/deploy/examples/sample-app-v2.yaml
+++ b/deploy/examples/sample-app-v2.yaml
@@ -1,4 +1,4 @@
-apiVersion: apps/v1beta1
+apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: demo-app-v2
diff --git a/deploy/examples/sample-ing-traffic.yaml b/deploy/examples/sample-ing-traffic.yaml
index 0b1c7d5..f931b36 100644
--- a/deploy/examples/sample-ing-traffic.yaml
+++ b/deploy/examples/sample-ing-traffic.yaml
@@ -1,4 +1,4 @@
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: "demo-traffic-switching"
@@ -13,8 +13,16 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: "demo-app-v1"
-          servicePort: 80
+          service:
+            name: "demo-app-v1"
+            port:
+              number: 80
+        path: /
+        pathType: ImplementationSpecific
       - backend:
-          serviceName: "demo-app-v2"
-          servicePort: 80
+          service:
+            name: "demo-app-v2"
+            port:
+              number: 80
+        path: /
+        pathType: ImplementationSpecific
diff --git a/deploy/examples/sample-ing-v1.yaml b/deploy/examples/sample-ing-v1.yaml
index 73d7a35..1234567 100644
--- a/deploy/examples/sample-ing-v1.yaml
+++ b/deploy/examples/sample-ing-v1.yaml
@@ -1,4 +1,4 @@
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: "demo-v1"
@@ -10,5 +10,9 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: "demo-app-v1"
-          servicePort: 80
+          service:
+            name: demo-app-v1
+            port:
+              number: 80
+        path: /
+        pathType: ImplementationSpecific
diff --git a/deploy/examples/sample-ing-v2.yaml b/deploy/examples/sample-ing-v2.yaml
index c2ace4b..f39d53a 100644
--- a/deploy/examples/sample-ing-v2.yaml
+++ b/deploy/examples/sample-ing-v2.yaml
@@ -1,4 +1,4 @@
-apiVersion: extensions/v1beta1
+apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   name: "demo-feature-toggle"
@@ -13,5 +13,10 @@ spec:
     http:
       paths:
       - backend:
-          serviceName: "demo-app-v1"
-          servicePort: 80
+      - backend:
+          service:
+            name: demo-app-v1
+            port:
+              number: 80
+        path: /
+        pathType: ImplementationSpecific
diff --git a/deploy/ingress-serviceaccount.yaml b/deploy/ingress-serviceaccount.yaml
index 0509b75..f631536 100644
--- a/deploy/ingress-serviceaccount.yaml
+++ b/deploy/ingress-serviceaccount.yaml
@@ -39,6 +39,7 @@ rules:
   - zalando.org
   resources:
   - routegroups
+  - fabricgateways
   verbs:
   - get
   - list
@@ -46,6 +47,7 @@ rules:
   - zalando.org
   resources:
   - routegroups/status
+  - fabricgateways/status
   verbs:
   - patch
   - update
diff --git a/deploy/skipper-serviceaccount.yaml b/deploy/skipper-serviceaccount.yaml
index 34c88ef..b8f7d37 100644
--- a/deploy/skipper-serviceaccount.yaml
+++ b/deploy/skipper-serviceaccount.yaml
@@ -10,7 +10,9 @@ kind: ClusterRole
 metadata:
   name: skipper-ingress
 rules:
-- apiGroups: ["extensions"]
+- apiGroups:
+  - extensions
+  - networking.k8s.io
   resources: ["ingresses", ]
   verbs: ["get", "list"]
 - apiGroups: [""]
@@ -20,6 +22,7 @@ rules:
   - zalando.org
   resources:
   - routegroups
+  - fabricgateways
   verbs:
   - get
   - list
diff --git a/kubernetes/adapter.go b/kubernetes/adapter.go
index e2e9638..7dfdede 100644
--- a/kubernetes/adapter.go
+++ b/kubernetes/adapter.go
@@ -23,6 +23,7 @@ type Adapter struct {
 	ingressDefaultLoadBalancerType string
 	clusterLocalDomain             string
 	routeGroupSupport              bool
+	fabricSupport                  bool
 }
 
 type ingressType int
@@ -30,6 +31,7 @@ type ingressType int
 const (
 	ingressTypeIngress ingressType = iota + 1
 	ingressTypeRouteGroup
+	ingressTypeFabric
 )
 
 func (t ingressType) String() string {
@@ -38,6 +40,8 @@ func (t ingressType) String() string {
 		return "ingress"
 	case ingressTypeRouteGroup:
 		return "routegroup"
+	case ingressTypeFabric:
+		return "fabric"
 	default:
 		return "unknown"
 	}
@@ -51,8 +55,7 @@ const (
 
 var (
 	// ErrMissingKubernetesEnv is returned when the Kubernetes API server environment variables are not defined
-	ErrMissingKubernetesEnv = errors.New("unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and " +
-		"KUBERNETES_SERVICE_PORT are not defined")
+	ErrMissingKubernetesEnv = errors.New("unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT are not defined")
 	// ErrInvalidIngressUpdateParams is returned when a request to update ingress resources has an empty DNS name
 	// or doesn't specify any ingress resources
 	ErrInvalidIngressUpdateParams = errors.New("invalid ingress update parameters")
@@ -136,6 +139,7 @@ func NewAdapter(config *Config, ingressAPIVersion string, ingressClassFilters []
 		ingressDefaultLoadBalancerType: loadBalancerTypesAWSToIngress[ingressDefaultLoadBalancerType],
 		clusterLocalDomain:             clusterLocalDomain,
 		routeGroupSupport:              true,
+		fabricSupport:                  true,
 	}, nil
 }
 
@@ -177,6 +181,34 @@ func (a *Adapter) newIngressFromRouteGroup(rg *routegroup) (*Ingress, error) {
 	return a.newIngress(ingressTypeRouteGroup, rg.Metadata, host, hostnames)
 }
 
+func (a *Adapter) newIngressFromFabric(fg *fabric) (*Ingress, error) {
+	var host string
+	var hostnames []string
+	for _, lb := range fg.Status.LoadBalancer.Fabric {
+		if lb.Hostname != "" {
+			host = lb.Hostname
+			break
+		}
+	}
+
+	switch {
+	case len(fg.Spec.Service) > 0:
+		for _, svc := range fg.Spec.Service {
+			if svc.Host != "" && (a.clusterLocalDomain == "" || !strings.HasSuffix(svc.Host, a.clusterLocalDomain)) {
+				hostnames = append(hostnames, svc.Host)
+			}
+		}
+	case fg.Spec.ExternalServiceProvider != nil:
+		for _, host := range fg.Spec.ExternalServiceProvider.Hosts {
+			if host != "" && (a.clusterLocalDomain == "" || !strings.HasSuffix(host, a.clusterLocalDomain)) {
+				hostnames = append(hostnames, host)
+			}
+		}
+	}
+
+	return a.newIngress(ingressTypeFabric, fg.Metadata, host, hostnames)
+}
+
 func (a *Adapter) newIngress(typ ingressType, metadata kubeItemMetadata, host string, hostnames []string) (*Ingress, error) {
 	annotations := metadata.Annotations
 
@@ -271,35 +303,50 @@ func (a *Adapter) IngressFiltersString() string {
 	return strings.TrimSpace(strings.Join(a.ingressFilters, ","))
 }
 
-// ListResources can be used to obtain the list of ingress and
-// routegroup resources for all namespaces filtered by class. It
+// ListResources can be used to obtain the list of ingress, routegroup and
+// fabric resources for all namespaces filtered by class. It
 // returns the Ingress business object, that for the controller does
-// not matter to be routegroup or ingress..
+// not matter to be fabricgateway, routegroup or ingress..
 func (a *Adapter) ListResources() ([]*Ingress, error) {
 	ings, err := a.ListIngress()
 	if err != nil {
 		return nil, err
 	}
-	rgs, err := a.ListRoutegroups()
-	if err != nil {
-		if a.routeGroupSupport {
+
+	var rgs []*Ingress
+	if a.routeGroupSupport {
+		rgs, err = a.ListRoutegroups()
+		if err != nil {
 			a.routeGroupSupport = false
 			log.Warnf("Disabling RouteGroup support because listing RouteGroups failed: %v, to get more information https://opensource.zalando.com/skipper/kubernetes/routegroups/#routegroups", err)
+			// Generic error, RouteGroup CRD exists and we have permission to access
+			if err != ErrResourceNotFound && err != ErrNoPermissionToAccessResource {
+				return nil, err
+			}
 		}
-		// RouteGroup CRD does not exist or no permission to access RouteGroup resources
-		if err == ErrResourceNotFound || err == ErrNoPermissionToAccessResource {
-			return ings, nil
+	}
+
+	var fgs []*Ingress
+	if a.fabricSupport {
+		fgs, err = a.ListFabricgateways()
+		if err != nil {
+			a.fabricSupport = false
+			log.Warnf("Disabling FabricGateway support because listing FabricGateways failed: %v, to get more information https://opensource.zalando.com/skipper/kubernetes/fabric/#fabricgateways", err)
+			// Generic error, FabricGateway CRD exists and we have permission to access
+			if err != ErrResourceNotFound && err != ErrNoPermissionToAccessResource {
+				return nil, err
+			}
 		}
-		return nil, err
 	}
-	a.routeGroupSupport = true
-	return append(ings, rgs...), nil
+
+	ings = append(ings, rgs...)
+	return append(ings, fgs...), nil
 }
 
 // ListIngress can be used to obtain the list of ingress resources for
 // all namespaces filtered by class. It returns the Ingress business
-// object, that for the controller does not matter to be routegroup or
-// ingress..
+// object, that for the controller does not matter to be
+// fabricgateway, routegroup or ingress..
 func (a *Adapter) ListIngress() ([]*Ingress, error) {
 	il, err := a.ingressClient.listIngress(a.kubeClient)
 	if err != nil {
@@ -327,7 +374,7 @@ func (a *Adapter) ListIngress() ([]*Ingress, error) {
 // ListRoutegroups can be used to obtain the list of Ingress resources
 // for all namespaces filtered by class. It returns the Ingress
 // business object, that for the controller does not matter to be
-// routegroup or ingress.
+// fabricgateway, routegroup or ingress.
 func (a *Adapter) ListRoutegroups() ([]*Ingress, error) {
 	rgs, err := listRoutegroups(a.kubeClient)
 	if err != nil {
@@ -336,7 +383,7 @@ func (a *Adapter) ListRoutegroups() ([]*Ingress, error) {
 
 	var ret []*Ingress
 	for _, rg := range rgs.Items {
-		if !a.supportedRoutegroup(rg.Metadata) {
+		if !a.supportedCRD(rg.Metadata) {
 			continue
 		}
 		ing, err := a.newIngressFromRouteGroup(rg)
@@ -353,7 +400,36 @@ func (a *Adapter) ListRoutegroups() ([]*Ingress, error) {
 	return ret, nil
 }
 
-func (a *Adapter) supportedRoutegroup(metadata kubeItemMetadata) bool {
+// ListFabricgateways can be used to obtain the list of Ingress resources
+// for all namespaces filtered by class. It returns the Ingress
+// business object, that for the controller does not matter to be
+// fabricgateway, routegroup or ingress.
+func (a *Adapter) ListFabricgateways() ([]*Ingress, error) {
+	fgs, err := listFabricgateways(a.kubeClient)
+	if err != nil {
+		return nil, err
+	}
+
+	var ret []*Ingress
+	for _, fg := range fgs.Items {
+		if !a.supportedCRD(fg.Metadata) {
+			continue
+		}
+		ing, err := a.newIngressFromFabric(fg)
+		if err == nil {
+			ret = append(ret, ing)
+		} else {
+			log.WithFields(log.Fields{
+				"type": ingressTypeFabric.String(),
+				"ns":   fg.Metadata.Namespace,
+				"name": fg.Metadata.Name,
+			}).Errorf("%v", err)
+		}
+	}
+	return ret, nil
+}
+
+func (a *Adapter) supportedCRD(metadata kubeItemMetadata) bool {
 	if len(a.ingressFilters) == 0 {
 		return true
 	}
@@ -399,6 +475,8 @@ func (a *Adapter) UpdateIngressLoadBalancer(ingress *Ingress, loadBalancerDNSNam
 	}
 
 	switch ingress.resourceType {
+	case ingressTypeFabric:
+		return updateFabricgatewayLoadBalancer(a.kubeClient, ingress.Namespace, ingress.Name, loadBalancerDNSName)
 	case ingressTypeRouteGroup:
 		return updateRoutegroupLoadBalancer(a.kubeClient, ingress.Namespace, ingress.Name, loadBalancerDNSName)
 	case ingressTypeIngress:
diff --git a/kubernetes/config_map.go b/kubernetes/config_map.go
index fa700ce..6a38ede 100644
--- a/kubernetes/config_map.go
+++ b/kubernetes/config_map.go
@@ -27,19 +27,19 @@ func getConfigMap(c client, namespace, name string) (*configMap, error) {
 
 	r, err := c.get(resource)
 	if err != nil {
-		return nil, fmt.Errorf("failed to get ConfigMap %s/%s: %v", namespace, name, err)
+		return nil, fmt.Errorf("failed to get ConfigMap %s/%s: %w", namespace, name, err)
 	}
 
 	defer r.Close()
 
 	b, err := io.ReadAll(r)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read ConfigMap %s/%s: %v", namespace, name, err)
+		return nil, fmt.Errorf("failed to read ConfigMap %s/%s: %w", namespace, name, err)
 	}
 
 	var result configMap
 	if err := json.Unmarshal(b, &result); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal ConfigMap %s/%s: %v", namespace, name, err)
+		return nil, fmt.Errorf("failed to unmarshal ConfigMap %s/%s: %w", namespace, name, err)
 	}
 
 	return &result, nil
diff --git a/kubernetes/fabric.go b/kubernetes/fabric.go
new file mode 100644
index 0000000..5def97f
--- /dev/null
+++ b/kubernetes/fabric.go
@@ -0,0 +1,103 @@
+package kubernetes
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+)
+
+const (
+	fabricListResource        = "/apis/zalando.org/v1/fabricgateways"
+	fabricNamespacedResource  = "/apis/zalando.org/v1/namespaces/%s/fabricgateways/%s"
+	fabricPatchStatusResource = "/apis/zalando.org/v1/namespaces/%s/fabricgateways/%s/status"
+)
+
+type fabricList struct {
+	Kind       string             `json:"kind"`
+	APIVersion string             `json:"apiVersion"`
+	Metadata   fabricListMetadata `json:"metadata"`
+	Items      []*fabric          `json:"items"`
+}
+
+type fabric struct {
+	Metadata kubeItemMetadata `json:"metadata"`
+	Spec     fabricSpec       `json:"spec"`
+	Status   fabricStatus     `json:"status"`
+}
+
+type fabricListMetadata struct {
+	SelfLink        string `json:"selfLink"`
+	ResourceVersion string `json:"resourceVersion"`
+}
+
+type fabricSpec struct {
+	Service                 []*fabricService               `json:"x-fabric-service"`
+	ExternalServiceProvider *fabricExternalServiceProvider `json:"x-external-service-provider"`
+}
+
+type fabricService struct {
+	Host string `json:"host"`
+}
+type fabricExternalServiceProvider struct {
+	Hosts []string `json:"hosts"`
+}
+
+type fabricStatus struct {
+	LoadBalancer fabricLoadBalancerStatus `json:"loadBalancer"`
+}
+
+type fabricLoadBalancerStatus struct {
+	Fabric []fabricLoadBalancer `json:"fabricgateway"`
+}
+
+type fabricLoadBalancer struct {
+	Hostname string `json:"hostname"`
+}
+
+func listFabricgateways(c client) (*fabricList, error) {
+	r, err := c.get(fabricListResource)
+	if err != nil {
+		return nil, err
+	}
+
+	defer r.Close()
+
+	b, err := io.ReadAll(r)
+	if err != nil {
+		return nil, err
+	}
+
+	var result fabricList
+	if err := json.Unmarshal(b, &result); err != nil {
+		return nil, err
+	}
+
+	return &result, nil
+}
+
+type patchFabricStatus struct {
+	Status fabricStatus `json:"status"`
+}
+
+func updateFabricgatewayLoadBalancer(c client, ns, name, newHostName string) error {
+	patchStatus := patchFabricStatus{
+		Status: fabricStatus{
+			LoadBalancer: fabricLoadBalancerStatus{
+				Fabric: []fabricLoadBalancer{{Hostname: newHostName}},
+			},
+		},
+	}
+
+	resource := fmt.Sprintf(fabricPatchStatusResource, ns, name)
+	payload, err := json.Marshal(patchStatus)
+	if err != nil {
+		return err
+	}
+
+	r, err := c.patch(resource, payload)
+	if err != nil {
+		return fmt.Errorf("failed to patch fabric %s/%s = %q: %w", ns, name, newHostName, err)
+	}
+	defer r.Close()
+	return nil
+}
diff --git a/kubernetes/ingress.go b/kubernetes/ingress.go
index 496111f..e1da9fc 100644
--- a/kubernetes/ingress.go
+++ b/kubernetes/ingress.go
@@ -99,7 +99,7 @@ type ingressClient struct {
 func (ic *ingressClient) listIngress(c client) (*ingressList, error) {
 	r, err := c.get(fmt.Sprintf(ingressListResource, ic.apiVersion))
 	if err != nil {
-		return nil, fmt.Errorf("failed to get ingress list: %v", err)
+		return nil, fmt.Errorf("failed to get ingress list: %w", err)
 	}
 
 	defer r.Close()
@@ -138,7 +138,7 @@ func (ic *ingressClient) updateIngressLoadBalancer(c client, ns, name, newHostNa
 
 	r, err := c.patch(resource, payload)
 	if err != nil {
-		return fmt.Errorf("failed to patch ingress %s/%s = %q: %v", ns, name, newHostName, err)
+		return fmt.Errorf("failed to patch ingress %s/%s = %q: %w", ns, name, newHostName, err)
 	}
 	defer r.Close()
 	return nil
diff --git a/kubernetes/routegroup.go b/kubernetes/routegroup.go
index 5b39066..7575807 100644
--- a/kubernetes/routegroup.go
+++ b/kubernetes/routegroup.go
@@ -88,7 +88,7 @@ func updateRoutegroupLoadBalancer(c client, ns, name, newHostName string) error
 
 	r, err := c.patch(resource, payload)
 	if err != nil {
-		return fmt.Errorf("failed to patch routegroup %s/%s = %q: %v", ns, name, newHostName, err)
+		return fmt.Errorf("failed to patch routegroup %s/%s = %q: %w", ns, name, newHostName, err)
 	}
 	defer r.Close()
 	return nil


Test patch:
diff --git a/kubernetes/adapter_test.go b/kubernetes/adapter_test.go
index 6614807..817a24a 100644
--- a/kubernetes/adapter_test.go
+++ b/kubernetes/adapter_test.go
@@ -488,6 +488,8 @@ func (c *mockClient) get(res string) (io.ReadCloser, error) {
 
 	var fixture string
 	switch res {
+	case fabricListResource:
+		fixture = "testdata/fixture01_fg.json"
 	case routegroupListResource:
 		fixture = "testdata/fixture01_rg.json"
 	case fmt.Sprintf(ingressListResource, IngressAPIVersionNetworking):
@@ -512,6 +514,8 @@ func (c *mockClient) patch(res string, payload []byte) (io.ReadCloser, error) {
 			return io.NopCloser(strings.NewReader(":)")), nil
 		case "/apis/zalando.org/v1/namespaces/default/routegroups/foo/status":
 			return io.NopCloser(strings.NewReader(":)")), nil
+		case "/apis/zalando.org/v1/namespaces/default/fabricgateways/foo/status":
+			return io.NopCloser(strings.NewReader(":)")), nil
 		}
 	}
 	return nil, errors.New("mocked error")
@@ -593,6 +597,35 @@ func TestUpdateRouteGroupLoadBalancer(t *testing.T) {
 	}
 }
 
+func TestUpdateFabricGatewayLoadBalancer(t *testing.T) {
+	a, _ := NewAdapter(testConfig, IngressAPIVersionNetworking, testIngressFilter, testSecurityGroup, testSSLPolicy, aws.LoadBalancerTypeApplication, DefaultClusterLocalDomain, false)
+	client := &mockClient{}
+	a.kubeClient = client
+	ing := &Ingress{
+		Namespace:      "default",
+		Name:           "foo",
+		Hostname:       "bar",
+		CertificateARN: "zbr",
+		resourceType:   ingressTypeFabric,
+	}
+	if err := a.UpdateIngressLoadBalancer(ing, "bar"); err != ErrUpdateNotNeeded {
+		t.Error("expected ErrUpdateNotNeeded")
+	}
+	if err := a.UpdateIngressLoadBalancer(ing, "xpto"); err != nil {
+		t.Error(err)
+	}
+	client.broken = true
+	if err := a.UpdateIngressLoadBalancer(ing, "xpto"); err == nil {
+		t.Error("expected an error")
+	}
+	if err := a.UpdateIngressLoadBalancer(ing, ""); err == nil {
+		t.Error("expected an error")
+	}
+	if err := a.UpdateIngressLoadBalancer(nil, "xpto"); err == nil {
+		t.Error("expected an error")
+	}
+}
+
 func TestBrokenConfig(t *testing.T) {
 	for _, test := range []struct {
 		name string
@@ -651,6 +684,9 @@ func TestListIngressFilterClass(t *testing.T) {
 				"fixture-rg01",
 				"fixture-rg02",
 				"fixture-rg03",
+				"fixture-fg01",
+				"fixture-fg02",
+				"fixture-fg03",
 			},
 		},
 		"emptyIngressClassFilters2": {
@@ -664,6 +700,9 @@ func TestListIngressFilterClass(t *testing.T) {
 				"fixture-rg01",
 				"fixture-rg02",
 				"fixture-rg03",
+				"fixture-fg01",
+				"fixture-fg02",
+				"fixture-fg03",
 			},
 		},
 		"singleIngressClass1": {
@@ -671,6 +710,7 @@ func TestListIngressFilterClass(t *testing.T) {
 			expectedIngressNames: []string{
 				"fixture02",
 				"fixture-rg02",
+				"fixture-fg02",
 			},
 		},
 		"singleIngressClass2": {
@@ -678,6 +718,7 @@ func TestListIngressFilterClass(t *testing.T) {
 			expectedIngressNames: []string{
 				"fixture03",
 				"fixture-rg03",
+				"fixture-fg03",
 			},
 		},
 		"multipleIngressClass": {
@@ -687,6 +728,8 @@ func TestListIngressFilterClass(t *testing.T) {
 				"fixture03",
 				"fixture-rg02",
 				"fixture-rg03",
+				"fixture-fg02",
+				"fixture-fg03",
 			},
 		},
 		"multipleIngressClassWithDefault": {
@@ -696,6 +739,8 @@ func TestListIngressFilterClass(t *testing.T) {
 				"fixture02",
 				"fixture-rg01",
 				"fixture-rg02",
+				"fixture-fg01",
+				"fixture-fg02",
 			},
 		},
 		"multipleIngressClassWithDefault2": {
@@ -705,6 +750,8 @@ func TestListIngressFilterClass(t *testing.T) {
 				"fixture03",
 				"fixture-rg01",
 				"fixture-rg03",
+				"fixture-fg01",
+				"fixture-fg03",
 			},
 		},
 		"multipleIngressClassMixedAnnotationAndSpec": {
@@ -712,6 +759,7 @@ func TestListIngressFilterClass(t *testing.T) {
 			expectedIngressNames: []string{
 				"fixture03",
 				"fixture-rg03",
+				"fixture-fg03",
 				"fixture04",
 			},
 		},
diff --git a/kubernetes/fabric_test.go b/kubernetes/fabric_test.go
new file mode 100644
index 0000000..ef6ee5d
--- /dev/null
+++ b/kubernetes/fabric_test.go
@@ -0,0 +1,180 @@
+package kubernetes
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestListFabricgateways(t *testing.T) {
+	testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		f, _ := os.Open("testdata/fixture01_fg.json")
+		defer f.Close()
+		rw.WriteHeader(http.StatusOK)
+		io.Copy(rw, f)
+	}))
+	defer testServer.Close()
+	kubeClient, _ := newSimpleClient(&Config{BaseURL: testServer.URL}, false)
+	want := newFabricgatewayList(
+		newFabricgateway("fixture-fg01", nil, "example.org", "fixture-fg01"),
+		newFabricgateway("fixture-fg02", map[string]string{ingressClassAnnotation: "skipper"}, "skipper.example.org", "fixture-fg02"),
+		newFabricgateway("fixture-fg03", map[string]string{ingressClassAnnotation: "other"}, "other.example.org", "fixture-fg03"),
+	)
+	got, err := listFabricgateways(kubeClient)
+	if err != nil {
+		t.Errorf("unexpected error from listFabricgateways: %v", err)
+	} else {
+		if !cmp.Equal(got, want) {
+			t.Errorf("unexpected result from listFabricgateways. %v", cmp.Diff(want, got))
+		}
+	}
+}
+
+func TestListFabricgatewayFailureScenarios(t *testing.T) {
+	for _, test := range []struct {
+		statusCode int
+		body       string
+	}{
+		{http.StatusInternalServerError, "{}"},
+		{http.StatusOK, "`"},
+	} {
+		t.Run(fmt.Sprintf("%v", test.statusCode), func(t *testing.T) {
+			testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+				rw.WriteHeader(test.statusCode)
+				fmt.Fprintln(rw, test.body)
+			}))
+			defer testServer.Close()
+			cfg := &Config{BaseURL: testServer.URL}
+			kubeClient, _ := newSimpleClient(cfg, false)
+
+			_, err := listFabricgateways(kubeClient)
+			if err == nil {
+				t.Error("expected an error but list fabric call succeeded")
+			}
+		})
+	}
+}
+
+func TestUpdateFabricgatewayLoadBalancer(t *testing.T) {
+	expectedContentType := map[string]bool{
+		"application/json-patch+json":            true,
+		"application/merge-patch+json":           true,
+		"application/strategic-merge-patch+json": true,
+	}
+	testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		if req.URL.Path != fmt.Sprintf(fabricPatchStatusResource, "foo", "bar") {
+			t.Error("unexpected URL path sent by the client", req.URL.Path)
+		}
+		if req.Method != "PATCH" {
+			t.Error("unexpected HTTP method. Wanted PATCH but got", req.Method)
+		}
+		ct := req.Header.Get("Content-Type")
+		if !expectedContentType[ct] {
+			t.Error("unexpected content type", ct)
+		}
+		b, err := io.ReadAll(req.Body)
+		if err != nil {
+			t.Error(err)
+		}
+		got := string(b)
+		expected := `{"status":{"loadBalancer":{"fabricgateway":[{"hostname":"example.org"}]}}}`
+		if got != expected {
+			t.Errorf("unexpected request body. Wanted %s but got %s", expected, got)
+		}
+		rw.WriteHeader(http.StatusOK)
+	}))
+	defer testServer.Close()
+	cfg := &Config{BaseURL: testServer.URL}
+	kubeClient, _ := newSimpleClient(cfg, false)
+
+	if err := updateFabricgatewayLoadBalancer(kubeClient, "foo", "bar", "example.org"); err != nil {
+		t.Error("unexpected result from update call:", err)
+	}
+}
+
+func TestUpdateFabricgatewayFailureScenarios(t *testing.T) {
+	testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		rw.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer testServer.Close()
+	cfg := &Config{BaseURL: testServer.URL}
+	kubeClient, _ := newSimpleClient(cfg, false)
+	for _, test := range []struct {
+		fg *fabric
+	}{
+		{newFabricgateway("foo", nil, "example.org", "")},
+	} {
+		arn := getAnnotationsString(test.fg.Metadata.Annotations, ingressCertificateARNAnnotation, "<missing>")
+		t.Run(fmt.Sprintf("%v/%v", test.fg.Status.LoadBalancer.Fabric[0].Hostname, arn), func(t *testing.T) {
+			err := updateRoutegroupLoadBalancer(kubeClient, test.fg.Metadata.Namespace, test.fg.Metadata.Name, "example.com")
+			if err == nil {
+				t.Error("expected an error but update routegroup call succeeded")
+			}
+		})
+	}
+}
+
+func TestFabricgatewayAnnotationsFallback(t *testing.T) {
+	have := &fabric{Metadata: kubeItemMetadata{Annotations: map[string]string{"foo": "bar"}}}
+	for _, test := range []struct {
+		key      string
+		fallback string
+		want     string
+	}{
+		{"foo", "zbr", "bar"},
+		{"missing", "fallback", "fallback"},
+	} {
+		t.Run(fmt.Sprintf("%s/%s/%s", test.key, test.want, test.fallback), func(t *testing.T) {
+			if got := getAnnotationsString(have.Metadata.Annotations, test.key, test.fallback); got != test.want {
+				t.Errorf("unexpected metadata value. wanted %q, got %q", test.want, got)
+			}
+		})
+	}
+}
+
+func newFabricgatewayList(fgs ...*fabric) *fabricList {
+	ret := fabricList{
+		APIVersion: "zalando.org/v1",
+		Kind:       "FabricgatewayList",
+		Metadata: fabricListMetadata{
+			SelfLink:        fabricListResource,
+			ResourceVersion: "42",
+		},
+		Items: fgs,
+	}
+	return &ret
+}
+
+func newFabricgateway(name string, annotations map[string]string, hostname string, arn string) *fabric {
+	ret := fabric{
+		Metadata: kubeItemMetadata{
+			Name:              name,
+			Namespace:         "default",
+			Annotations:       annotations,
+			ResourceVersion:   "42",
+			SelfLink:          fmt.Sprintf(fabricNamespacedResource, "default", name),
+			Generation:        1,
+			UID:               name,
+			CreationTimestamp: time.Date(2016, 11, 29, 14, 53, 42, 0, time.UTC),
+		},
+	}
+	if arn != "" {
+		if annotations == nil {
+			ret.Metadata.Annotations = map[string]string{ingressCertificateARNAnnotation: arn}
+		} else {
+			ret.Metadata.Annotations[ingressCertificateARNAnnotation] = arn
+		}
+	}
+	if hostname != "" {
+		ret.Status.LoadBalancer = fabricLoadBalancerStatus{
+			Fabric: []fabricLoadBalancer{{Hostname: hostname}},
+		}
+	}
+	return &ret
+}
diff --git a/kubernetes/testdata/fixture01_fg.json b/kubernetes/testdata/fixture01_fg.json
new file mode 100644
index 0000000..abd00b7
--- /dev/null
+++ b/kubernetes/testdata/fixture01_fg.json
@@ -0,0 +1,75 @@
+{
+  "kind": "FabricgatewayList",
+  "apiVersion": "zalando.org/v1",
+  "metadata": {
+    "selfLink": "/apis/zalando.org/v1/fabricgateways",
+    "resourceVersion": "42"
+  },
+  "items": [
+    {
+      "metadata": {
+        "name": "fixture-fg01",
+        "namespace": "default",
+        "selfLink": "/apis/zalando.org/v1/namespaces/default/fabricgateways/fixture-fg01",
+        "uid": "fixture-fg01",
+        "resourceVersion": "42",
+        "generation": 1,
+        "creationTimestamp": "2016-11-29T14:53:42Z",
+        "annotations": {
+          "zalando.org/aws-load-balancer-ssl-cert": "fixture-fg01"
+        }
+      },
+      "status": {
+        "loadBalancer": {
+          "fabricgateway": [
+            {"hostname": "example.org"}
+          ]
+        }
+      }
+    },
+    {
+      "metadata": {
+        "name": "fixture-fg02",
+        "namespace": "default",
+        "selfLink": "/apis/zalando.org/v1/namespaces/default/fabricgateways/fixture-fg02",
+        "uid": "fixture-fg02",
+        "resourceVersion": "42",
+        "generation": 1,
+        "creationTimestamp": "2016-11-29T14:53:42Z",
+        "annotations": {
+          "zalando.org/aws-load-balancer-ssl-cert": "fixture-fg02",
+          "kubernetes.io/ingress.class": "skipper"
+        }
+      },
+      "status": {
+        "loadBalancer": {
+          "fabricgateway": [
+            {"hostname": "skipper.example.org"}
+          ]
+        }
+      }
+    },
+    {
+      "metadata": {
+        "name": "fixture-fg03",
+        "namespace": "default",
+        "selfLink": "/apis/zalando.org/v1/namespaces/default/fabricgateways/fixture-fg03",
+        "uid": "fixture-fg03",
+        "resourceVersion": "42",
+        "generation": 1,
+        "creationTimestamp": "2016-11-29T14:53:42Z",
+        "annotations": {
+          "zalando.org/aws-load-balancer-ssl-cert": "fixture-fg03",
+          "kubernetes.io/ingress.class": "other"
+        }
+      },
+      "status": {
+        "loadBalancer": {
+          "fabricgateway": [
+            {"hostname": "other.example.org"}
+          ]
+        }
+      }
+    }
+  ]
+}