Skip to content

Commit

Permalink
DEVOPS-3378 mutate external dns (#16)
Browse files Browse the repository at this point in the history
* add externalDNSConfig and necessary labels to admission controller, add functional option for externaldns config, add tests for external mutation to controller

* tool and configure the externaldnsconfig for the admission controller into the root command

* remove gateway labelseletor from the informer and check the label value inline during reconciliation

* update skaffold examples to show mutations

* add documentation for mutation

* update cli help strings for clairty

* fix markdown

* refactor external dns mutation logic and add a test case

* update admission controller docs to explain external-dns hostname mutation

* update annotation defaults and doc strings, use annotations defaults, remove nil checks

* ignore swp
  • Loading branch information
whwalter authored Sep 9, 2022
1 parent b8f66b8 commit 87db08b
Show file tree
Hide file tree
Showing 13 changed files with 457 additions and 27 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.licenses
bin/
.vscode
*.swp
70 changes: 68 additions & 2 deletions docs/admission_controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This service runs a mutating webhook on `/mutate`.

## Mutation Logic
## TLS Mutation Logic

- Given a Gateway [labeled](./api/v1beta1.md) for management by the controller.
- Inspect each Server entry.
Expand Down Expand Up @@ -35,4 +35,70 @@ spec:
The mutated object will contain the following `tls.credentialName=default-httpbin-gateway-https`.

Since the `tls.credentialName` is used to name the `Certificate` and `Secret` resources it is subject to the [253 max character limit](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names). The `<namespace>-<gateway-name>` will be truncated accordingly to preserve the `portName`
Since the `tls.credentialName` is used to name the `Certificate` and `Secret` resources it is subject to the [253 max character limit](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names). The `<namespace>-<gateway-name>` will be truncated accordingly to preserve the `portName`

The [Controller](./controllers/gateway.md) is responsible for the reconciliation of the referenced `Certificate` and `Secret` resources.

## External DNS Annotation Mutation Logic

The external-dns mutation feature will remove the external-dns.alpha.kubernetes.io/hostname and remove or mutate external-dns.alpha.kubernetes.io/target annotations from all istio gateway objects.
The hsotname annotation is removed since:
1. external-dns will always use the hosts list from the gateway server block when generating dns entries.
1. istio requires the host on the gateway to route the request properly

- If the controller has external-dns management enabled
- If the namespace the gateway is created in is subject to mutation
- Delete the `external-dns.alpha.kubernetes.io/hostname` annotation if present.
- If `externalDNSConfig.target` is a non-empty value, et the `external-dns.alpha.kubernetes.io/target` value to the
- Else delete the annotation

For example, with --external-dns-target=loadbalancer-vanity.example.com set, the gateway configuration of:

```yaml
---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: httpbin-gateway
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: "tobedeleted.gateway.example.com"
external-dns.alpha.kubernetes.io/target: "anotherhost.example.com"
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 443
name: https
protocol: HTTPS
hosts:
- "default/httpbin.example.com"
tls:
mode: SIMPLE
```

will become:

```yaml
---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: httpbin-gateway
namespace: default
annotations:
external-dns.alpha.kubernetes.io/target: "loadbalancer-vanity.example.com"
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 443
name: https
protocol: HTTPS
hosts:
- "default/httpbin.example.com"
tls:
mode: SIMPLE
```
7 changes: 4 additions & 3 deletions docs/api/v1beta1.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# API - v1beta1

## Gateway
## Server TLS

The gateway controller will only manage certificates for [v1beta1.Gateway](https://pkg.go.dev/istio.io/api/networking/v1beta1#Gateway) resources that are labeled with the following:
The gateway and admission controllers will only mutate TLS and manage certificates for [v1beta1.Gateway](https://pkg.go.dev/istio.io/api/networking/v1beta1#Gateway) resources that are labeled with the following:

```yaml
labels:
"v1beta1.kanopy-platform.github.io/istio-cert-controller-inject-simple-credential-name": "true"
```
When this label is set the controller will take over the TLS.CredentialName and install a certificate according to the default issuer set during [Installation](../installation.md)
A custom [ClusterIssuers](https://pkg.go.dev/github.com/jetstack/cert-manager/pkg/apis/certmanager/v1#ClusterIssuer) installed in your kubernetes cluster may be used per gateway with the annotation:
Expand All @@ -29,4 +30,4 @@ The value of this label will consist of two parts:
```yaml
labels:
v1beta1.kanopy-platform.github.io/istio-cert-controller-managed: name-of-gateway.namespace
```
```
8 changes: 7 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Flags:
--context string The name of the kubeconfig context to use
--default-issuer string The default ClusterIssuer (default "selfsigned")
--dry-run Controller dry-run changes only
--external-dns Enable external-dns mutation support, default: disabled
--external-dns-target Set or delete value for the external-dns target annotation, implies --external-dns, default: delete
--external-dns-selector Annotation key=value selector string to use for excluding namespace from mutation, implies --external-dns, default: ingress-whitelist=*
-h, --help help for kanopy-gateway-cert-controller
--insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure
--kubeconfig string Path to the kubeconfig file to use for CLI requests.
Expand All @@ -45,10 +48,13 @@ As provided within the [example rbac](../examples/k8s/rolebindings.yaml) this co
- Full access to manage certificates within the `--certificate-namespace`
- Ability to create [leases](https://kubernetes.io/docs/reference/kubernetes-api/cluster-resources/lease-v1/) which the controller uses to manage leader election

External-DNS mutatios requires:
- get/list/watch all namespace objects

## Metrics

The service uses port 80 to host prometheus metrics on `/metrics`

## Replicas

A minimum two replicas MAY be run in order to provide fault tolerance. The controller uses leader election to verify that only one replica is active at a time.
A minimum two replicas MAY be run in order to provide fault tolerance. The controller uses leader election to verify that only one replica is active at a time.
1 change: 1 addition & 0 deletions examples/k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ spec:
args:
- "--certificate-namespace=routing"
- "--log-level=debug"
- "--external-dns-target=overthere"
imagePullPolicy: Always
volumeMounts:
- name: webhook-certs
Expand Down
7 changes: 7 additions & 0 deletions examples/k8s/rolebindings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ kind: ClusterRole
metadata:
name: kanopy-gateway-cert-controller
rules:
- apiGroups: [""]
resources:
- namespaces
verbs:
- list
- get
- watch
- apiGroups:
- networking.istio.io
resources:
Expand Down
116 changes: 105 additions & 11 deletions internal/admission/admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"

v1beta1labels "github.com/kanopy-platform/gateway-certificate-controller/pkg/v1beta1/labels"
networkingv1beta1 "istio.io/api/networking/v1beta1"
"istio.io/client-go/pkg/apis/networking/v1beta1"
istioversionedclient "istio.io/client-go/pkg/clientset/versioned"
corev1 "k8s.io/api/core/v1"
corev1listers "k8s.io/client-go/listers/core/v1"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/webhook"
Expand All @@ -21,13 +25,68 @@ const (

type GatewayMutationHook struct {
istioClient istioversionedclient.Interface
nsLister corev1listers.NamespaceLister
decoder *admission.Decoder
externalDNS *ExternalDNSConfig
}

func NewGatewayMutationHook(client istioversionedclient.Interface) *GatewayMutationHook {
//ExternalDNSConfig passes configuration to the external DNS mutation behavior
type ExternalDNSConfig struct {
enabled bool
target string
selector Selector
}

//Selector is a key value pair for matching annotations
type Selector struct {
key string
value string
}

//SetEnabled set the endabled field to a bool value
func (edc *ExternalDNSConfig) SetEnabled(enabled bool) {
edc.enabled = enabled
}

//SetTarget sets the target field to a string value
func (edc *ExternalDNSConfig) SetTarget(target string) {
edc.target = target
}

//SetSelector sets the select field from a string value or returns an error
func (edc *ExternalDNSConfig) SetSelector(target string) error {

v := strings.Split(target, "=")
if len(v) < 2 {
return fmt.Errorf("External DNS annotation selector parse error expected key=value got: %q", target)
}
edc.selector = Selector{
key: v[0],
value: v[1],
}
return nil
}

func NewExternalDNSConfig() *ExternalDNSConfig {
return &ExternalDNSConfig{
selector: Selector{
key: v1beta1labels.DefaultGatewayAllowListAnnotation,
value: v1beta1labels.DefaultGatewayAllowListAnnotationOverrideValue,
},
}
}

func NewGatewayMutationHook(client istioversionedclient.Interface, nsl corev1listers.NamespaceLister, opts ...OptionsFunc) *GatewayMutationHook {

gmh := &GatewayMutationHook{
istioClient: client,
nsLister: nsl,
}

for _, opt := range opts {
opt(gmh)
}

return gmh
}

Expand All @@ -46,7 +105,14 @@ func (g *GatewayMutationHook) Handle(ctx context.Context, req admission.Request)
return admission.Errored(http.StatusBadRequest, err)
}

gateway = mutate(ctx, gateway.DeepCopy())
var ns *corev1.Namespace
if g.externalDNS != nil && g.externalDNS.enabled {
ns, err = g.nsLister.Get(gateway.Namespace)
if err != nil {
log.Error(err, fmt.Sprintf("failed to get namespace: %s", gateway.Namespace))
}
}
gateway = mutate(ctx, gateway.DeepCopy(), g.externalDNS, ns)

jsonGateway, err := json.Marshal(gateway)
if err != nil {
Expand Down Expand Up @@ -76,20 +142,48 @@ func credentialName(ctx context.Context, namespace, name string, portName string
return fmt.Sprintf("%s-%s", prefix, portName)
}

func mutate(ctx context.Context, gateway *v1beta1.Gateway) *v1beta1.Gateway {
func mutate(ctx context.Context, gateway *v1beta1.Gateway, externalDNS *ExternalDNSConfig, ns *corev1.Namespace) *v1beta1.Gateway {
log := log.FromContext(ctx)

for _, s := range gateway.Spec.Servers {
if s.Tls == nil {
continue
}
if externalDNS != nil && externalDNS.enabled {
externalDNS.mutate(ctx, gateway, ns)
}

if s.Tls.Mode == networkingv1beta1.ServerTLSSettings_SIMPLE {
newCredentialName := credentialName(ctx, gateway.Namespace, gateway.Name, s.Port.Name)
log.Info(fmt.Sprintf("mutating gateway %s Tls.CredentialName, %s to %s", gateway.Name, s.Tls.CredentialName, newCredentialName))
s.Tls.CredentialName = newCredentialName
//If we don't have the tls management label or it isn't set to true return
if val, ok := gateway.Labels[v1beta1labels.InjectSimpleCredentialNameLabel]; ok && val == "true" {
for _, s := range gateway.Spec.Servers {
if s.Tls == nil {
continue
}

if s.Tls.Mode == networkingv1beta1.ServerTLSSettings_SIMPLE {
newCredentialName := credentialName(ctx, gateway.Namespace, gateway.Name, s.Port.Name)
log.Info(fmt.Sprintf("mutating gateway %s Tls.CredentialName, %s to %s", gateway.Name, s.Tls.CredentialName, newCredentialName))
s.Tls.CredentialName = newCredentialName
}
}
}

return gateway
}

func (edc *ExternalDNSConfig) mutate(ctx context.Context, gateway *v1beta1.Gateway, ns *corev1.Namespace) {

//If we don't have information about the namespace assume we want to mutate it.
if ns != nil {
// if any host ingress is allowed in the namespace, do no mutation and return
if allowed, ok := ns.Annotations[edc.selector.key]; ok && allowed == edc.selector.value {
return
}
}

// we only allow external-dns to use the hosts key on gateway server entries because those are validated by OPA
delete(gateway.Annotations, v1beta1labels.ExternalDNSHostnameAnnotationKey)

// set the target annotation if we have a target or delete it if we don't
if edc.target != "" {
gateway.Annotations[v1beta1labels.ExternalDNSTargetAnnotationKey] = edc.target
} else {
delete(gateway.Annotations, v1beta1labels.ExternalDNSTargetAnnotationKey)
}
}
Loading

0 comments on commit 87db08b

Please sign in to comment.