├── .gitignore ├── helm ├── Chart.yaml ├── values.yaml └── templates │ ├── 00_helpers.tpl │ ├── 01_rbac.yaml │ └── 02_deployment.yaml ├── Dockerfile ├── .circleci └── config.yml ├── LICENSE ├── go.mod ├── cmd └── ingress-merge │ └── main.go ├── README.md └── controller.go /.gitignore: -------------------------------------------------------------------------------- 1 | /go.sum 2 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: ingress-merge 3 | version: 0.1-alpha1 4 | description: Merge Ingress Controller combines multiple ingress resorces into a new one. 5 | keywords: 6 | - ingress 7 | home: https://github.com/jakubkulhan/ingress-merge 8 | maintainers: 9 | - name: Jakub Kulhan 10 | email: jakub.kulhan@gmail.com 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11 2 | 3 | COPY . /src 4 | 5 | RUN set -ex \ 6 | && cd /src \ 7 | && CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w -extldflags "-static"' -o /bin/ingress-merge ./cmd/ingress-merge 8 | 9 | FROM scratch 10 | COPY --from=0 /bin/ingress-merge /ingress-merge 11 | ENTRYPOINT ["/ingress-merge"] 12 | CMD ["--logtostderr"] 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang 6 | working_directory: /go/src/github.com/jakubkulhan/ingress-merge 7 | steps: 8 | - checkout 9 | - setup_remote_docker 10 | - run: 11 | command: | 12 | docker build -t jakubkulhan/ingress-merge:latest . 13 | if [ "$CIRCLE_BRANCH" == "master" ]; then 14 | docker login --username $DOCKER_USERNAME --password $DOCKER_PASSWORD 15 | docker push jakubkulhan/ingress-merge:latest 16 | fi 17 | workflows: 18 | version: 2 19 | wf: 20 | jobs: 21 | - build 22 | -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Ingress-class annotation to manage 2 | ingressClass: merge 3 | 4 | # Label selector for ConfigMap objects to be monitored for changes 5 | # e.g. "merge.ingress.kubernetes.io=owned" 6 | configMapSelector: "" 7 | 8 | # Label selector for Ingress objects to be monitored for changes 9 | ingressSelector: "" 10 | 11 | # List of annotations that will cause a ConfigMap to be ignored if present 12 | # e.g. "control-plane.alpha.kubernetes.io/leader" 13 | configMapWatchIgnore: [] 14 | 15 | # List of annotations that will cause an Ingress to be ignored if present 16 | ingressWatchIgnore: [] 17 | 18 | rbac: 19 | create: true 20 | serviceAccountName: default 21 | 22 | image: 23 | repository: jakubkulhan/ingress-merge 24 | tag: latest 25 | pullPolicy: Always 26 | 27 | resources: {} 28 | 29 | nodeSelector: {} 30 | 31 | tolerations: [] 32 | 33 | affinity: {} 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Jakub Kulhan 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /helm/templates/00_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "ingress-merge.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "ingress-merge.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "ingress-merge.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /helm/templates/01_rbac.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.rbac.create }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "ingress-merge.fullname" . }} 6 | labels: 7 | app: {{ include "ingress-merge.name" . }} 8 | chart: {{ include "ingress-merge.chart" . }} 9 | heritage: {{ .Release.Service }} 10 | release: {{ .Release.Name }} 11 | --- 12 | apiVersion: rbac.authorization.k8s.io/v1beta1 13 | kind: ClusterRole 14 | metadata: 15 | name: {{ include "ingress-merge.fullname" . }} 16 | labels: 17 | app: {{ include "ingress-merge.name" . }} 18 | chart: {{ include "ingress-merge.chart" . }} 19 | heritage: {{ .Release.Service }} 20 | release: {{ .Release.Name }} 21 | rules: 22 | - apiGroups: 23 | - "" 24 | resources: 25 | - configmaps 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - apiGroups: 31 | - extensions 32 | resources: 33 | - ingresses 34 | - ingresses/status 35 | verbs: 36 | - get 37 | - list 38 | - watch 39 | - create 40 | - update 41 | - patch 42 | - delete 43 | --- 44 | apiVersion: rbac.authorization.k8s.io/v1beta1 45 | kind: ClusterRoleBinding 46 | metadata: 47 | name: {{ include "ingress-merge.fullname" . }} 48 | labels: 49 | app: {{ include "ingress-merge.name" . }} 50 | chart: {{ include "ingress-merge.chart" . }} 51 | heritage: {{ .Release.Service }} 52 | release: {{ .Release.Name }} 53 | roleRef: 54 | apiGroup: rbac.authorization.k8s.io 55 | kind: ClusterRole 56 | name: {{ include "ingress-merge.fullname" . }} 57 | subjects: 58 | - kind: ServiceAccount 59 | name: {{ include "ingress-merge.fullname" . }} 60 | namespace: {{ .Release.Namespace }} 61 | {{ end }} 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jakubkulhan/ingress-merge 2 | 3 | require ( 4 | github.com/davecgh/go-spew v1.1.1 // indirect 5 | github.com/ghodss/yaml v1.0.0 6 | github.com/gogo/protobuf v1.1.1 // indirect 7 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b 8 | github.com/golang/groupcache v0.0.0-20180924190550-6f2cf27854a4 // indirect 9 | github.com/golang/protobuf v1.2.0 // indirect 10 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect 11 | github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect 12 | github.com/googleapis/gnostic v0.2.0 // indirect 13 | github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect 14 | github.com/hashicorp/golang-lru v0.5.0 // indirect 15 | github.com/imdario/mergo v0.3.6 // indirect 16 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 17 | github.com/json-iterator/go v1.1.5 // indirect 18 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 19 | github.com/modern-go/reflect2 v1.0.1 // indirect 20 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 21 | github.com/pkg/errors v0.8.1 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | github.com/spf13/cobra v0.0.3 24 | github.com/spf13/pflag v1.0.2 // indirect 25 | github.com/stretchr/testify v1.2.2 // indirect 26 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b // indirect 27 | golang.org/x/net v0.0.0-20180921000356-2f5d2388922f // indirect 28 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f // indirect 29 | golang.org/x/sys v0.0.0-20180920110915-d641721ec2de // indirect 30 | golang.org/x/text v0.3.0 // indirect 31 | golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2 // indirect 32 | gopkg.in/inf.v0 v0.9.1 // indirect 33 | gopkg.in/yaml.v2 v2.2.1 // indirect 34 | k8s.io/api v0.0.0-20180913155108-f456898a08e4 35 | k8s.io/apimachinery v0.0.0-20180621070125-103fd098999d 36 | k8s.io/client-go v8.0.0+incompatible 37 | ) 38 | -------------------------------------------------------------------------------- /helm/templates/02_deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1beta2 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "ingress-merge.fullname" . }} 5 | labels: 6 | app: {{ include "ingress-merge.name" . }} 7 | chart: {{ include "ingress-merge.chart" . }} 8 | heritage: {{ .Release.Service }} 9 | release: {{ .Release.Name }} 10 | spec: 11 | replicas: 1 12 | strategy: 13 | type: Recreate 14 | selector: 15 | matchLabels: 16 | app: {{ include "ingress-merge.name" . }} 17 | release: {{ .Release.Name }} 18 | template: 19 | metadata: 20 | labels: 21 | app: {{ include "ingress-merge.name" . }} 22 | release: {{ .Release.Name }} 23 | spec: 24 | serviceAccountName: {{ if .Values.rbac.create }}{{ include "ingress-merge.fullname" . }}{{ else }}"{{ .Values.rbac.serviceAccountName }}"{{ end }} 25 | containers: 26 | - name: {{ .Chart.Name }} 27 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 28 | imagePullPolicy: {{ .Values.image.pullPolicy }} 29 | args: 30 | - --logtostderr 31 | - --ingress-class={{ .Values.ingressClass }} 32 | {{- if .Values.configMapSelector }} 33 | - --configmap-selector={{ .Values.configMapSelector }}{{ end }} 34 | {{- if .Values.ingressSelector }} 35 | - --ingress-selector={{ .Values.ingressSelector }}{{ end }} 36 | {{- range .Values.configMapWatchIgnore }} 37 | - --configmap-watch-ignore={{ . }}{{ end }} 38 | {{- range .Values.ingressWatchIgnore }} 39 | - --ingress-watch-ignore={{ . }}{{ end }} 40 | resources: 41 | {{ toYaml .Values.resources | indent 12 }} 42 | {{- with .Values.nodeSelector }} 43 | nodeSelector: 44 | {{ toYaml . | indent 8 }} 45 | {{- end }} 46 | {{- with .Values.affinity }} 47 | affinity: 48 | {{ toYaml . | indent 8 }} 49 | {{- end }} 50 | {{- with .Values.tolerations }} 51 | tolerations: 52 | {{ toYaml . | indent 8 }} 53 | {{- end }} 54 | -------------------------------------------------------------------------------- /cmd/ingress-merge/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | goflag "flag" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/golang/glog" 12 | "github.com/jakubkulhan/ingress-merge" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func main() { 17 | rootCmd := &cobra.Command{ 18 | Use: os.Args[0], 19 | Short: "Merge Ingress Controller", 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | var ( 22 | err error 23 | controller = ingress_merge.NewController() 24 | ) 25 | 26 | if controller.MasterURL, err = cmd.Flags().GetString("apiserver"); err != nil { 27 | return err 28 | } 29 | 30 | if controller.KubeconfigPath, err = cmd.Flags().GetString("kubeconfig"); err != nil { 31 | return err 32 | } 33 | 34 | if controller.IngressClass, err = cmd.Flags().GetString("ingress-class"); err != nil { 35 | return err 36 | } 37 | 38 | if controller.IngressSelector, err = cmd.Flags().GetString("ingress-selector"); err != nil { 39 | return err 40 | } 41 | 42 | if controller.ConfigMapSelector, err = cmd.Flags().GetString("configmap-selector"); err != nil { 43 | return err 44 | } 45 | 46 | if controller.IngressWatchIgnore, err = cmd.Flags().GetStringArray("ingress-watch-ignore"); err != nil { 47 | return err 48 | } 49 | 50 | if controller.ConfigMapWatchIgnore, err = cmd.Flags().GetStringArray("configmap-watch-ignore"); err != nil { 51 | return err 52 | } 53 | 54 | ctx, cancel := context.WithCancel(context.Background()) 55 | interrupts := make(chan os.Signal, 1) 56 | go func() { 57 | select { 58 | case <-interrupts: 59 | cancel() 60 | } 61 | }() 62 | signal.Notify(interrupts, syscall.SIGINT, syscall.SIGTERM) 63 | 64 | glog.Infoln("Starting controller") 65 | 66 | return controller.Run(ctx) 67 | }, 68 | } 69 | 70 | rootCmd.PersistentFlags().String( 71 | "apiserver", 72 | "", 73 | "Address of Kubernetes API server.", 74 | ) 75 | 76 | rootCmd.PersistentFlags().String( 77 | "kubeconfig", 78 | "", 79 | "Path to kubeconfig file.", 80 | ) 81 | 82 | rootCmd.PersistentFlags().AddGoFlagSet(goflag.CommandLine) 83 | goflag.CommandLine.Parse([]string{}) // prevents glog errors 84 | 85 | rootCmd.Flags().String( 86 | "ingress-class", 87 | "merge", 88 | "Process ingress resources with this `kubernetes.io/ingress.class` annotation.", 89 | ) 90 | 91 | rootCmd.Flags().String( 92 | "ingress-selector", 93 | "", 94 | "Process ingress resources with labels matching this selector string.", 95 | ) 96 | 97 | rootCmd.Flags().String( 98 | "configmap-selector", 99 | "", 100 | "Process configmap resources with labels matching this selector string.", 101 | ) 102 | 103 | rootCmd.Flags().StringArray( 104 | "ingress-watch-ignore", 105 | []string{}, 106 | "Ignore ingress resources with matching annotations (can be specified multiple times).", 107 | ) 108 | 109 | rootCmd.Flags().StringArray( 110 | "configmap-watch-ignore", 111 | []string{}, 112 | "Ignore configmap resources with matching annotations (can be specified multiple times).", 113 | ) 114 | 115 | if err := rootCmd.Execute(); err != nil { 116 | log.Fatal(err) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Merge Ingress Controller 2 | 3 | Merge [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) Controller combines multiple ingress 4 | resources into a new one. 5 | 6 | ## Motivation 7 | 8 | Different ingress controllers [behave differently](https://github.com/kubernetes/ingress-nginx/issues/1539#issue-266008311) 9 | when creating _services_/_load balancers_ satisfying the ingress resources of the managed class. Some create single service/LB 10 | for all ingress resources, some merge resources according to hosts or TLS certificates, other create separate service/LB 11 | per ingress resource. 12 | 13 | E.g. AWS ALB Ingress Controller creates a new load balancer for each ingress resource. This can become quite costly. 14 | There is [an issue](https://github.com/kubernetes-sigs/aws-alb-ingress-controller/issues/298) to support reusing ALBs 15 | across ingress resources, however, it won't be implemented anytime soon. 16 | 17 | Merge Ingress Controller allows you to create ingress resources that will be combined together to create a new ingress 18 | resource that will be managed by different controller. 19 | 20 | ## Setup 21 | 22 | Install via [Helm](https://www.helm.sh/): 23 | 24 | ```sh 25 | helm install --namespace kube-system --name ingress-merge ./helm 26 | ``` 27 | 28 | ## Example 29 | 30 | Create multiple ingresses & one config map that will provide parameters for the result ingress: 31 | 32 | ```yaml 33 | apiVersion: extensions/v1beta1 34 | kind: Ingress 35 | metadata: 36 | name: foo-ingress 37 | annotations: 38 | kubernetes.io/ingress.class: merge 39 | merge.ingress.kubernetes.io/config: merged-ingress 40 | spec: 41 | rules: 42 | - host: foo.example.com 43 | http: 44 | paths: 45 | - path: / 46 | backend: 47 | serviceName: foo-svc 48 | servicePort: 80 49 | 50 | --- 51 | apiVersion: extensions/v1beta1 52 | kind: Ingress 53 | metadata: 54 | name: bar-ingress 55 | annotations: 56 | kubernetes.io/ingress.class: merge 57 | merge.ingress.kubernetes.io/config: merged-ingress 58 | spec: 59 | rules: 60 | - host: bar.example.com 61 | http: 62 | paths: 63 | - path: / 64 | backend: 65 | serviceName: bar-svc 66 | servicePort: 80 67 | 68 | --- 69 | apiVersion: v1 70 | kind: ConfigMap 71 | metadata: 72 | name: merged-ingress 73 | data: 74 | annotations: | 75 | kubernetes.io/ingress.class: other 76 | ``` 77 | 78 | Merge Ingress Controller will create new ingress resource named by the config map with rules combined together: 79 | 80 | ```yaml 81 | apiVersion: extensions/v1beta1 82 | kind: Ingress 83 | metadata: 84 | name: merged-ingress 85 | annotations: 86 | kubernetes.io/ingress.class: other 87 | spec: 88 | rules: 89 | - host: bar.example.com 90 | http: 91 | paths: 92 | - path: / 93 | backend: 94 | serviceName: bar-svc 95 | servicePort: 80 96 | 97 | - host: foo.example.com 98 | http: 99 | paths: 100 | - path: / 101 | backend: 102 | serviceName: foo-svc 103 | servicePort: 80 104 | ``` 105 | 106 | ## Annotations 107 | 108 | | Annotation | Default Value | Description | Example | 109 | |------------|---------------|-------------|---------| 110 | | `kubernetes.io/ingress.class` | | Use `merge` for this controller to take over. | `kubernetes.io/ingress.class: merge` | 111 | | `merge.ingress.kubernetes.io/config` | | Name of the [`ConfigMap`](https://kubernetes.io/docs/tutorials/configuration/) resource that will be used to merge this ingress with others. Because ingresses do not support to reference services across namespaces, neither does this reference. All ingresses to be merged, the config map & the result ingress use the same namespace. | `merge.ingress.kubernetes.io/config: merged-ingress` | 112 | | `merge.ingress.kubernetes.io/priority` | `0` | Rules from ingresses with higher priority come in the result ingress rules first. | `merge.ingress.kubernetes.io/priority: 10` | 113 | | `merge.ingress.kubernetes.io/result` | | Marks ingress created by the controller. If all source ingress resources are deleted, this ingress is deleted as well. | `merge.ingress.kubernetes.io/result: "true"` | 114 | 115 | ## Configuration keys 116 | 117 | | Key | Default Value | Description | Example | 118 | |-----|---------------|-------------|---------| 119 | | `name` | _name of the `ConfigMap`_ | Name of the result ingress resource. | `name: my-merged-ingress` | 120 | | `labels` | | YAML/JSON-serialized labels to be applied to the result ingress. | `labels: '{"app": "loadbalancer", "env": "prod"}'` | 121 | | `annotations` | `{"merge.ingress.kubernetes.io/result":"true"}` | YAML/JSON-serialized labels to be applied to the result ingress. `merge.ingress.kubernetes.io/result` with value `true` will be always added to the annotations. | `annotations: '{"kubernetes.io/ingress.class": "alb"}` | 122 | | `backend` | | Default backend for the result ingress (`spec.backend`). Source ingresses **must not** specify default backend (such ingresses won't be merged). | `backend: '{"serviceName": "default-backend-svc", "servicePort": 80}` | 123 | 124 | ## License 125 | 126 | Licensed under MIT license. See `LICENSE` file. 127 | -------------------------------------------------------------------------------- /controller.go: -------------------------------------------------------------------------------- 1 | package ingress_merge 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "reflect" 8 | "sort" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/ghodss/yaml" 13 | "github.com/golang/glog" 14 | "github.com/pkg/errors" 15 | "k8s.io/api/core/v1" 16 | extensionsV1beta1 "k8s.io/api/extensions/v1beta1" 17 | metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/labels" 19 | "k8s.io/client-go/kubernetes" 20 | "k8s.io/client-go/tools/cache" 21 | "k8s.io/client-go/tools/clientcmd" 22 | ) 23 | 24 | const ( 25 | IngressClassAnnotation = "kubernetes.io/ingress.class" 26 | ConfigAnnotation = "merge.ingress.kubernetes.io/config" 27 | PriorityAnnotation = "merge.ingress.kubernetes.io/priority" 28 | ResultAnnotation = "merge.ingress.kubernetes.io/result" 29 | ) 30 | 31 | const ( 32 | NameConfigKey = "name" 33 | LabelsConfigKey = "labels" 34 | AnnotationsConfigKey = "annotations" 35 | BackendConfigKey = "backend" 36 | ) 37 | 38 | type Controller struct { 39 | MasterURL string 40 | KubeconfigPath string 41 | IngressClass string 42 | IngressSelector string 43 | ConfigMapSelector string 44 | IngressWatchIgnore []string 45 | ConfigMapWatchIgnore []string 46 | 47 | client *kubernetes.Clientset 48 | ingressesIndex cache.Indexer 49 | ingressesInformer cache.Controller 50 | configMapsIndex cache.Indexer 51 | configMapsInformer cache.Controller 52 | wakeCh chan struct{} 53 | } 54 | 55 | func NewController() *Controller { 56 | return &Controller{} 57 | } 58 | 59 | func (c *Controller) Run(ctx context.Context) (err error) { 60 | clientConfig, err := clientcmd.BuildConfigFromFlags(c.MasterURL, c.KubeconfigPath) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | c.client, err = kubernetes.NewForConfig(clientConfig) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | childCtx, cancel := context.WithCancel(ctx) 71 | defer func() { 72 | if err != nil { 73 | cancel() 74 | } 75 | }() 76 | 77 | if _, err = labels.Parse(c.IngressSelector); err != nil { 78 | return errors.Wrap(err, "Invalid Ingress selector") 79 | } 80 | 81 | c.ingressesIndex, c.ingressesInformer = cache.NewIndexerInformer( 82 | cache.NewFilteredListWatchFromClient( 83 | c.client.ExtensionsV1beta1().RESTClient(), 84 | "ingresses", 85 | "", 86 | func(options *metaV1.ListOptions) { 87 | options.LabelSelector = c.IngressSelector 88 | duration := int64(math.MaxInt32) 89 | options.TimeoutSeconds = &duration 90 | }), 91 | &extensionsV1beta1.Ingress{}, 92 | 0, 93 | c, 94 | cache.Indexers{}, 95 | ) 96 | 97 | go c.ingressesInformer.Run(childCtx.Done()) 98 | 99 | if _, err = labels.Parse(c.ConfigMapSelector); err != nil { 100 | return errors.Wrap(err, "Invalid ConfigMap selector") 101 | } 102 | 103 | c.configMapsIndex, c.configMapsInformer = cache.NewIndexerInformer( 104 | cache.NewFilteredListWatchFromClient( 105 | c.client.CoreV1().RESTClient(), 106 | "configmaps", 107 | "", 108 | func(options *metaV1.ListOptions) { 109 | options.LabelSelector = c.ConfigMapSelector 110 | duration := int64(math.MaxInt32) 111 | options.TimeoutSeconds = &duration 112 | }), 113 | &v1.ConfigMap{}, 114 | 0, 115 | c, 116 | cache.Indexers{}, 117 | ) 118 | 119 | go c.configMapsInformer.Run(childCtx.Done()) 120 | 121 | glog.Infoln("Waiting for caches to sync") 122 | if !cache.WaitForCacheSync(childCtx.Done(), c.ingressesInformer.HasSynced, c.configMapsInformer.HasSynced) { 123 | return fmt.Errorf("could not sync cache") 124 | } 125 | 126 | if c.IngressSelector != "" { 127 | glog.Infof("Watching Ingress objects matching the following label selector: %v", c.IngressSelector) 128 | } 129 | 130 | if c.ConfigMapSelector != "" { 131 | glog.Infof("Watching ConfigMap objects matching the following label selector: %v", c.ConfigMapSelector) 132 | } 133 | 134 | if len(c.IngressWatchIgnore) > 0 { 135 | glog.Infof("Ignoring Ingress objects with the following annotations: %v", c.IngressWatchIgnore) 136 | } 137 | 138 | if len(c.ConfigMapWatchIgnore) > 0 { 139 | glog.Infof("Ignoring ConfigMap objects with the following annotations: %v", c.ConfigMapWatchIgnore) 140 | } 141 | 142 | c.wakeCh = make(chan struct{}, 1) 143 | 144 | c.Process(childCtx) 145 | 146 | var debounceCh <-chan time.Time 147 | for { 148 | select { 149 | case <-c.wakeCh: 150 | if debounceCh == nil { 151 | debounceCh = time.After(1 * time.Second) 152 | } 153 | case <-debounceCh: 154 | debounceCh = nil 155 | c.Process(childCtx) 156 | case <-ctx.Done(): 157 | return nil 158 | } 159 | } 160 | } 161 | 162 | func (c *Controller) isIgnored(obj interface{}) bool { 163 | 164 | switch object := obj.(type) { 165 | case *extensionsV1beta1.Ingress: 166 | for _, val := range c.IngressWatchIgnore { 167 | if _, exists := object.Annotations[val]; exists { 168 | return true 169 | } 170 | } 171 | case *v1.ConfigMap: 172 | for _, val := range c.ConfigMapWatchIgnore { 173 | if _, exists := object.Annotations[val]; exists { 174 | return true 175 | } 176 | } 177 | default: 178 | return false 179 | } 180 | return false 181 | } 182 | 183 | func (c *Controller) OnAdd(obj interface{}) { 184 | if !c.isIgnored(obj) { 185 | glog.Infof("Watched resource added") 186 | c.wakeUp() 187 | } 188 | } 189 | 190 | func (c *Controller) OnUpdate(oldObj, newObj interface{}) { 191 | if !c.isIgnored(oldObj) || !c.isIgnored(newObj) { 192 | glog.Infof("Watched resource updated") 193 | c.wakeUp() 194 | } 195 | } 196 | 197 | func (c *Controller) OnDelete(obj interface{}) { 198 | if !c.isIgnored(obj) { 199 | glog.Infof("Watched resource deleted") 200 | c.wakeUp() 201 | } 202 | } 203 | 204 | func (c *Controller) wakeUp() { 205 | if c.wakeCh != nil { 206 | c.wakeCh <- struct{}{} 207 | } 208 | } 209 | 210 | func (c *Controller) Process(ctx context.Context) { 211 | glog.Infof("Processing ingress resources") 212 | 213 | var ( 214 | mergeMap = make(map[*v1.ConfigMap][]*extensionsV1beta1.Ingress) 215 | orphaned = make(map[string]*extensionsV1beta1.Ingress) 216 | ) 217 | 218 | for _, ingressIface := range c.ingressesIndex.List() { 219 | ingress := ingressIface.(*extensionsV1beta1.Ingress) 220 | 221 | ingressClass := ingress.Annotations[IngressClassAnnotation] 222 | if ingressClass != c.IngressClass { 223 | if _, exists := ingress.Annotations[ResultAnnotation]; exists { 224 | orphaned[ingress.Namespace+"/"+ingress.Name] = ingress 225 | } 226 | continue 227 | } 228 | 229 | if priorityString, exists := ingress.Annotations[PriorityAnnotation]; exists { 230 | if _, err := strconv.Atoi(priorityString); err != nil { 231 | glog.Errorf( 232 | "Ingress [%s/%s] [%s] annotation must be an integer: %v", 233 | ingress.Namespace, 234 | ingress.Name, 235 | PriorityAnnotation, 236 | err, 237 | ) 238 | // TODO: emit error event on ingress that priority must be integer 239 | 240 | continue 241 | } 242 | } 243 | 244 | configMapName, exists := ingress.Annotations[ConfigAnnotation] 245 | if !exists { 246 | // TODO: emit error event on ingress that no config map name is set 247 | glog.Errorf( 248 | "Ingress [%s/%s] is missing [%s] annotation", 249 | ingress.Namespace, 250 | ingress.Name, 251 | ConfigAnnotation, 252 | ) 253 | continue 254 | } 255 | 256 | configMapIface, exists, _ := c.configMapsIndex.GetByKey(ingress.Namespace + "/" + configMapName) 257 | if !exists { 258 | // TODO: emit error event on ingress that config map does not exist 259 | glog.Errorf( 260 | "Ingress [%s/%s] needs ConfigMap [%s/%s], however it does not exist", 261 | ingress.Namespace, 262 | ingress.Name, 263 | ingress.Namespace, 264 | configMapName, 265 | ) 266 | continue 267 | } 268 | 269 | configMap := configMapIface.(*v1.ConfigMap) 270 | mergeMap[configMap] = append(mergeMap[configMap], ingress) 271 | } 272 | 273 | glog.Infof("Collected %d ingresses to be merged", len(mergeMap)) 274 | 275 | changed := false 276 | 277 | for configMap, ingresses := range mergeMap { 278 | sort.Slice(ingresses, func(i, j int) bool { 279 | var ( 280 | a = ingresses[i] 281 | b = ingresses[j] 282 | priorityA = 0 283 | priorityB = 0 284 | ) 285 | 286 | if priorityString, exits := a.Annotations[PriorityAnnotation]; exits { 287 | priorityA, _ = strconv.Atoi(priorityString) 288 | } 289 | 290 | if priorityString, exits := b.Annotations[PriorityAnnotation]; exits { 291 | priorityB, _ = strconv.Atoi(priorityString) 292 | } 293 | 294 | if priorityA > priorityB { 295 | return true 296 | } else if priorityA < priorityB { 297 | return false 298 | } else { 299 | return a.Name < b.Name 300 | } 301 | }) 302 | 303 | var ( 304 | ownerReferences []metaV1.OwnerReference 305 | tls []extensionsV1beta1.IngressTLS 306 | rules []extensionsV1beta1.IngressRule 307 | ) 308 | 309 | for _, ingress := range ingresses { 310 | ownerReferences = append(ownerReferences, metaV1.OwnerReference{ 311 | APIVersion: "extensions/v1beta1", 312 | Kind: "Ingress", 313 | Name: ingress.Name, 314 | UID: ingress.UID, 315 | }) 316 | 317 | // FIXME: merge by SecretName/Hosts? 318 | for _, t := range ingress.Spec.TLS { 319 | tls = append(tls, t) 320 | } 321 | 322 | rules: 323 | for _, r := range ingress.Spec.Rules { 324 | for _, s := range rules { 325 | if r.Host == s.Host { 326 | for _, path := range r.HTTP.Paths { 327 | s.HTTP.Paths = append(s.HTTP.Paths, path) 328 | } 329 | continue rules 330 | } 331 | } 332 | 333 | rules = append(rules, *r.DeepCopy()) 334 | } 335 | } 336 | 337 | var ( 338 | name string 339 | labels map[string]string 340 | annotations map[string]string 341 | backend *extensionsV1beta1.IngressBackend 342 | ) 343 | 344 | if dataName, exists := configMap.Data[NameConfigKey]; exists { 345 | name = dataName 346 | } else { 347 | name = configMap.Name 348 | } 349 | 350 | if dataLabels, exists := configMap.Data[LabelsConfigKey]; exists { 351 | if err := yaml.Unmarshal([]byte(dataLabels), &labels); err != nil { 352 | labels = nil 353 | glog.Errorf("Could unmarshal [%s] from ConfigMap [%s/%s]: %v", LabelsConfigKey, configMap.Namespace, configMap.Name, err) 354 | } 355 | } 356 | 357 | if dataAnnotations, exists := configMap.Data[AnnotationsConfigKey]; exists { 358 | if err := yaml.Unmarshal([]byte(dataAnnotations), &annotations); err != nil { 359 | annotations = nil 360 | glog.Errorf("Could not unmarshal [%s] from config [%s/%s]: %v", AnnotationsConfigKey, configMap.Namespace, configMap.Name, err) 361 | } 362 | 363 | if annotations[IngressClassAnnotation] == c.IngressClass { 364 | glog.Errorf( 365 | "Config [%s/%s] trying to create merged ingress of merge ingress class [%s], you have to change [%s] annotation value", 366 | configMap.Namespace, 367 | configMap.Name, 368 | c.IngressClass, 369 | IngressClassAnnotation, 370 | ) 371 | continue 372 | } 373 | } 374 | 375 | if annotations == nil { 376 | annotations = make(map[string]string) 377 | } 378 | annotations[ResultAnnotation] = "true" 379 | 380 | if dataBackend, exists := configMap.Data[BackendConfigKey]; exists { 381 | if err := yaml.Unmarshal([]byte(dataBackend), &backend); err != nil { 382 | backend = nil 383 | glog.Errorf("Could not unmarshal [%s] from config [%s/%s]: %v", BackendConfigKey, configMap.Namespace, configMap.Name, err) 384 | } 385 | } 386 | 387 | mergedIngres := &extensionsV1beta1.Ingress{ 388 | ObjectMeta: metaV1.ObjectMeta{ 389 | Namespace: configMap.Namespace, 390 | Name: name, 391 | Labels: labels, 392 | Annotations: annotations, 393 | OwnerReferences: ownerReferences, 394 | }, 395 | Spec: extensionsV1beta1.IngressSpec{ 396 | Backend: backend, 397 | TLS: tls, 398 | Rules: rules, 399 | }, 400 | } 401 | 402 | if existingMergedIngressIface, exists, _ := c.ingressesIndex.Get(mergedIngres); exists { 403 | existingMergedIngress := existingMergedIngressIface.(*extensionsV1beta1.Ingress) 404 | 405 | if hasIngressChanged(existingMergedIngress, mergedIngres) { 406 | changed = true 407 | ret, err := c.client.ExtensionsV1beta1().Ingresses(mergedIngres.Namespace).Update(mergedIngres) 408 | if err != nil { 409 | glog.Errorf("Could not update ingress [%s/%s]: %v", mergedIngres.Namespace, mergedIngres.Name, err) 410 | continue 411 | } 412 | mergedIngres = ret 413 | glog.Infof("Updated merged ingress [%s/%s]", mergedIngres.Namespace, mergedIngres.Name) 414 | } else { 415 | mergedIngres = existingMergedIngress 416 | } 417 | 418 | } else { 419 | changed = true 420 | ret, err := c.client.ExtensionsV1beta1().Ingresses(mergedIngres.Namespace).Create(mergedIngres) 421 | if err != nil { 422 | glog.Errorf("Could not create ingress [%s/%s]: %v", mergedIngres.Namespace, mergedIngres.Name, err) 423 | continue 424 | } 425 | mergedIngres = ret 426 | glog.Infof("Created merged ingress [%s/%s]", mergedIngres.Namespace, mergedIngres.Name) 427 | } 428 | 429 | delete(orphaned, mergedIngres.Namespace+"/"+mergedIngres.Name) 430 | c.ingressesIndex.Add(mergedIngres) 431 | 432 | for i, ingress := range ingresses { 433 | if reflect.DeepEqual(ingress.Status, mergedIngres.Status) { 434 | continue 435 | } 436 | 437 | mergedIngres.Status.DeepCopyInto(&ingress.Status) 438 | 439 | changed = true 440 | ret, err := c.client.ExtensionsV1beta1().Ingresses(ingress.Namespace).UpdateStatus(ingress) 441 | if err != nil { 442 | glog.Errorf("Could not update status of ingress [%s/%s]: %v", ingress.Namespace, ingress.Name, err) 443 | continue 444 | } 445 | 446 | ingress = ret 447 | ingresses[i] = ret 448 | 449 | glog.Infof( 450 | "Propagated ingress status back from [%s/%s] to [%s/%s]", 451 | mergedIngres.Namespace, 452 | mergedIngres.Name, 453 | ingress.Namespace, 454 | ingress.Name, 455 | ) 456 | } 457 | } 458 | 459 | for _, ingress := range orphaned { 460 | changed = true 461 | err := c.client.ExtensionsV1beta1().Ingresses(ingress.Namespace).Delete(ingress.Name, nil) 462 | if err != nil { 463 | glog.Errorf("Could not delete ingress [%s/%s]: %v", ingress.Namespace, ingress.Name, err) 464 | continue 465 | } 466 | 467 | glog.Infof("Deleted merged ingress [%s/%s]", ingress.Namespace, ingress.Name) 468 | 469 | c.ingressesIndex.Delete(ingress) 470 | } 471 | 472 | if !changed { 473 | glog.Infof("Nothing changed") 474 | } 475 | } 476 | 477 | func hasIngressChanged(old, new *extensionsV1beta1.Ingress) bool { 478 | if new.Namespace != old.Namespace { 479 | return true 480 | } 481 | if new.Name != old.Name { 482 | return true 483 | } 484 | if !reflect.DeepEqual(new.Labels, old.Labels) { 485 | return true 486 | } 487 | if !reflect.DeepEqual(new.Annotations, old.Annotations) { 488 | return true 489 | } 490 | if !reflect.DeepEqual(new.OwnerReferences, old.OwnerReferences) { 491 | return true 492 | } 493 | if !reflect.DeepEqual(new.Spec, old.Spec) { 494 | return true 495 | } 496 | 497 | return false 498 | } 499 | --------------------------------------------------------------------------------