diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index ab6b7daf1..7a764acc2 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -176,6 +176,10 @@ "ImportPath": "github.com/juju/ratelimit", "Rev": "5b9ff866471762aa2ab2dced63c9fb6f53921342" }, + { + "ImportPath": "github.com/kylelemons/godebug/diff", + "Rev": "d65d576e9348f5982d7f6d83682b694e731a45c6" + }, { "ImportPath": "github.com/mailru/easyjson/buffer", "Rev": "d5b7844b561a7bc640052f1b935f7b800330d7e0" diff --git a/pkg/common/ingress/controller/controller.go b/pkg/common/ingress/controller/controller.go index be958949c..b3abd9d7d 100644 --- a/pkg/common/ingress/controller/controller.go +++ b/pkg/common/ingress/controller/controller.go @@ -154,6 +154,8 @@ type Configuration struct { UpdateStatusOnShutdown bool SortBackends bool + + V07 bool } // newIngressController creates an Ingress controller @@ -204,6 +206,11 @@ func newIngressController(config *Configuration) *GenericController { return &ic } +// GetConfig expose the controller configuration +func (ic *GenericController) GetConfig() *Configuration { + return ic.cfg +} + // Info returns information about the backend func (ic GenericController) Info() *ingress.BackendInfo { return ic.cfg.Backend.Info() @@ -262,6 +269,11 @@ func (ic *GenericController) syncIngress(item interface{}) error { return nil } + if !ic.cfg.V07 { + ic.cfg.Backend.SyncIngress(item) + return nil + } + // force reload of default backend data // see GetDefaultBackend() ic.defaultBackend = nil diff --git a/pkg/common/ingress/controller/launch.go b/pkg/common/ingress/controller/launch.go index 037efa948..403696ba5 100644 --- a/pkg/common/ingress/controller/launch.go +++ b/pkg/common/ingress/controller/launch.go @@ -119,6 +119,9 @@ func NewIngressController(backend ingress.Controller) *GenericController { useNodeInternalIP = flags.Bool("report-node-internal-ip-address", false, `Defines if the nodes IP address to be returned in the ingress status should be the internal instead of the external IP address`) + v07 = flags.Bool("v07-controller", true, + `Defines if legacy v07 controller code should be used`) + showVersion = flags.Bool("version", false, `Shows release information about the NGINX Ingress controller`) ) @@ -252,6 +255,7 @@ func NewIngressController(backend ingress.Controller) *GenericController { UpdateStatusOnShutdown: *updateStatusOnShutdown, SortBackends: *sortBackends, UseNodeInternalIP: *useNodeInternalIP, + V07: *v07, } ic := newIngressController(config) diff --git a/pkg/common/ingress/types.go b/pkg/common/ingress/types.go index f6e8e8012..58bd77030 100644 --- a/pkg/common/ingress/types.go +++ b/pkg/common/ingress/types.go @@ -92,6 +92,8 @@ type Controller interface { // The backend returns an error if was not possible to update the configuration. // OnUpdate(Configuration) error + // SyncIngress sync load balancer config from a very early stage + SyncIngress(item interface{}) error // ConfigMap content of --configmap SetConfig(*apiv1.ConfigMap) // SetListers allows the access of store listers present in the generic controller diff --git a/pkg/controller/cache.go b/pkg/controller/cache.go new file mode 100644 index 000000000..eb7c9cff1 --- /dev/null +++ b/pkg/controller/cache.go @@ -0,0 +1,59 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + "strings" + + api "k8s.io/api/core/v1" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress" +) + +type cache struct { + listers *ingress.StoreLister +} + +func (c *cache) GetService(serviceName string) (*api.Service, error) { + return c.listers.Service.GetByName(serviceName) +} + +func (c *cache) GetEndpoints(service *api.Service) (*api.Endpoints, error) { + ep, err := c.listers.Endpoint.GetServiceEndpoints(service) + return &ep, err +} + +func (c *cache) GetPod(podName string) (*api.Pod, error) { + sname := strings.Split(podName, "/") + if len(sname) != 2 { + return nil, fmt.Errorf("invalid pod name: '%s'", podName) + } + return c.listers.Pod.GetPod(sname[0], sname[1]) +} + +func (c *cache) GetTLSSecretPath(secretName string) (string, error) { + return "", fmt.Errorf("implement") +} + +func (c *cache) GetCASecretPath(secretName string) (string, error) { + return "", fmt.Errorf("implement") +} + +func (c *cache) GetSecretContent(secretName, keyName string) ([]byte, error) { + return []byte{}, fmt.Errorf("implement") +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 413af07b5..e6e18e68f 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -17,30 +17,39 @@ limitations under the License. package controller import ( - "github.com/golang/glog" - "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress" - "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/controller" - "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/defaults" - "github.com/jcmoraisjr/haproxy-ingress/pkg/controller/dynconfig" - "github.com/jcmoraisjr/haproxy-ingress/pkg/types" - "github.com/jcmoraisjr/haproxy-ingress/pkg/version" - "github.com/spf13/pflag" "io/ioutil" - api "k8s.io/api/core/v1" - extensions "k8s.io/api/extensions/v1beta1" "net/http" "os" "os/exec" "sort" "strings" "time" + + "github.com/golang/glog" + "github.com/spf13/pflag" + api "k8s.io/api/core/v1" + extensions "k8s.io/api/extensions/v1beta1" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress" + "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/annotations/class" + "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/controller" + "github.com/jcmoraisjr/haproxy-ingress/pkg/common/ingress/defaults" + "github.com/jcmoraisjr/haproxy-ingress/pkg/controller/dynconfig" + ingressconverter "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress" + ingtypes "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/version" ) // HAProxyController has internal data of a HAProxyController instance type HAProxyController struct { + instance haproxy.Instance controller *controller.GenericController + cfg *controller.Configuration configMap *api.ConfigMap storeLister *ingress.StoreLister + converterOptions *ingtypes.ConverterOptions command string reloadStrategy *string configDir string @@ -59,7 +68,7 @@ func NewHAProxyController() *HAProxyController { } // Info provides controller name and repository infos -func (haproxy *HAProxyController) Info() *ingress.BackendInfo { +func (hc *HAProxyController) Info() *ingress.BackendInfo { return &ingress.BackendInfo{ Name: "HAProxy", Release: version.RELEASE, @@ -69,52 +78,78 @@ func (haproxy *HAProxyController) Info() *ingress.BackendInfo { } // Start starts the controller -func (haproxy *HAProxyController) Start() { - haproxy.controller = controller.NewIngressController(haproxy) - if *haproxy.reloadStrategy == "multibinder" { +func (hc *HAProxyController) Start() { + hc.controller = controller.NewIngressController(hc) + hc.configController() + hc.controller.Start() +} + +func (hc *HAProxyController) configController() { + if *hc.reloadStrategy == "multibinder" { glog.Warningf("multibinder is deprecated, using reusesocket strategy instead. update your deployment configuration") } - haproxy.controller.Start() + hc.cfg = hc.controller.GetConfig() + + if hc.cfg.V07 { + return + } + + // starting v0.8 only config + logger := &logger{depth: 1} + hc.converterOptions = &ingtypes.ConverterOptions{ + Logger: logger, + Cache: &cache{listers: hc.storeLister}, + AnnotationPrefix: "ingress.kubernetes.io", + DefaultBackend: hc.cfg.DefaultService, + DefaultSSLSecret: hc.cfg.DefaultSSLCertificate, + } + instanceOptions := haproxy.InstanceOptions{ + HAProxyCmd: "haproxy", + ReloadCmd: "/haproxy-reload.sh", + HAProxyConfigFile: "/etc/haproxy/haproxy.cfg", + ReloadStrategy: *hc.reloadStrategy, + } + hc.instance = haproxy.CreateInstance(logger, instanceOptions) } // Stop shutdown the controller process -func (haproxy *HAProxyController) Stop() error { - err := haproxy.controller.Stop() +func (hc *HAProxyController) Stop() error { + err := hc.controller.Stop() return err } // Name provides the complete name of the controller -func (haproxy *HAProxyController) Name() string { +func (hc *HAProxyController) Name() string { return "HAProxy Ingress Controller" } // DefaultIngressClass returns the ingress class name -func (haproxy *HAProxyController) DefaultIngressClass() string { +func (hc *HAProxyController) DefaultIngressClass() string { return "haproxy" } // Check health check implementation -func (haproxy *HAProxyController) Check(_ *http.Request) error { +func (hc *HAProxyController) Check(_ *http.Request) error { return nil } // SetListers give access to the store listers -func (haproxy *HAProxyController) SetListers(lister *ingress.StoreLister) { - haproxy.storeLister = lister +func (hc *HAProxyController) SetListers(lister *ingress.StoreLister) { + hc.storeLister = lister } // UpdateIngressStatus custom callback used to update the status in an Ingress rule // If the function returns nil the standard functions will be executed. -func (haproxy *HAProxyController) UpdateIngressStatus(*extensions.Ingress) []api.LoadBalancerIngress { +func (hc *HAProxyController) UpdateIngressStatus(*extensions.Ingress) []api.LoadBalancerIngress { return nil } // ConfigureFlags allow to configure more flags before the parsing of // command line arguments -func (haproxy *HAProxyController) ConfigureFlags(flags *pflag.FlagSet) { - haproxy.reloadStrategy = flags.String("reload-strategy", "native", +func (hc *HAProxyController) ConfigureFlags(flags *pflag.FlagSet) { + hc.reloadStrategy = flags.String("reload-strategy", "native", `Name of the reload strategy. Options are: native (default) or reusesocket`) - haproxy.maxOldConfigFiles = flags.Int("max-old-config-files", 0, + hc.maxOldConfigFiles = flags.Int("max-old-config-files", 0, `Maximum old haproxy timestamped config files to allow before being cleaned up. A value <= 0 indicates a single non-timestamped config file will be used`) ingressClass := flags.Lookup("ingress-class") if ingressClass != nil { @@ -124,33 +159,33 @@ func (haproxy *HAProxyController) ConfigureFlags(flags *pflag.FlagSet) { } // OverrideFlags allows controller to override command line parameter flags -func (haproxy *HAProxyController) OverrideFlags(flags *pflag.FlagSet) { - haproxy.configDir = "/etc/haproxy" - haproxy.configFilePrefix = "haproxy" - haproxy.configFileSuffix = ".cfg" - haproxy.haproxyTemplate = newTemplate("haproxy.tmpl", "/etc/haproxy/template/haproxy.tmpl", 16384) - haproxy.modsecConfigFile = "/etc/haproxy/spoe-modsecurity.conf" - haproxy.modsecTemplate = newTemplate("spoe-modsecurity.tmpl", "/etc/haproxy/modsecurity/spoe-modsecurity.tmpl", 1024) - haproxy.command = "/haproxy-reload.sh" - - if !(*haproxy.reloadStrategy == "native" || *haproxy.reloadStrategy == "reusesocket" || *haproxy.reloadStrategy == "multibinder") { - glog.Fatalf("Unsupported reload strategy: %v", *haproxy.reloadStrategy) +func (hc *HAProxyController) OverrideFlags(flags *pflag.FlagSet) { + hc.configDir = "/etc/haproxy" + hc.configFilePrefix = "haproxy" + hc.configFileSuffix = ".cfg" + hc.haproxyTemplate = newTemplate("haproxy-v07.tmpl", "/etc/haproxy/template/haproxy-v07.tmpl", 16384) + hc.modsecConfigFile = "/etc/haproxy/spoe-modsecurity.conf" + hc.modsecTemplate = newTemplate("spoe-modsecurity-v07.tmpl", "/etc/haproxy/modsecurity/spoe-modsecurity-v07.tmpl", 1024) + hc.command = "/haproxy-reload.sh" + + if !(*hc.reloadStrategy == "native" || *hc.reloadStrategy == "reusesocket" || *hc.reloadStrategy == "multibinder") { + glog.Fatalf("Unsupported reload strategy: %v", *hc.reloadStrategy) } } // SetConfig receives the ConfigMap the user has configured -func (haproxy *HAProxyController) SetConfig(configMap *api.ConfigMap) { - haproxy.configMap = configMap +func (hc *HAProxyController) SetConfig(configMap *api.ConfigMap) { + hc.configMap = configMap } // BackendDefaults defines default values to the ingress core -func (haproxy *HAProxyController) BackendDefaults() defaults.Backend { - return newHAProxyConfig(haproxy).Backend +func (hc *HAProxyController) BackendDefaults() defaults.Backend { + return newHAProxyConfig(hc).Backend } // DefaultEndpoint returns the Endpoint to use as default when the // referenced service does not exists -func (haproxy *HAProxyController) DefaultEndpoint() ingress.Endpoint { +func (hc *HAProxyController) DefaultEndpoint() ingress.Endpoint { return ingress.Endpoint{ Address: "127.0.0.1", Port: "8181", @@ -162,38 +197,66 @@ func (haproxy *HAProxyController) DefaultEndpoint() ingress.Endpoint { // DrainSupport indicates whether or not this controller supports a "drain" mode where // unavailable and terminating pods are included in the list of returned pods and used to // direct certain traffic (e.g., traffic using persistence) to terminating/unavailable pods. -func (haproxy *HAProxyController) DrainSupport() (drainSupport bool) { - if haproxy.currentConfig != nil { - drainSupport = haproxy.currentConfig.Cfg.DrainSupport +func (hc *HAProxyController) DrainSupport() (drainSupport bool) { + if hc.currentConfig != nil { + drainSupport = hc.currentConfig.Cfg.DrainSupport } return } +// SyncIngress sync HAProxy config from a very early stage +func (hc *HAProxyController) SyncIngress(item interface{}) error { + var ingress []*extensions.Ingress + for _, iing := range hc.storeLister.Ingress.List() { + ing := iing.(*extensions.Ingress) + if class.IsValid(ing, hc.cfg.IngressClass, hc.cfg.DefaultIngressClass) { + ingress = append(ingress, ing) + } + } + sort.SliceStable(ingress, func(i, j int) bool { + return ingress[i].ResourceVersion < ingress[j].ResourceVersion + }) + + var globalConfig map[string]string + if hc.configMap != nil { + globalConfig = hc.configMap.Data + } + converter := ingressconverter.NewIngressConverter( + hc.converterOptions, + hc.instance.CreateConfig(), + globalConfig, + ) + converter.Sync(ingress) + hc.instance.Update() + + return nil +} + // OnUpdate regenerate the configuration file of the backend -func (haproxy *HAProxyController) OnUpdate(cfg ingress.Configuration) error { - updatedConfig, err := newControllerConfig(&cfg, haproxy) +func (hc *HAProxyController) OnUpdate(cfg ingress.Configuration) error { + updatedConfig, err := newControllerConfig(&cfg, hc) if err != nil { return err } - reloadRequired := !dynconfig.ConfigBackends(haproxy.currentConfig, updatedConfig) - haproxy.currentConfig = updatedConfig + reloadRequired := !dynconfig.ConfigBackends(hc.currentConfig, updatedConfig) + hc.currentConfig = updatedConfig - modSecConf, err := haproxy.modsecTemplate.execute(updatedConfig) + modSecConf, err := hc.modsecTemplate.execute(updatedConfig) if err != nil { return err } - if err := haproxy.writeModSecConfigFile(modSecConf); err != nil { + if err := hc.writeModSecConfigFile(modSecConf); err != nil { return err } - data, err := haproxy.haproxyTemplate.execute(updatedConfig) + data, err := hc.haproxyTemplate.execute(updatedConfig) if err != nil { return err } - configFile, err := haproxy.rewriteConfigFiles(data) + configFile, err := hc.rewriteConfigFiles(data) if err != nil { return err } @@ -203,7 +266,7 @@ func (haproxy *HAProxyController) OnUpdate(cfg ingress.Configuration) error { return nil } - reloadCmd := exec.Command(haproxy.command, *haproxy.reloadStrategy, configFile) + reloadCmd := exec.Command(hc.command, *hc.reloadStrategy, configFile) out, err := reloadCmd.CombinedOutput() if len(out) > 0 { glog.Infof("HAProxy[pid=%v] output:\n%v", reloadCmd.Process.Pid, string(out)) @@ -211,8 +274,8 @@ func (haproxy *HAProxyController) OnUpdate(cfg ingress.Configuration) error { return err } -func (haproxy *HAProxyController) writeModSecConfigFile(data []byte) error { - if err := ioutil.WriteFile(haproxy.modsecConfigFile, data, 644); err != nil { +func (hc *HAProxyController) writeModSecConfigFile(data []byte) error { + if err := ioutil.WriteFile(hc.modsecConfigFile, data, 644); err != nil { glog.Warningf("Error writing modsecurity config file: %v", err) return err } @@ -220,14 +283,14 @@ func (haproxy *HAProxyController) writeModSecConfigFile(data []byte) error { } // RewriteConfigFiles safely replaces configuration files with new contents after validation -func (haproxy *HAProxyController) rewriteConfigFiles(data []byte) (string, error) { +func (hc *HAProxyController) rewriteConfigFiles(data []byte) (string, error) { // Include timestamp in config file name to aid troubleshooting. When using a single, ever-changing config file it // was difficult to know what config was loaded by any given haproxy process timestamp := "" - if *haproxy.maxOldConfigFiles > 0 { + if *hc.maxOldConfigFiles > 0 { timestamp = time.Now().Format("-20060102-150405.000") } - configFile := haproxy.configDir + "/" + haproxy.configFilePrefix + timestamp + haproxy.configFileSuffix + configFile := hc.configDir + "/" + hc.configFilePrefix + timestamp + hc.configFileSuffix // Write directly to configFile if err := ioutil.WriteFile(configFile, data, 644); err != nil { @@ -240,8 +303,8 @@ func (haproxy *HAProxyController) rewriteConfigFiles(data []byte) (string, error return "", err } - if *haproxy.maxOldConfigFiles > 0 { - if err := haproxy.removeOldConfigFiles(); err != nil { + if *hc.maxOldConfigFiles > 0 { + if err := hc.removeOldConfigFiles(); err != nil { glog.Warningf("Problem removing old config files, but continuing in case it was a fluke. err=%v", err) } } @@ -249,8 +312,8 @@ func (haproxy *HAProxyController) rewriteConfigFiles(data []byte) (string, error return configFile, nil } -func (haproxy *HAProxyController) removeOldConfigFiles() error { - files, err := ioutil.ReadDir(haproxy.configDir) +func (hc *HAProxyController) removeOldConfigFiles() error { + files, err := ioutil.ReadDir(hc.configDir) if err != nil { return err } @@ -262,11 +325,11 @@ func (haproxy *HAProxyController) removeOldConfigFiles() error { matchesFound := 0 for _, f := range files { - if !f.IsDir() && strings.HasPrefix(f.Name(), haproxy.configFilePrefix) && strings.HasSuffix(f.Name(), haproxy.configFileSuffix) { + if !f.IsDir() && strings.HasPrefix(f.Name(), hc.configFilePrefix) && strings.HasSuffix(f.Name(), hc.configFileSuffix) { matchesFound = matchesFound + 1 - if matchesFound > *haproxy.maxOldConfigFiles { - filePath := haproxy.configDir + "/" + f.Name() - glog.Infof("Removing old config file (%v). maxOldConfigFiles=%v", filePath, *haproxy.maxOldConfigFiles) + if matchesFound > *hc.maxOldConfigFiles { + filePath := hc.configDir + "/" + f.Name() + glog.Infof("Removing old config file (%v). maxOldConfigFiles=%v", filePath, *hc.maxOldConfigFiles) if err := os.Remove(filePath); err != nil { return err } diff --git a/pkg/controller/logger.go b/pkg/controller/logger.go new file mode 100644 index 000000000..51312eedf --- /dev/null +++ b/pkg/controller/logger.go @@ -0,0 +1,56 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + "github.com/golang/glog" +) + +type logger struct { + depth int +} + +func (l *logger) build(msg string, args []interface{}) string { + if len(args) == 0 { + return msg + } + return fmt.Sprintf(msg, args...) +} + +func (l *logger) InfoV(v int, msg string, args ...interface{}) { + if glog.V(glog.Level(v)) { + glog.InfoDepth(l.depth, l.build(msg, args)) + } +} + +func (l *logger) Info(msg string, args ...interface{}) { + glog.InfoDepth(l.depth, l.build(msg, args)) +} + +func (l *logger) Warn(msg string, args ...interface{}) { + glog.WarningDepth(l.depth, l.build(msg, args)) +} + +func (l *logger) Error(msg string, args ...interface{}) { + glog.ErrorDepth(l.depth, l.build(msg, args)) +} + +func (l *logger) Fatal(msg string, args ...interface{}) { + glog.FatalDepth(l.depth, l.build(msg, args)) +} diff --git a/pkg/converters/ingress/annotations/backend.go b/pkg/converters/ingress/annotations/backend.go new file mode 100644 index 000000000..5a3ced2ac --- /dev/null +++ b/pkg/converters/ingress/annotations/backend.go @@ -0,0 +1,263 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "fmt" + "strconv" + "strings" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/utils" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" +) + +func (c *updater) buildAffinity(d *backData) { + if d.ann.Affinity != "cookie" { + if d.ann.Affinity != "" { + c.logger.Error("unsupported affinity type on %v: %s", d.ann.Source, d.ann.Affinity) + } + return + } + name := d.ann.SessionCookieName + if name == "" { + name = "INGRESSCOOKIE" + } + strategy := d.ann.SessionCookieStrategy + switch strategy { + case "insert", "rewrite", "prefix": + default: + if strategy != "" { + c.logger.Warn("invalid affinity cookie strategy '%s' on %v, using 'insert' instead", strategy, d.ann.Source) + } + strategy = "insert" + } + d.backend.Cookie.Name = name + d.backend.Cookie.Strategy = strategy + d.backend.Cookie.Key = d.ann.CookieKey +} + +func (c *updater) buildAuthHTTP(d *backData) { + if d.ann.AuthType != "basic" { + if d.ann.AuthType != "" { + c.logger.Error("unsupported authentication type on %v: %s", d.ann.Source, d.ann.AuthType) + } + return + } + if d.ann.AuthSecret == "" { + c.logger.Error("missing secret name on basic authentication on %v", d.ann.Source) + return + } + secretName := utils.FullQualifiedName(d.ann.Source.Namespace, d.ann.AuthSecret) + listName := strings.Replace(secretName, "/", "_", 1) + userlist := c.haproxy.FindUserlist(listName) + if userlist == nil { + userb, err := c.cache.GetSecretContent(secretName, "auth") + if err != nil { + c.logger.Error("error reading basic authentication on %v: %v", d.ann.Source, err) + return + } + userstr := string(userb) + users, errs := c.buildAuthHTTPExtractUserlist(d.ann.Source.Name, secretName, userstr) + for _, err := range errs { + c.logger.Warn("ignoring malformed usr/passwd on secret '%s', declared on %v: %v", secretName, d.ann.Source, err) + } + userlist = c.haproxy.AddUserlist(listName, users) + if len(users) == 0 { + c.logger.Warn("userlist on %v for basic authentication is empty", d.ann.Source) + } + } + d.backend.HreqValidateUserlist(userlist) +} + +func (c *updater) buildAuthHTTPExtractUserlist(source, secret, users string) ([]hatypes.User, []error) { + var userlist []hatypes.User + var err []error + for i, usr := range strings.Split(users, "\n") { + if usr == "" { + continue + } + sep := strings.Index(usr, ":") + if sep == -1 { + err = append(err, fmt.Errorf("missing password of user '%s' line %d", usr, i+1)) + continue + } + username := usr[:sep] + if username == "" { + err = append(err, fmt.Errorf("missing username line %d", i+1)) + continue + } + if sep == len(usr)-1 || usr[sep:] == "::" { + err = append(err, fmt.Errorf("missing password of user '%s' line %d", username, i+1)) + continue + } + var user hatypes.User + if string(usr[sep+1]) == ":" { + // usr::pwd + user = hatypes.User{ + Name: username, + Passwd: usr[sep+2:], + Encrypted: false, + } + } else { + // usr:pwd + user = hatypes.User{ + Name: username, + Passwd: usr[sep+1:], + Encrypted: true, + } + } + userlist = append(userlist, user) + } + return userlist, err +} + +func (c *updater) buildBlueGreen(d *backData) { + balance := d.ann.BlueGreenBalance + if balance == "" { + balance = d.ann.BlueGreenDeploy + if balance == "" { + return + } + } + type deployWeight struct { + labelName string + labelValue string + weight int + endpoints []*hatypes.Endpoint + } + var deployWeights []*deployWeight + for _, weight := range strings.Split(balance, ",") { + dwSlice := strings.Split(weight, "=") + if len(dwSlice) != 3 { + c.logger.Error("blue/green config on %v has an invalid weight format: %s", d.ann.Source, weight) + return + } + w, err := strconv.ParseInt(dwSlice[2], 10, 0) + if err != nil { + c.logger.Error("blue/green config on %v has an invalid weight value: %v", d.ann.Source, err) + return + } + if w < 0 { + c.logger.Warn("invalid weight '%d' on %v, using '0' instead", w, d.ann.Source) + w = 0 + } + if w > 256 { + c.logger.Warn("invalid weight '%d' on %v, using '256' instead", w, d.ann.Source) + w = 256 + } + dw := &deployWeight{ + labelName: dwSlice[0], + labelValue: dwSlice[1], + weight: int(w), + } + deployWeights = append(deployWeights, dw) + } + for _, ep := range d.backend.Endpoints { + hasLabel := false + if pod, err := c.cache.GetPod(ep.Target); err == nil { + for _, dw := range deployWeights { + if label, found := pod.Labels[dw.labelName]; found { + if label == dw.labelValue { + // mode == pod and gcdGroupWeight == 0 need ep.Weight assgined, + // otherwise ep.Weight will be rewritten after rebalance + ep.Weight = dw.weight + dw.endpoints = append(dw.endpoints, ep) + hasLabel = true + } + } + } + } else { + if ep.Target == "" { + err = fmt.Errorf("endpoint does not reference a pod") + } + c.logger.Warn("endpoint '%s:%d' on %v was removed from balance: %v", ep.IP, ep.Port, d.ann.Source, err) + } + if !hasLabel { + // no label match, set weight as zero to remove new traffic + // without remove from the balancer + ep.Weight = 0 + } + } + for _, dw := range deployWeights { + if len(dw.endpoints) == 0 { + c.logger.InfoV(3, "blue/green balance label '%s=%s' on %v does not reference any endpoint", dw.labelName, dw.labelValue, d.ann.Source) + } + } + if mode := d.ann.BlueGreenMode; mode == "pod" { + // mode == pod, same weight as defined on balance annotation, + // no need to rebalance + return + } else if mode != "" && mode != "deploy" { + c.logger.Warn("unsupported blue/green mode '%s' on %v, falling back to 'deploy'", d.ann.BlueGreenMode, d.ann.Source) + } + // mode == deploy, need to recalc based on the number of replicas + lcmCount := 0 + for _, dw := range deployWeights { + count := len(dw.endpoints) + if count == 0 { + continue + } + if lcmCount > 0 { + lcmCount = utils.LCM(lcmCount, count) + } else { + lcmCount = count + } + } + if lcmCount == 0 { + // all counts are zero, this config won't be used + return + } + gcdGroupWeight := 0 + maxWeight := 0 + for _, dw := range deployWeights { + count := len(dw.endpoints) + if count == 0 || dw.weight == 0 { + continue + } + groupWeight := dw.weight * lcmCount / count + if gcdGroupWeight > 0 { + gcdGroupWeight = utils.GCD(gcdGroupWeight, groupWeight) + } else { + gcdGroupWeight = groupWeight + } + if groupWeight > maxWeight { + maxWeight = groupWeight + } + } + if gcdGroupWeight == 0 { + // all weights are zero, no need to rebalance + return + } + // HAProxy weight must be between 0..256. + // weightFactor has how many times the max weight is greater than 256. + weightFactor := float32(maxWeight) / float32(gcdGroupWeight) / float32(256) + // LCM of denominators and GCD of the results are known. Updating ep.Weight + for _, dw := range deployWeights { + for _, ep := range dw.endpoints { + weight := dw.weight * lcmCount / len(dw.endpoints) / gcdGroupWeight + if weightFactor > 1 { + propWeight := int(float32(weight) / weightFactor) + if propWeight == 0 && dw.weight > 0 { + propWeight = 1 + } + ep.Weight = propWeight + } else { + ep.Weight = weight + } + } + } +} diff --git a/pkg/converters/ingress/annotations/backend_test.go b/pkg/converters/ingress/annotations/backend_test.go new file mode 100644 index 000000000..ceb91cc6a --- /dev/null +++ b/pkg/converters/ingress/annotations/backend_test.go @@ -0,0 +1,503 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "reflect" + "strings" + "testing" + + api "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + + ing_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/helper_test" + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" +) + +func TestAffinity(t *testing.T) { + testCase := []struct { + ann types.BackendAnnotations + expCookie hatypes.Cookie + expLogging string + }{ + // 0 + { + ann: types.BackendAnnotations{}, + expLogging: "", + }, + // 1 + { + ann: types.BackendAnnotations{Affinity: "no"}, + expLogging: "ERROR unsupported affinity type on ingress 'default/ing1': no", + }, + // 2 + { + ann: types.BackendAnnotations{Affinity: "cookie"}, + expCookie: hatypes.Cookie{Name: "INGRESSCOOKIE", Strategy: "insert"}, + expLogging: "", + }, + // 3 + { + ann: types.BackendAnnotations{Affinity: "cookie", SessionCookieName: "ing"}, + expCookie: hatypes.Cookie{Name: "ing", Strategy: "insert"}, + expLogging: "", + }, + // 4 + { + ann: types.BackendAnnotations{Affinity: "cookie", SessionCookieStrategy: "err"}, + expCookie: hatypes.Cookie{Name: "INGRESSCOOKIE", Strategy: "insert"}, + expLogging: "WARN invalid affinity cookie strategy 'err' on ingress 'default/ing1', using 'insert' instead", + }, + // 5 + { + ann: types.BackendAnnotations{Affinity: "cookie", SessionCookieStrategy: "rewrite"}, + expCookie: hatypes.Cookie{Name: "INGRESSCOOKIE", Strategy: "rewrite"}, + expLogging: "", + }, + // 6 + { + ann: types.BackendAnnotations{Affinity: "cookie", SessionCookieStrategy: "prefix"}, + expCookie: hatypes.Cookie{Name: "INGRESSCOOKIE", Strategy: "prefix"}, + expLogging: "", + }, + // 7 + { + ann: types.BackendAnnotations{Affinity: "cookie", CookieKey: "ha"}, + expCookie: hatypes.Cookie{Name: "INGRESSCOOKIE", Strategy: "insert", Key: "ha"}, + expLogging: "", + }, + } + + for i, test := range testCase { + c := setup(t) + u := c.createUpdater() + d := c.createBackendData("default", "ing1", &test.ann) + u.buildAffinity(d) + if !reflect.DeepEqual(test.expCookie, d.backend.Cookie) { + t.Errorf("config %d differs - expected: %+v - actual: %+v", i, test.expCookie, d.backend.Cookie) + } + c.logger.CompareLogging(test.expLogging) + c.teardown() + } +} + +func TestAuthHTTP(t *testing.T) { + testCase := []struct { + namespace string + ingname string + ann types.BackendAnnotations + secrets ing_helper.SecretContent + expUserlists []*hatypes.Userlist + expHTTPRequests []*hatypes.HTTPRequest + expLogging string + }{ + // 0 + { + ann: types.BackendAnnotations{}, + expLogging: "", + }, + // 1 + { + ann: types.BackendAnnotations{AuthType: "fail"}, + expLogging: "ERROR unsupported authentication type on ingress 'default/ing1': fail", + }, + // 2 + { + ann: types.BackendAnnotations{AuthType: "basic"}, + expLogging: "ERROR missing secret name on basic authentication on ingress 'default/ing1'", + }, + // 3 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "mypwd"}, + expLogging: "ERROR error reading basic authentication on ingress 'default/ing1': secret not found: 'default/mypwd'", + }, + // 4 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "mypwd"}, + secrets: ing_helper.SecretContent{"default/mypwd": {"xx": []byte{}}}, + expLogging: "ERROR error reading basic authentication on ingress 'default/ing1': secret 'default/mypwd' does not have file/key 'auth'", + }, + // 5 + { + namespace: "ns1", + ingname: "i1", + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "mypwd"}, + secrets: ing_helper.SecretContent{"ns1/mypwd": {"auth": []byte{}}}, + expUserlists: []*hatypes.Userlist{&hatypes.Userlist{Name: "ns1_mypwd"}}, + expHTTPRequests: []*hatypes.HTTPRequest{{}}, + expLogging: "WARN userlist on ingress 'ns1/i1' for basic authentication is empty", + }, + // 6 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "basicpwd"}, + secrets: ing_helper.SecretContent{"default/basicpwd": {"auth": []byte("fail")}}, + expUserlists: []*hatypes.Userlist{&hatypes.Userlist{Name: "default_basicpwd"}}, + expHTTPRequests: []*hatypes.HTTPRequest{{}}, + expLogging: ` +WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing password of user 'fail' line 1 +WARN userlist on ingress 'default/ing1' for basic authentication is empty`, + }, + // 7 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "basicpwd"}, + secrets: ing_helper.SecretContent{"default/basicpwd": {"auth": []byte(` +usr1::clearpwd1 +nopwd`)}}, + expUserlists: []*hatypes.Userlist{&hatypes.Userlist{Name: "default_basicpwd", Users: []hatypes.User{ + {Name: "usr1", Passwd: "clearpwd1", Encrypted: false}, + }}}, + expHTTPRequests: []*hatypes.HTTPRequest{{}}, + expLogging: "WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing password of user 'nopwd' line 3", + }, + // 8 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "basicpwd"}, + secrets: ing_helper.SecretContent{"default/basicpwd": {"auth": []byte(` +usrnopwd1: +usrnopwd2:: +:encpwd3 +::clearpwd4`)}}, + expUserlists: []*hatypes.Userlist{&hatypes.Userlist{Name: "default_basicpwd"}}, + expHTTPRequests: []*hatypes.HTTPRequest{{}}, + expLogging: ` +WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing password of user 'usrnopwd1' line 2 +WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing password of user 'usrnopwd2' line 3 +WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing username line 4 +WARN ignoring malformed usr/passwd on secret 'default/basicpwd', declared on ingress 'default/ing1': missing username line 5 +WARN userlist on ingress 'default/ing1' for basic authentication is empty`, + }, + // 9 + { + ann: types.BackendAnnotations{AuthType: "basic", AuthSecret: "basicpwd"}, + secrets: ing_helper.SecretContent{"default/basicpwd": {"auth": []byte(` +usr1:encpwd1 +usr2::clearpwd2`)}}, + expUserlists: []*hatypes.Userlist{&hatypes.Userlist{Name: "default_basicpwd", Users: []hatypes.User{ + {Name: "usr1", Passwd: "encpwd1", Encrypted: true}, + {Name: "usr2", Passwd: "clearpwd2", Encrypted: false}, + }}}, + expHTTPRequests: []*hatypes.HTTPRequest{{}}, + expLogging: "", + }, + } + + for i, test := range testCase { + // TODO missing expHTTPRequests + c := setup(t) + u := c.createUpdater() + if test.namespace == "" { + test.namespace = "default" + } + if test.ingname == "" { + test.ingname = "ing1" + } + c.cache.SecretContent = test.secrets + d := c.createBackendData(test.namespace, test.ingname, &test.ann) + u.buildAuthHTTP(d) + userlists := u.haproxy.Userlists() + httpRequests := d.backend.HTTPRequests + if len(userlists)+len(test.expUserlists) > 0 && !reflect.DeepEqual(test.expUserlists, userlists) { + t.Errorf("userlists config %d differs - expected: %+v - actual: %+v", i, test.expUserlists, userlists) + } + if len(httpRequests)+len(test.expHTTPRequests) > 0 && !reflect.DeepEqual(test.expHTTPRequests, httpRequests) { + t.Errorf("httprequest config %d differs - expected: %+v - actual: %+v", i, test.expHTTPRequests, httpRequests) + } + c.logger.CompareLogging(test.expLogging) + c.teardown() + } +} + +func TestBlueGreen(t *testing.T) { + buildPod := func(labels string) *api.Pod { + l := make(map[string]string) + for _, label := range strings.Split(labels, ",") { + kv := strings.Split(label, "=") + l[kv[0]] = kv[1] + } + return &api.Pod{ + ObjectMeta: meta.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: l, + }, + } + } + buildAnn := func(bal, mode string) types.BackendAnnotations { + return types.BackendAnnotations{BlueGreenBalance: bal, BlueGreenMode: mode} + } + buildEndpoints := func(targets string) []*hatypes.Endpoint { + ep := []*hatypes.Endpoint{} + if targets != "" { + for _, target := range strings.Split(targets, ",") { + ep = append(ep, &hatypes.Endpoint{ + IP: "172.17.0.11", + Port: 8080, + Weight: 1, + Target: target, + }) + } + } + return ep + } + pods := map[string]*api.Pod{ + "pod0101-01": buildPod("app=d01,v=1"), + "pod0101-02": buildPod("app=d01,v=1"), + "pod0102-01": buildPod("app=d01,v=2"), + "pod0102-02": buildPod("app=d01,v=2"), + "pod0102-03": buildPod("app=d01,v=2"), + "pod0102-04": buildPod("app=d01,v=2"), + "pod0103-01": buildPod("app=d01,v=3"), + } + testCase := []struct { + ann types.BackendAnnotations + endpoints []*hatypes.Endpoint + expWeights []int + expLogging string + }{ + // 0 + { + ann: buildAnn("", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "", + }, + // 1 + { + ann: buildAnn("", "err"), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "", + }, + // 22 + { + ann: buildAnn("err", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "ERROR blue/green config on ingress 'default/ing1' has an invalid weight format: err", + }, + // 3 + { + ann: buildAnn("v=1=xx", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "ERROR blue/green config on ingress 'default/ing1' has an invalid weight value: strconv.ParseInt: parsing \"xx\": invalid syntax", + }, + // 4 + { + ann: buildAnn("v=1=1.5", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "ERROR blue/green config on ingress 'default/ing1' has an invalid weight value: strconv.ParseInt: parsing \"1.5\": invalid syntax", + }, + // 5 + { + ann: buildAnn("v=1=-1", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{0}, + expLogging: "WARN invalid weight '-1' on ingress 'default/ing1', using '0' instead", + }, + // 6 + { + ann: buildAnn("v=1=260", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "WARN invalid weight '260' on ingress 'default/ing1', using '256' instead", + }, + // 7 + { + ann: buildAnn("v=1=10", "err"), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "WARN unsupported blue/green mode 'err' on ingress 'default/ing1', falling back to 'deploy'", + }, + // 8 + { + ann: buildAnn("v=1=10", ""), + endpoints: buildEndpoints("pod0101-01"), + expWeights: []int{1}, + expLogging: "", + }, + // 9 + { + ann: buildAnn("", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{1, 1}, + expLogging: "", + }, + // 10 + { + ann: buildAnn("v=1=50,v=2=50", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{1, 1}, + expLogging: "", + }, + // 11 + { + ann: buildAnn("v=1=50,v=2=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{2, 1}, + expLogging: "", + }, + // 12 + { + ann: buildAnn("v=1=50,v=2=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02"), + expWeights: []int{4, 1, 1}, + expLogging: "", + }, + // 13 + { + ann: buildAnn("v=1=50,v=2=25", "pod"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02"), + expWeights: []int{50, 25, 25}, + expLogging: "", + }, + // 14 + { + ann: buildAnn("v=1=50,v=2=25", ""), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02"), + expWeights: []int{4, 1, 1}, + expLogging: "", + }, + // 15 + { + ann: buildAnn("v=1=500,v=2=2", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{128, 1}, + expLogging: "WARN invalid weight '500' on ingress 'default/ing1', using '256' instead", + }, + // 16 + { + ann: buildAnn("v=1=60,v=2=3", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02,pod0102-03,pod0102-04"), + expWeights: []int{80, 1, 1, 1, 1}, + expLogging: "", + }, + // 17 + { + ann: buildAnn("v=1=70,v=2=3", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02,pod0102-03,pod0102-04"), + expWeights: []int{256, 2, 2, 2, 2}, + expLogging: "", + }, + // 18 + { + ann: buildAnn("v=1=50,v=2=25", "deploy"), + endpoints: buildEndpoints(",pod0102-01"), + expWeights: []int{0, 1}, + expLogging: ` +WARN endpoint '172.17.0.11:8080' on ingress 'default/ing1' was removed from balance: endpoint does not reference a pod +INFO-V(3) blue/green balance label 'v=1' on ingress 'default/ing1' does not reference any endpoint`, + }, + // 19 + { + ann: buildAnn("v=1=50,v=non=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{1, 0}, + expLogging: "INFO-V(3) blue/green balance label 'v=non' on ingress 'default/ing1' does not reference any endpoint", + }, + // 20 + { + ann: buildAnn("v=1=50,v=non=25", "pod"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{50, 0}, + expLogging: "INFO-V(3) blue/green balance label 'v=non' on ingress 'default/ing1' does not reference any endpoint", + }, + // 21 + { + ann: buildAnn("v=1=50,non=2=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{1, 0}, + expLogging: "INFO-V(3) blue/green balance label 'non=2' on ingress 'default/ing1' does not reference any endpoint", + }, + // 22 + { + ann: buildAnn("v=1=50,v=2=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-non"), + expWeights: []int{1, 0}, + expLogging: ` +WARN endpoint '172.17.0.11:8080' on ingress 'default/ing1' was removed from balance: pod not found: 'pod0102-non' +INFO-V(3) blue/green balance label 'v=2' on ingress 'default/ing1' does not reference any endpoint`, + }, + // 23 + { + ann: buildAnn("v=1=50,v=2=25", "pod"), + endpoints: buildEndpoints("pod0101-01,pod0102-non"), + expWeights: []int{50, 0}, + expLogging: ` +WARN endpoint '172.17.0.11:8080' on ingress 'default/ing1' was removed from balance: pod not found: 'pod0102-non' +INFO-V(3) blue/green balance label 'v=2' on ingress 'default/ing1' does not reference any endpoint`, + }, + // 24 + { + ann: buildAnn("v=1=50,v=2=25,v=3=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02,pod0103-01"), + expWeights: []int{4, 1, 1, 2}, + expLogging: "", + }, + // 25 + { + ann: buildAnn("v=1=50,v=2=0,v=3=25", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02,pod0103-01"), + expWeights: []int{2, 0, 0, 1}, + expLogging: "", + }, + // 26 + { + ann: buildAnn("v=1=50,v=2=0,v=3=25", "deploy"), + endpoints: buildEndpoints(""), + expWeights: []int{}, + expLogging: ` +INFO-V(3) blue/green balance label 'v=1' on ingress 'default/ing1' does not reference any endpoint +INFO-V(3) blue/green balance label 'v=2' on ingress 'default/ing1' does not reference any endpoint +INFO-V(3) blue/green balance label 'v=3' on ingress 'default/ing1' does not reference any endpoint`, + }, + // 27 + { + ann: buildAnn("v=1=0,v=2=0", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01"), + expWeights: []int{0, 0}, + expLogging: "", + }, + // 28 + { + ann: buildAnn("v=1=255,v=2=2", "deploy"), + endpoints: buildEndpoints("pod0101-01,pod0102-01,pod0102-02,pod0102-03,pod0102-04"), + expWeights: []int{256, 1, 1, 1, 1}, + expLogging: "", + }, + } + + for i, test := range testCase { + c := setup(t) + c.cache.PodList = pods + d := c.createBackendData("default", "ing1", &test.ann) + d.backend.Endpoints = test.endpoints + u := c.createUpdater() + u.buildBlueGreen(d) + weights := make([]int, len(d.backend.Endpoints)) + for j, ep := range d.backend.Endpoints { + weights[j] = ep.Weight + } + if len(test.expWeights)+len(weights) > 0 && !reflect.DeepEqual(test.expWeights, weights) { + t.Errorf("weight on %d differs - expected: %v - actual: %v", i, test.expWeights, weights) + } + c.logger.CompareLogging(test.expLogging) + c.teardown() + } +} diff --git a/pkg/converters/ingress/annotations/frontend.go b/pkg/converters/ingress/annotations/frontend.go new file mode 100644 index 000000000..9ac6e1a3a --- /dev/null +++ b/pkg/converters/ingress/annotations/frontend.go @@ -0,0 +1,50 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +func (c *updater) buildAuthTLS(d *frontData) { + if d.ann.AuthTLSSecret == "" { + return + } + if cafile, err := c.cache.GetCASecretPath(d.ann.AuthTLSSecret); err == nil { + d.frontend.TLS.CAFilename = cafile + d.frontend.TLS.ErrorPage = d.ann.AuthTLSErrorPage + d.frontend.TLS.AddCertHeader = d.ann.AuthTLSCertHeader + } +} + +func (c *updater) buildSSLPassthrough(d *frontData) { + if !d.ann.SSLPassthrough { + return + } + rootPath := d.frontend.FindPath("/") + if rootPath == nil { + c.logger.Warn("skipping SSL of %s: root path was not configured", d.ann.Source) + return + } + for _, path := range d.frontend.Paths { + if path.Path != "/" { + c.logger.Warn("ignoring path '%s' from '%s': ssl-passthrough only support root path", path.Path, d.ann.Source) + } + } + if d.ann.SSLPassthroughHTTPPort != 0 { + httpBackend := c.haproxy.FindBackend(rootPath.Backend.Namespace, rootPath.Backend.Name, d.ann.SSLPassthroughHTTPPort) + d.frontend.HTTPPassthroughBackend = httpBackend + } + rootPath.Backend.ModeTCP = true + d.frontend.SSLPassthrough = true +} diff --git a/pkg/converters/ingress/annotations/updater.go b/pkg/converters/ingress/annotations/updater.go new file mode 100644 index 000000000..b3695b796 --- /dev/null +++ b/pkg/converters/ingress/annotations/updater.go @@ -0,0 +1,84 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + ingtypes "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// Updater ... +type Updater interface { + UpdateFrontendConfig(frontend *hatypes.Frontend, ann *ingtypes.FrontendAnnotations) + UpdateBackendConfig(backend *hatypes.Backend, ann *ingtypes.BackendAnnotations) +} + +// NewUpdater ... +func NewUpdater(haproxy haproxy.Config, cache ingtypes.Cache, logger types.Logger) Updater { + return &updater{ + haproxy: haproxy, + cache: cache, + logger: logger, + } +} + +type updater struct { + haproxy haproxy.Config + cache ingtypes.Cache + logger types.Logger +} + +type frontData struct { + frontend *hatypes.Frontend + ann *ingtypes.FrontendAnnotations +} + +type backData struct { + backend *hatypes.Backend + ann *ingtypes.BackendAnnotations +} + +func (c *updater) UpdateFrontendConfig(frontend *hatypes.Frontend, ann *ingtypes.FrontendAnnotations) { + data := &frontData{ + frontend: frontend, + ann: ann, + } + frontend.RootRedirect = ann.AppRoot + frontend.Alias.AliasName = ann.ServerAlias + frontend.Alias.AliasRegex = ann.ServerAliasRegex + frontend.Timeout.Client = ann.TimeoutClient + frontend.Timeout.ClientFin = ann.TimeoutClientFin + c.buildAuthTLS(data) + c.buildSSLPassthrough(data) +} + +func (c *updater) UpdateBackendConfig(backend *hatypes.Backend, ann *ingtypes.BackendAnnotations) { + data := &backData{ + backend: backend, + ann: ann, + } + // TODO check ModeTCP with HTTP annotations + backend.BalanceAlgorithm = ann.BalanceAlgorithm + backend.MaxconnServer = ann.MaxconnServer + backend.ProxyBodySize = ann.ProxyBodySize + backend.SSLRedirect = ann.SSLRedirect + c.buildAffinity(data) + c.buildAuthHTTP(data) + c.buildBlueGreen(data) +} diff --git a/pkg/converters/ingress/annotations/updater_test.go b/pkg/converters/ingress/annotations/updater_test.go new file mode 100644 index 000000000..4af6416c3 --- /dev/null +++ b/pkg/converters/ingress/annotations/updater_test.go @@ -0,0 +1,74 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "testing" + + ing_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/helper_test" + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" + types_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/types/helper_test" +) + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * + * BUILDERS + * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +type testConfig struct { + t *testing.T + haproxy haproxy.Config + cache *ing_helper.CacheMock + logger *types_helper.LoggerMock +} + +func setup(t *testing.T) *testConfig { + logger := &types_helper.LoggerMock{T: t} + return &testConfig{ + t: t, + haproxy: haproxy.CreateInstance(logger, haproxy.InstanceOptions{}).Config(), + cache: &ing_helper.CacheMock{}, + logger: logger, + } +} + +func (c *testConfig) teardown() { + c.logger.CompareLogging("") +} + +func (c *testConfig) createUpdater() *updater { + return &updater{ + haproxy: c.haproxy, + cache: c.cache, + logger: c.logger, + } +} + +func (c *testConfig) createBackendData(namespace, name string, ann *types.BackendAnnotations) *backData { + ann.Source = types.Source{ + Namespace: namespace, + Name: name, + Type: "ingress", + } + return &backData{ + backend: &hatypes.Backend{}, + ann: ann, + } +} diff --git a/pkg/converters/ingress/defaults.go b/pkg/converters/ingress/defaults.go new file mode 100644 index 000000000..27fe90ece --- /dev/null +++ b/pkg/converters/ingress/defaults.go @@ -0,0 +1,106 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingress + +import ( + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/utils" +) + +const ( + defaultSSLCiphers = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK" +) + +func createDefaults() *types.Config { + return &types.Config{ + ConfigDefaults: types.ConfigDefaults{ + BalanceAlgorithm: "roundrobin", + CookieKey: "Ingress", + HSTS: true, + HSTSIncludeSubdomains: false, + HSTSMaxAge: "15768000", + HSTSPreload: false, + ProxyBodySize: "", + SSLRedirect: true, + TimeoutClient: "50s", + TimeoutClientFin: "50s", + TimeoutConnect: "5s", + TimeoutHTTPRequest: "5s", + TimeoutKeepAlive: "1m", + TimeoutQueue: "5s", + TimeoutServer: "50s", + TimeoutServerFin: "50s", + TimeoutTunnel: "1h", + }, + ConfigGlobals: types.ConfigGlobals{ + BackendCheckInterval: "2s", + BackendServerSlotsIncrement: 32, + BindIPAddrHealthz: "*", + BindIPAddrHTTP: "*", + BindIPAddrStats: "*", + BindIPAddrTCP: "*", + ConfigFrontend: "", + ConfigGlobal: "", + DNSAcceptedPayloadSize: 8192, + DNSClusterDomain: "cluster.local", + DNSHoldObsolete: "0s", + DNSHoldValid: "1s", + DNSResolvers: "", + DNSTimeoutRetry: "1s", + DrainSupport: false, + DynamicScaling: false, + Forwardfor: "add", + HealthzPort: 10253, + HTTPLogFormat: "", + HTTPPort: 80, + HTTPSLogFormat: "", + HTTPSPort: 443, + HTTPStoHTTPPort: 0, + LoadServerState: false, + MaxConnections: 2000, + ModsecurityEndpoints: "", + ModsecurityTimeoutHello: "100ms", + ModsecurityTimeoutIdle: "30s", + ModsecurityTimeoutProcessing: "1s", + NbprocBalance: 1, + NbprocSSL: 0, + Nbthread: 1, + NoTLSRedirectLocations: "/.well-known/acme-challenge", + SSLCiphers: defaultSSLCiphers, + SSLDHDefaultMaxSize: 2048, + SSLDHParam: "", + SSLEngine: "", + SSLHeadersPrefix: "X-SSL", + SSLModeAsync: false, + SSLOptions: "no-sslv3 no-tls-tickets", + StatsAuth: "", + StatsPort: 1936, + StatsProxyProtocol: false, + StatsSSLCert: "", + StrictHost: true, + Syslog: "", + TCPLogFormat: "", + TimeoutStop: "", + UseProxyProtocol: false, + }, + } +} + +func mergeConfig(configDefault *types.Config, config map[string]string) *types.Config { + utils.MergeMap(config, configDefault) + return configDefault +} diff --git a/pkg/converters/ingress/helper_test/cachemock.go b/pkg/converters/ingress/helper_test/cachemock.go new file mode 100644 index 000000000..ce5863247 --- /dev/null +++ b/pkg/converters/ingress/helper_test/cachemock.go @@ -0,0 +1,94 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper_test + +import ( + "fmt" + "strings" + + api "k8s.io/api/core/v1" +) + +// SecretContent ... +type SecretContent map[string]map[string][]byte + +// CacheMock ... +type CacheMock struct { + SvcList []*api.Service + EpList map[string]*api.Endpoints + PodList map[string]*api.Pod + SecretTLSPath map[string]string + SecretCAPath map[string]string + SecretContent SecretContent +} + +// GetService ... +func (c *CacheMock) GetService(serviceName string) (*api.Service, error) { + sname := strings.Split(serviceName, "/") + if len(sname) == 2 { + for _, svc := range c.SvcList { + if svc.Namespace == sname[0] && svc.Name == sname[1] { + return svc, nil + } + } + } + return nil, fmt.Errorf("service not found: '%s'", serviceName) +} + +// GetEndpoints ... +func (c *CacheMock) GetEndpoints(service *api.Service) (*api.Endpoints, error) { + serviceName := service.Namespace + "/" + service.Name + if ep, found := c.EpList[serviceName]; found { + return ep, nil + } + return nil, fmt.Errorf("could not find endpoints for service '%s'", serviceName) +} + +// GetPod ... +func (c *CacheMock) GetPod(podName string) (*api.Pod, error) { + if pod, found := c.PodList[podName]; found { + return pod, nil + } + return nil, fmt.Errorf("pod not found: '%s'", podName) +} + +// GetTLSSecretPath ... +func (c *CacheMock) GetTLSSecretPath(secretName string) (string, error) { + if path, found := c.SecretTLSPath[secretName]; found { + return path, nil + } + return "", fmt.Errorf("secret not found: '%s'", secretName) +} + +// GetCASecretPath ... +func (c *CacheMock) GetCASecretPath(secretName string) (string, error) { + if path, found := c.SecretCAPath[secretName]; found { + return path, nil + } + return "", fmt.Errorf("secret not found: '%s'", secretName) +} + +// GetSecretContent ... +func (c *CacheMock) GetSecretContent(secretName, keyName string) ([]byte, error) { + if content, found := c.SecretContent[secretName]; found { + if val, found := content[keyName]; found { + return val, nil + } + return nil, fmt.Errorf("secret '%s' does not have file/key '%s'", secretName, keyName) + } + return nil, fmt.Errorf("secret not found: '%s'", secretName) +} diff --git a/pkg/converters/ingress/ingress.go b/pkg/converters/ingress/ingress.go new file mode 100644 index 000000000..7fdc285d7 --- /dev/null +++ b/pkg/converters/ingress/ingress.go @@ -0,0 +1,295 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingress + +import ( + "fmt" + "strings" + + api "k8s.io/api/core/v1" + extensions "k8s.io/api/extensions/v1beta1" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/annotations" + ingtypes "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/utils" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// Config ... +type Config interface { + Sync(ingress []*extensions.Ingress) +} + +// NewIngressConverter ... +func NewIngressConverter(options *ingtypes.ConverterOptions, haproxy haproxy.Config, globalConfig map[string]string) Config { + c := &converter{ + haproxy: haproxy, + options: options, + logger: options.Logger, + cache: options.Cache, + globalConfig: mergeConfig(createDefaults(), globalConfig), + frontendAnnotations: map[string]*ingtypes.FrontendAnnotations{}, + backendAnnotations: map[string]*ingtypes.BackendAnnotations{}, + } + if backend, err := c.addBackend(options.DefaultBackend, 0, &ingtypes.BackendAnnotations{}); err == nil { + haproxy.ConfigDefaultBackend(backend) + } else { + c.logger.Error("error reading default service: %v", err) + } + return c +} + +type converter struct { + haproxy haproxy.Config + options *ingtypes.ConverterOptions + logger types.Logger + cache ingtypes.Cache + globalConfig *ingtypes.Config + frontendAnnotations map[string]*ingtypes.FrontendAnnotations + backendAnnotations map[string]*ingtypes.BackendAnnotations +} + +func (c *converter) Sync(ingress []*extensions.Ingress) { + for _, ing := range ingress { + c.syncIngress(ing) + } + c.syncAnnotations() +} + +func (c *converter) syncIngress(ing *extensions.Ingress) { + fullIngName := fmt.Sprintf("%s/%s", ing.Namespace, ing.Name) + ingFrontAnn, ingBackAnn := c.readAnnotations(&ingtypes.Source{ + Namespace: ing.Namespace, + Name: ing.Name, + Type: "ingress", + }, ing.Annotations) + if ing.Spec.Backend != nil { + svcName, svcPort := readServiceNamePort(ing.Spec.Backend) + err := c.addDefaultHostBackend(utils.FullQualifiedName(ing.Namespace, svcName), svcPort, ingFrontAnn, ingBackAnn) + if err != nil { + c.logger.Warn("skipping default backend of ingress '%s': %v", fullIngName, err) + } + } + for _, rule := range ing.Spec.Rules { + if rule.HTTP == nil { + continue + } + hostname := rule.Host + if hostname == "" { + hostname = "*" + } + frontend := c.addFrontend(hostname, ingFrontAnn) + for _, path := range rule.HTTP.Paths { + uri := path.Path + if uri == "" { + uri = "/" + } + if frontend.FindPath(uri) != nil { + c.logger.Warn("skipping redeclared path '%s' of ingress '%s'", uri, fullIngName) + continue + } + svcName, svcPort := readServiceNamePort(&path.Backend) + fullSvcName := utils.FullQualifiedName(ing.Namespace, svcName) + backend, err := c.addBackend(fullSvcName, svcPort, ingBackAnn) + if err != nil { + c.logger.Warn("skipping backend config of ingress '%s': %v", fullIngName, err) + continue + } + frontend.AddPath(backend, uri) + c.addHTTPPassthrough(fullSvcName, ingFrontAnn, ingBackAnn) + } + for _, tls := range ing.Spec.TLS { + for _, host := range tls.Hosts { + if host == hostname { + tlsPath, err := c.addTLS(ing.Namespace, tls.SecretName) + if err == nil { + if frontend.TLS.TLSFilename == "" { + frontend.TLS.TLSFilename = tlsPath + } else if frontend.TLS.TLSFilename != tlsPath { + err = fmt.Errorf("TLS of host '%s' was already assigned", frontend.Hostname) + } + } + if err != nil { + if tls.SecretName != "" { + c.logger.Warn("skipping TLS secret '%s' of ingress '%s': %v", tls.SecretName, fullIngName, err) + } else { + c.logger.Warn("skipping default TLS secret of ingress '%s': %v", fullIngName, err) + } + } + } + } + } + } +} + +func (c *converter) syncAnnotations() { + updater := annotations.NewUpdater(c.haproxy, c.cache, c.logger) + for _, frontend := range c.haproxy.Frontends() { + if ann, found := c.frontendAnnotations[frontend.Hostname]; found { + updater.UpdateFrontendConfig(frontend, ann) + } + } + for _, backend := range c.haproxy.Backends() { + if ann, found := c.backendAnnotations[backend.ID]; found { + updater.UpdateBackendConfig(backend, ann) + } + } +} + +func (c *converter) addDefaultHostBackend(fullSvcName string, svcPort int, ingFrontAnn *ingtypes.FrontendAnnotations, ingBackAnn *ingtypes.BackendAnnotations) error { + if fr := c.haproxy.FindFrontend("*"); fr != nil { + if fr.FindPath("/") != nil { + return fmt.Errorf("path / was already defined on default host") + } + } + backend, err := c.addBackend(fullSvcName, svcPort, ingBackAnn) + if err != nil { + return err + } + frontend := c.addFrontend("*", ingFrontAnn) + frontend.AddPath(backend, "/") + return nil +} + +func (c *converter) addFrontend(host string, ingAnn *ingtypes.FrontendAnnotations) *hatypes.Frontend { + frontend := c.haproxy.AcquireFrontend(host) + if ann, found := c.frontendAnnotations[frontend.Hostname]; found { + skipped, _ := utils.UpdateStruct(c.globalConfig.ConfigDefaults, ingAnn, ann) + if len(skipped) > 0 { + c.logger.Info("skipping frontend annotation(s) from %v due to conflict: %v", ingAnn.Source, skipped) + } + } else { + c.frontendAnnotations[frontend.Hostname] = ingAnn + } + return frontend +} + +func (c *converter) addBackend(fullSvcName string, svcPort int, ingAnn *ingtypes.BackendAnnotations) (*hatypes.Backend, error) { + svc, err := c.cache.GetService(fullSvcName) + if err != nil { + return nil, err + } + ssvcName := strings.Split(fullSvcName, "/") + namespace := ssvcName[0] + svcName := ssvcName[1] + if svcPort == 0 { + // if the port wasn't specified, take the first one + // from the api.Service object + // TODO named port + svcPort = svc.Spec.Ports[0].TargetPort.IntValue() + } + backend := c.haproxy.AcquireBackend(namespace, svcName, svcPort) + ann, found := c.backendAnnotations[backend.ID] + if !found { + // New backend, configure endpoints and svc annotations + if err := c.addEndpoints(svc, svcPort, backend); err != nil { + c.logger.Error("error adding endpoints of service '%s': %v", fullSvcName, err) + } + // Initialize with service annotations, giving precedence + _, ann = c.readAnnotations(&ingtypes.Source{ + Namespace: namespace, + Name: svcName, + Type: "service", + }, svc.Annotations) + c.backendAnnotations[backend.ID] = ann + } + // Merging Ingress annotations + skipped, _ := utils.UpdateStruct(c.globalConfig.ConfigDefaults, ingAnn, ann) + if len(skipped) > 0 { + c.logger.Info("skipping backend annotation(s) from %v due to conflict: %v", ingAnn.Source, skipped) + } + return backend, nil +} + +func (c *converter) addHTTPPassthrough(fullSvcName string, ingFrontAnn *ingtypes.FrontendAnnotations, ingBackAnn *ingtypes.BackendAnnotations) { + // a very specific use case of pre-parsing annotations: + // need to add a backend if ssl-passthrough-http-port assigned + if ingFrontAnn.SSLPassthrough && ingFrontAnn.SSLPassthroughHTTPPort != 0 { + c.addBackend(fullSvcName, ingFrontAnn.SSLPassthroughHTTPPort, ingBackAnn) + } +} + +func (c *converter) addTLS(namespace, secretName string) (string, error) { + defaultSecret := c.options.DefaultSSLSecret + tlsSecretName := defaultSecret + if secretName != "" { + tlsSecretName = namespace + "/" + secretName + } + tlsPath, err := c.cache.GetTLSSecretPath(tlsSecretName) + if err != nil { + if tlsSecretName == defaultSecret { + return "", err + } + tlsSecretErr := err + tlsPath, err = c.cache.GetTLSSecretPath(defaultSecret) + if err != nil { + return "", fmt.Errorf("failed to use custom and default certificate. custom: %v; default: %v", tlsSecretErr, err) + } + c.logger.Warn("using default certificate due to an error reading secret '%s': %v", tlsSecretName, tlsSecretErr) + } + return tlsPath, nil +} + +func (c *converter) addEndpoints(svc *api.Service, servicePort int, backend *hatypes.Backend) error { + endpoints, err := c.cache.GetEndpoints(svc) + if err != nil { + return err + } + // TODO ServiceTypeExternalName + // TODO ServiceUpstream - annotation nao documentada + // TODO DrainSupport + for _, subset := range endpoints.Subsets { + for _, port := range subset.Ports { + if int(port.Port) == servicePort && port.Protocol == api.ProtocolTCP { + for _, addr := range subset.Addresses { + backend.NewEndpoint(addr.IP, servicePort, addr.TargetRef.Namespace+"/"+addr.TargetRef.Name) + } + } + } + } + return nil +} + +func (c *converter) readAnnotations(source *ingtypes.Source, annotations map[string]string) (*ingtypes.FrontendAnnotations, *ingtypes.BackendAnnotations) { + ann := make(map[string]string, len(annotations)) + prefix := c.options.AnnotationPrefix + "/" + for annName, annValue := range annotations { + if strings.HasPrefix(annName, prefix) { + name := strings.TrimPrefix(annName, prefix) + ann[name] = annValue + } + } + frontAnn := &ingtypes.FrontendAnnotations{Source: *source} + backAnn := &ingtypes.BackendAnnotations{Source: *source} + utils.UpdateStruct(struct{}{}, c.globalConfig.ConfigDefaults, frontAnn) + utils.UpdateStruct(struct{}{}, c.globalConfig.ConfigDefaults, backAnn) + if err := utils.MergeMap(ann, frontAnn); err != nil { + c.logger.Error("error merging frontend annotations from %v: %v", source, err) + } + if err := utils.MergeMap(ann, backAnn); err != nil { + c.logger.Error("error merging backend annotations from %v: %v", source, err) + } + return frontAnn, backAnn +} + +func readServiceNamePort(backend *extensions.IngressBackend) (string, int) { + serviceName := backend.ServiceName + servicePort := backend.ServicePort.IntValue() + return serviceName, servicePort +} diff --git a/pkg/converters/ingress/ingress_test.go b/pkg/converters/ingress/ingress_test.go new file mode 100644 index 000000000..ede548852 --- /dev/null +++ b/pkg/converters/ingress/ingress_test.go @@ -0,0 +1,1244 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingress + +import ( + "strings" + "testing" + + "github.com/kylelemons/godebug/diff" + yaml "gopkg.in/yaml.v2" + api "k8s.io/api/core/v1" + extensions "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + + ing_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/helper_test" + ingtypes "github.com/jcmoraisjr/haproxy-ingress/pkg/converters/ingress/types" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy" + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" + types_helper "github.com/jcmoraisjr/haproxy-ingress/pkg/types/helper_test" +) + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * + * CORE INGRESS + * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +func TestSyncSvcNotFound(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "notfound:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: []`) + + c.compareConfigDefaultBack(` +id: system_default_8080 +endpoints: +- ip: 172.17.0.99 + port: 8080`) + + c.compareLogging(` +WARN skipping backend config of ingress 'default/echo': service not found: 'default/notfound'`) +} + +func TestSyncDefaultSvcNotFound(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.cache.SvcList = []*api.Service{} + c.createSvc1Auto() + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080`) + + c.compareConfigDefaultBack(`[]`) + + c.compareLogging(` +ERROR error reading default service: service not found: 'system/default'`) +} + +func TestSyncSingle(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo", "8080", "172.17.0.11,172.17.0.28") + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + - ip: 172.17.0.28 + port: 8080`) +} + +func TestSyncReuseBackend(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo", "8080", "172.17.0.10,172.17.0.11") + c.Sync( + c.createIng1("default/ing1", "svc1.example.com", "/", "echo:8080"), + c.createIng1("default/ing2", "svc2.example.com", "/app", "echo:8080"), + ) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.10 + port: 8080 + - ip: 172.17.0.11 + port: 8080`) +} + +func TestSyncReuseFrontend(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.21") + c.createSvc1("default/echo2", "8080", "172.17.0.22,172.17.0.23") + c.Sync( + c.createIng1("default/echo1", "echo.example.com", "/path1", "echo1:8080"), + c.createIng1("default/echo2", "echo.example.com", "/path2", "echo2:8080"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /path2 + backend: default_echo2_8080 + - path: /path1 + backend: default_echo1_8080`) +} + +func TestSyncNoEndpoint(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo", "8080", "") + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareConfigBack(` +- id: default_echo_8080`) +} + +func TestSyncInvalidEndpoint(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + delete(c.cache.EpList, "default/echo") + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareConfigBack(` +- id: default_echo_8080`) + + c.compareLogging(` +ERROR error adding endpoints of service 'default/echo': could not find endpoints for service 'default/echo'`) +} + +func TestSyncRootPathLast(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync( + c.createIng1("default/echo", "echo.example.com", "/", "echo:8080"), + c.createIng1("default/echo", "echo.example.com", "/app", "echo:8080"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080 + - path: / + backend: default_echo_8080`) +} + +func TestSyncFrontendSorted(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.createSvc1("default/echo3", "8080", "172.17.0.13") + c.Sync( + c.createIng1("default/echo1", "echo-B.example.com", "/", "echo1:8080"), + c.createIng1("default/echo2", "echo-A.example.com", "/", "echo2:8080"), + c.createIng1("default/echo3", "echo-C.example.com", "/", "echo3:8080"), + ) + + c.compareConfigFront(` +- hostname: echo-A.example.com + paths: + - path: / + backend: default_echo2_8080 +- hostname: echo-B.example.com + paths: + - path: / + backend: default_echo1_8080 +- hostname: echo-C.example.com + paths: + - path: / + backend: default_echo3_8080`) +} + +func TestSyncBackendSorted(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo3", "8080", "172.17.0.13") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.Sync( + c.createIng1("default/echo2", "echo.example.com", "/app2", "echo2:8080"), + c.createIng1("default/echo1", "echo.example.com", "/app1", "echo1:8080"), + c.createIng1("default/echo3", "echo.example.com", "/app3", "echo3:8080"), + ) + + c.compareConfigBack(` +- id: default_echo1_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 +- id: default_echo2_8080 + endpoints: + - ip: 172.17.0.12 + port: 8080 +- id: default_echo3_8080 + endpoints: + - ip: 172.17.0.13 + port: 8080`) +} + +func TestSyncRedeclarePath(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.Sync( + c.createIng1("default/echo1", "echo.example.com", "/p1", "echo1:8080"), + c.createIng1("default/echo1", "echo.example.com", "/p1", "echo2:8080"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /p1 + backend: default_echo1_8080`) + + c.compareConfigBack(` +- id: default_echo1_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080`) + + c.compareLogging(` +WARN skipping redeclared path '/p1' of ingress 'default/echo1'`) +} + +func TestSyncTLSDefault(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIngTLS1("default/echo", "echo.example.com", "/", "echo:8080", "")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/tls-default.pem`) +} + +func TestSyncTLSSecretNotFound(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIngTLS1("default/echo", "echo.example.com", "/", "echo:8080", "ing-tls")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/tls-default.pem`) + + c.compareLogging(` +WARN using default certificate due to an error reading secret 'default/ing-tls': secret not found: 'default/ing-tls'`) +} + +func TestSyncTLSCustom(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.createSecretTLS1("default/tls-echo") + c.Sync(c.createIngTLS1("default/echo", "echo.example.com", "/", "echo:8080", "tls-echo")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/default/tls-echo.pem`) +} + +func TestSyncRedeclareTLS(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.createSecretTLS1("default/tls-echo1") + c.createSecretTLS1("default/tls-echo2") + c.Sync(c.createIngTLS1("default/echo1", "echo.example.com", "/", "echo:8080", "tls-echo1:echo.example.com;tls-echo2:echo.example.com")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/default/tls-echo1.pem`) + + c.compareLogging(` +WARN skipping TLS secret 'tls-echo2' of ingress 'default/echo1': TLS of host 'echo.example.com' was already assigned`) +} + +func TestSyncRedeclareSameTLS(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.createSecretTLS1("default/tls-echo1") + c.Sync( + c.createIngTLS1("default/echo1", "echo.example.com", "/", "echo:8080", "tls-echo1:echo.example.com"), + c.createIngTLS1("default/echo2", "echo.example.com", "/app", "echo:8080", "tls-echo1:echo.example.com"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080 + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/default/tls-echo1.pem`) +} + +func TestSyncRedeclareTLSDefaultFirst(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.createSecretTLS1("default/tls-echo1") + c.Sync( + c.createIngTLS1("default/echo1", "echo.example.com", "/", "echo:8080", ""), + c.createIngTLS1("default/echo2", "echo.example.com", "/app", "echo:8080", "tls-echo1:echo.example.com"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080 + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/tls-default.pem`) + + c.compareLogging(` +WARN skipping TLS secret 'tls-echo1' of ingress 'default/echo2': TLS of host 'echo.example.com' was already assigned`) +} + +func TestSyncRedeclareTLSCustomFirst(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.createSecretTLS1("default/tls-echo1") + c.Sync( + c.createIngTLS1("default/echo1", "echo.example.com", "/", "echo:8080", "tls-echo1:echo.example.com"), + c.createIngTLS1("default/echo2", "echo.example.com", "/app", "echo:8080", ""), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080 + - path: / + backend: default_echo_8080 + tls: + tlsfilename: /tls/default/tls-echo1.pem`) + + c.compareLogging(` +WARN skipping default TLS secret of ingress 'default/echo2': TLS of host 'echo.example.com' was already assigned`) +} + +func TestSyncNoDefaultNoTLS(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.cache.SecretTLSPath = map[string]string{} + c.createSvc1Auto() + c.Sync(c.createIngTLS1("default/echo", "echo.example.com", "/", "echo:8080", "")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareLogging(` +WARN skipping default TLS secret of ingress 'default/echo': secret not found: 'system/ingress-default'`) +} + +func TestSyncNoDefaultInvalidTLS(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.cache.SecretTLSPath = map[string]string{} + c.createSvc1Auto() + c.Sync(c.createIngTLS1("default/echo", "echo.example.com", "/", "echo:8080", "tls-invalid")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) + + c.compareLogging(` +WARN skipping TLS secret 'tls-invalid' of ingress 'default/echo': failed to use custom and default certificate. custom: secret not found: 'default/tls-invalid'; default: secret not found: 'system/ingress-default'`) +} + +func TestSyncRootPathDefault(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng1("default/echo", "echo.example.com", "/app", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080`) +} + +func TestSyncPathEmpty(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng1("default/echo", "echo.example.com", "", "echo:8080")) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080`) +} + +func TestSyncBackendDefault(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng2("default/echo", "echo:8080")) + + c.compareConfigFront(` +- hostname: '*' + paths: + - path: / + backend: default_echo_8080`) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080`) +} + +func TestSyncBackendSvcNotFound(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng2("default/echo", "notfound:8080")) + + c.compareConfigFront(`[]`) + c.compareConfigBack(`[]`) + + c.compareLogging(` +WARN skipping default backend of ingress 'default/echo': service not found: 'default/notfound'`) +} + +func TestSyncBackendReuseDefaultSvc(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.Sync(c.createIng1("system/defbackend", "default.example.com", "/app", "default:8080")) + + c.compareConfigFront(` +- hostname: default.example.com + paths: + - path: /app + backend: system_default_8080`) + + c.compareConfigBack(`[]`) + + c.compareConfigDefaultBack(` +id: system_default_8080 +endpoints: +- ip: 172.17.0.99 + port: 8080`) +} + +func TestSyncDefaultBackendReusedPath1(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.Sync( + c.createIng1("default/echo1", "'*'", "/", "echo1:8080"), + c.createIng2("default/echo2", "echo2:8080"), + ) + + c.compareConfigFront(` +- hostname: '*' + paths: + - path: / + backend: default_echo1_8080`) + + c.compareConfigBack(` +- id: default_echo1_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080`) + + c.compareLogging(` +WARN skipping default backend of ingress 'default/echo2': path / was already defined on default host`) +} + +func TestSyncDefaultBackendReusedPath2(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.Sync( + c.createIng2("default/echo1", "echo1:8080"), + c.createIng1("default/echo2", "'*'", "/", "echo2:8080"), + ) + + c.compareConfigFront(` +- hostname: '*' + paths: + - path: / + backend: default_echo1_8080`) + + c.compareConfigBack(` +- id: default_echo1_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080`) + + c.compareLogging(` +WARN skipping redeclared path '/' of ingress 'default/echo2'`) +} + +func TestSyncEmptyHTTP(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.Sync(c.createIng3("default/echo")) + c.compareConfigFront(`[]`) +} + +func TestSyncEmptyHost(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng1("default/echo", "", "/", "echo:8080")) + + c.compareConfigFront(` +- hostname: '*' + paths: + - path: / + backend: default_echo_8080`) +} + +func TestSyncMultiNamespace(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("ns1/echo", "8080", "172.17.0.11") + c.createSvc1("ns2/echo", "8080", "172.17.0.12") + + c.Sync( + c.createIng1("ns1/echo", "echo.example.com", "/app1", "echo:8080"), + c.createIng1("ns2/echo", "echo.example.com", "/app2", "echo:8080"), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app2 + backend: ns2_echo_8080 + - path: /app1 + backend: ns1_echo_8080`) + + c.compareConfigBack(` +- id: ns1_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 +- id: ns2_echo_8080 + endpoints: + - ip: 172.17.0.12 + port: 8080`) +} + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * + * ANNOTATIONS + * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +func TestSyncAnnFront(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync( + c.createIng1Ann("default/echo", "echo.example.com", "/", "echo:8080", map[string]string{ + "ingress.kubernetes.io/app-root": "/app", + }), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: / + backend: default_echo_8080 + rootredirect: /app`) +} + +func TestSyncAnnFrontsConflict(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync( + c.createIng1Ann("default/echo1", "echo.example.com", "/", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "1s", + }), + c.createIng1Ann("default/echo2", "echo.example.com", "/app", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "2s", + }), + ) + + c.compareConfigFront(` +- hostname: echo.example.com + paths: + - path: /app + backend: default_echo_8080 + - path: / + backend: default_echo_8080 + timeout: + client: 1s`) + + c.compareLogging(` +INFO skipping frontend annotation(s) from ingress 'default/echo2' due to conflict: [timeout-client]`) +} + +func TestSyncAnnFronts(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync( + c.createIng1Ann("default/echo1", "echo1.example.com", "/app1", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "1s", + }), + c.createIng1Ann("default/echo2", "echo2.example.com", "/app2", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "2s", + }), + ) + + c.compareConfigFront(` +- hostname: echo1.example.com + paths: + - path: /app1 + backend: default_echo_8080 + timeout: + client: 1s +- hostname: echo2.example.com + paths: + - path: /app2 + backend: default_echo_8080 + timeout: + client: 2s`) +} + +func TestSyncAnnFrontDefault(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.SyncDef(map[string]string{"timeout-client": "1s"}, + c.createIng1Ann("default/echo1", "echo1.example.com", "/app", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "2s", + }), + c.createIng1Ann("default/echo2", "echo2.example.com", "/app", "echo:8080", map[string]string{ + "ingress.kubernetes.io/timeout-client": "1s", + }), + c.createIng1Ann("default/echo3", "echo3.example.com", "/app", "echo:8080", map[string]string{}), + ) + + c.compareConfigFront(` +- hostname: echo1.example.com + paths: + - path: /app + backend: default_echo_8080 + timeout: + client: 2s +- hostname: echo2.example.com + paths: + - path: /app + backend: default_echo_8080 + timeout: + client: 1s +- hostname: echo3.example.com + paths: + - path: /app + backend: default_echo_8080 + timeout: + client: 1s`) +} + +func TestSyncAnnBack(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1Auto() + c.Sync(c.createIng1Ann("default/echo", "echo.example.com", "/", "echo:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + })) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + balancealgorithm: leastconn`) +} + +func TestSyncAnnBackSvc(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1AutoAnn(map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.Sync(c.createIng1("default/echo", "echo.example.com", "/", "echo:8080")) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + balancealgorithm: leastconn`) +} + +func TestSyncAnnBackSvcIngConflict(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1AutoAnn(map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.Sync(c.createIng1Ann("default/echo", "echo.example.com", "/", "echo:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "first", + })) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + balancealgorithm: leastconn`) + + c.compareLogging(` +INFO skipping backend annotation(s) from ingress 'default/echo' due to conflict: [balance-algorithm]`) +} + +func TestSyncAnnBacksSvcIng(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1AutoAnn(map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.Sync(c.createIng1Ann("default/echo", "echo.example.com", "/", "echo:8080", map[string]string{ + "ingress.kubernetes.io/maxconn-server": "10", + })) + + c.compareConfigBack(` +- id: default_echo_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + balancealgorithm: leastconn + maxconnserver: 10`) +} + +func TestSyncAnnBackDefault(t *testing.T) { + c := setup(t) + defer c.teardown() + + c.createSvc1("default/echo1", "8080", "172.17.0.11") + c.createSvc1("default/echo2", "8080", "172.17.0.12") + c.createSvc1("default/echo3", "8080", "172.17.0.13") + c.createSvc1Ann("default/echo4", "8080", "172.17.0.14", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.createSvc1Ann("default/echo5", "8080", "172.17.0.15", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.createSvc1Ann("default/echo6", "8080", "172.17.0.16", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }) + c.createSvc1Ann("default/echo7", "8080", "172.17.0.17", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "roundrobin", + }) + c.SyncDef(map[string]string{"balance-algorithm": "roundrobin"}, + c.createIng1Ann("default/echo1", "echo.example.com", "/app1", "echo1:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }), + c.createIng1Ann("default/echo2", "echo.example.com", "/app2", "echo2:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "roundrobin", + }), + c.createIng1Ann("default/echo3", "echo.example.com", "/app3", "echo3:8080", map[string]string{}), + c.createIng1Ann("default/echo4", "echo.example.com", "/app4", "echo4:8080", map[string]string{}), + c.createIng1Ann("default/echo5", "echo.example.com", "/app5", "echo5:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "first", + }), + c.createIng1Ann("default/echo6", "echo.example.com", "/app6", "echo6:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "roundrobin", + }), + c.createIng1Ann("default/echo7", "echo.example.com", "/app7", "echo7:8080", map[string]string{ + "ingress.kubernetes.io/balance-algorithm": "leastconn", + }), + ) + + c.compareConfigBack(` +- id: default_echo1_8080 + endpoints: + - ip: 172.17.0.11 + port: 8080 + balancealgorithm: leastconn +- id: default_echo2_8080 + endpoints: + - ip: 172.17.0.12 + port: 8080 + balancealgorithm: roundrobin +- id: default_echo3_8080 + endpoints: + - ip: 172.17.0.13 + port: 8080 + balancealgorithm: roundrobin +- id: default_echo4_8080 + endpoints: + - ip: 172.17.0.14 + port: 8080 + balancealgorithm: leastconn +- id: default_echo5_8080 + endpoints: + - ip: 172.17.0.15 + port: 8080 + balancealgorithm: leastconn +- id: default_echo6_8080 + endpoints: + - ip: 172.17.0.16 + port: 8080 + balancealgorithm: leastconn +- id: default_echo7_8080 + endpoints: + - ip: 172.17.0.17 + port: 8080 + balancealgorithm: leastconn`) + + c.compareLogging(` +INFO skipping backend annotation(s) from ingress 'default/echo5' due to conflict: [balance-algorithm]`) +} + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * + * BUILDERS + * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +type testConfig struct { + t *testing.T + decode func(data []byte, defaults *schema.GroupVersionKind, into runtime.Object) (runtime.Object, *schema.GroupVersionKind, error) + hconfig haproxy.Config + cache *ing_helper.CacheMock + logger *types_helper.LoggerMock +} + +func setup(t *testing.T) *testConfig { + logger := &types_helper.LoggerMock{ + Logging: []string{}, + T: t, + } + c := &testConfig{ + t: t, + decode: scheme.Codecs.UniversalDeserializer().Decode, + hconfig: haproxy.CreateInstance(logger, haproxy.InstanceOptions{}).Config(), + cache: &ing_helper.CacheMock{ + SvcList: []*api.Service{}, + EpList: map[string]*api.Endpoints{}, + SecretTLSPath: map[string]string{ + "system/ingress-default": "/tls/tls-default.pem", + }, + }, + logger: logger, + } + c.createSvc1("system/default", "8080", "172.17.0.99") + return c +} + +func (c *testConfig) teardown() { + c.compareLogging("") +} + +func (c *testConfig) Sync(ing ...*extensions.Ingress) { + c.SyncDef(map[string]string{}, ing...) +} + +func (c *testConfig) SyncDef(config map[string]string, ing ...*extensions.Ingress) { + conv := NewIngressConverter( + &ingtypes.ConverterOptions{ + Cache: c.cache, + Logger: c.logger, + DefaultBackend: "system/default", + DefaultSSLSecret: "system/ingress-default", + AnnotationPrefix: "ingress.kubernetes.io", + }, + c.hconfig, + config, + ).(*converter) + conv.globalConfig = mergeConfig(&ingtypes.Config{}, config) + conv.Sync(ing) +} + +func (c *testConfig) createSvc1Auto() *api.Service { + return c.createSvc1("default/echo", "8080", "172.17.0.11") +} + +func (c *testConfig) createSvc1AutoAnn(ann map[string]string) *api.Service { + svc := c.createSvc1Auto() + svc.SetAnnotations(ann) + return svc +} + +func (c *testConfig) createSvc1Ann(name, port, endpoints string, ann map[string]string) *api.Service { + svc := c.createSvc1(name, port, endpoints) + svc.SetAnnotations(ann) + return svc +} + +func (c *testConfig) createSvc1(name, port, endpoints string) *api.Service { + sname := strings.Split(name, "/") + + svc := c.createObject(` +apiVersion: v1 +kind: Service +metadata: + name: ` + sname[1] + ` + namespace: ` + sname[0] + ` +spec: + ports: + - port: ` + port + ` + targetPort: ` + port).(*api.Service) + + c.cache.SvcList = append(c.cache.SvcList, svc) + + ep := c.createObject(` +apiVersion: v1 +kind: Endpoints +metadata: + name: ` + sname[1] + ` + namespace: ` + sname[0] + ` +subsets: +- addresses: [] + ports: + - port: ` + port + ` + protocol: TCP`).(*api.Endpoints) + + addr := []api.EndpointAddress{} + for _, e := range strings.Split(endpoints, ",") { + if e != "" { + target := &api.ObjectReference{ + Kind: "Pod", + Name: sname[1] + "-xxxxx", + Namespace: sname[0], + } + addr = append(addr, api.EndpointAddress{IP: e, TargetRef: target}) + } + } + ep.Subsets[0].Addresses = addr + c.cache.EpList[name] = ep + + return svc +} + +func (c *testConfig) createSecretTLS1(secretName string) { + c.cache.SecretTLSPath[secretName] = "/tls/" + secretName + ".pem" +} + +func (c *testConfig) createIng1(name, hostname, path, service string) *extensions.Ingress { + sname := strings.Split(name, "/") + sservice := strings.Split(service, ":") + return c.createObject(` +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: ` + sname[1] + ` + namespace: ` + sname[0] + ` +spec: + rules: + - host: ` + hostname + ` + http: + paths: + - path: ` + path + ` + backend: + serviceName: ` + sservice[0] + ` + servicePort: ` + sservice[1]).(*extensions.Ingress) +} + +func (c *testConfig) createIng1Ann(name, hostname, path, service string, ann map[string]string) *extensions.Ingress { + ing := c.createIng1(name, hostname, path, service) + ing.SetAnnotations(ann) + return ing +} + +func (c *testConfig) createIng2(name, service string) *extensions.Ingress { + sname := strings.Split(name, "/") + sservice := strings.Split(service, ":") + return c.createObject(` +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: ` + sname[1] + ` + namespace: ` + sname[0] + ` +spec: + backend: + serviceName: ` + sservice[0] + ` + servicePort: ` + sservice[1]).(*extensions.Ingress) +} + +func (c *testConfig) createIng3(name string) *extensions.Ingress { + sname := strings.Split(name, "/") + return c.createObject(` +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: ` + sname[1] + ` + namespace: ` + sname[0] + ` +spec: + rules: + - http:`).(*extensions.Ingress) +} + +func (c *testConfig) createIngTLS1(name, hostname, path, service, secretHostName string) *extensions.Ingress { + tls := []extensions.IngressTLS{} + for _, secret := range strings.Split(secretHostName, ";") { + ssecret := strings.Split(secret, ":") + hosts := []string{} + if len(ssecret) > 1 { + for _, host := range strings.Split(ssecret[1], ",") { + hosts = append(hosts, host) + } + } + if len(hosts) == 0 { + hosts = []string{hostname} + } + tls = append(tls, extensions.IngressTLS{ + Hosts: hosts, + SecretName: ssecret[0], + }) + } + ing := c.createIng1(name, hostname, path, service) + ing.Spec.TLS = tls + return ing +} + +func (c *testConfig) createObject(cfg string) runtime.Object { + obj, _, err := c.decode([]byte(cfg), nil, nil) + if err != nil { + c.t.Errorf("error decoding object: %v", err) + return nil + } + return obj +} + +func _yamlMarshal(in interface{}) string { + out, _ := yaml.Marshal(in) + return string(out) +} + +func (c *testConfig) compareText(actual, expected string) { + txt1 := "\n" + strings.Trim(expected, "\n") + txt2 := "\n" + strings.Trim(actual, "\n") + if txt1 != txt2 { + c.t.Error(diff.Diff(txt1, txt2)) + } +} + +type ( + pathMock struct { + Path string + BackendID string `yaml:"backend"` + } + timeoutMock struct { + Client string `yaml:",omitempty"` + } + tlsMock struct { + TLSFilename string `yaml:",omitempty"` + } + frontendMock struct { + Hostname string + Paths []pathMock + RootRedirect string `yaml:",omitempty"` + Timeout timeoutMock `yaml:",omitempty"` + TLS tlsMock `yaml:",omitempty"` + } +) + +func (c *testConfig) compareConfigFront(expected string) { + frontends := []frontendMock{} + for _, f := range c.hconfig.Frontends() { + paths := []pathMock{} + for _, p := range f.Paths { + paths = append(paths, pathMock{Path: p.Path, BackendID: p.BackendID}) + } + frontends = append(frontends, frontendMock{ + Hostname: f.Hostname, + Paths: paths, + RootRedirect: f.RootRedirect, + Timeout: timeoutMock{Client: f.Timeout.Client}, + TLS: tlsMock{TLSFilename: f.TLS.TLSFilename}, + }) + } + c.compareText(_yamlMarshal(frontends), expected) +} + +type ( + endpointMock struct { + IP string + Port int + } + backendMock struct { + ID string + Endpoints []endpointMock `yaml:",omitempty"` + BalanceAlgorithm string `yaml:",omitempty"` + MaxconnServer int `yaml:",omitempty"` + } +) + +func convertBackend(habackends ...*hatypes.Backend) []backendMock { + backends := []backendMock{} + for _, b := range habackends { + endpoints := []endpointMock{} + for _, e := range b.Endpoints { + endpoints = append(endpoints, endpointMock{IP: e.IP, Port: e.Port}) + } + backends = append(backends, backendMock{ + ID: b.ID, + Endpoints: endpoints, + BalanceAlgorithm: b.BalanceAlgorithm, + MaxconnServer: b.MaxconnServer, + }) + } + return backends +} + +func (c *testConfig) compareConfigBack(expected string) { + c.compareText(_yamlMarshal(convertBackend(c.hconfig.Backends()...)), expected) +} + +func (c *testConfig) compareConfigDefaultBack(expected string) { + backend := c.hconfig.DefaultBackend() + if backend != nil { + c.compareText(_yamlMarshal(convertBackend(backend)[0]), expected) + } else { + c.compareText("[]", expected) + } +} + +func (c *testConfig) compareLogging(expected string) { + c.compareText(strings.Join(c.logger.Logging, "\n"), expected) + c.logger.Logging = []string{} +} diff --git a/pkg/converters/ingress/types/annotations.go b/pkg/converters/ingress/types/annotations.go new file mode 100644 index 000000000..6e4cf3b09 --- /dev/null +++ b/pkg/converters/ingress/types/annotations.go @@ -0,0 +1,97 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +// FrontendAnnotations ... +type FrontendAnnotations struct { + Source Source `json:"-"` + AppRoot string `json:"app-root"` + AuthTLSCertHeader bool `json:"auth-tls-cert-header"` + AuthTLSErrorPage string `json:"auth-tls-error-page"` + AuthTLSSecret string `json:"auth-tls-secret"` + ServerAlias string `json:"server-alias"` + ServerAliasRegex string `json:"server-alias-regex"` + SSLPassthrough bool `json:"ssl-passthrough"` + SSLPassthroughHTTPPort int `json:"ssl-passthrough-http-port"` + TimeoutClient string `json:"timeout-client"` + TimeoutClientFin string `json:"timeout-client-fin"` +} + +// BackendAnnotations ... +type BackendAnnotations struct { + Source Source `json:"-"` + Affinity string `json:"affinity"` + AuthRealm string `json:"auth-realm"` + AuthSecret string `json:"auth-secret"` + AuthType string `json:"auth-type"` + BalanceAlgorithm string `json:"balance-algorithm"` + BlueGreenBalance string `json:"blue-green-balance"` + BlueGreenDeploy string `json:"blue-green-deploy"` + BlueGreenMode string `json:"blue-green-mode"` + ConfigBackend string `json:"config-backend"` + CookieKey string `json:"cookie-key"` + CorsAllowCredentials bool `json:"cors-allow-credentials"` + CorsAllowHeaders string `json:"cors-allow-headers"` + CorsAllowMethods string `json:"cors-allow-methods"` + CorsAllowOrigin string `json:"cors-allow-origin"` + CorsEnable bool `json:"cors-enable"` + CorsMaxAge int `json:"cors-max-age"` + HSTS bool `json:"hsts"` + HSTSIncludeSubdomains bool `json:"hsts-include-subdomains"` + HSTSMaxAge int `json:"hsts-max-age"` + HSTSPreload bool `json:"hsts-preload"` + LimitConnections int `json:"limit-connections"` + LimitRPS int `json:"limit-rps"` + LimitWhitelist string `json:"limit-whitelist"` + MaxconnServer int `json:"maxconn-server"` + MaxQueueServer int `json:"maxqueue-server"` + OAuth string `json:"oauth"` + OAuthHeaders string `json:"oauth-headers"` + OAuthURIPrefix string `json:"oauth-uri-prefix"` + ProxyBodySize string `json:"proxy-body-size"` + ProxyProtocol string `json:"proxy-protocol"` + RewriteTarget string `json:"rewrite-target"` + SlotsIncrement int `json:"slots-increment"` + SecureBackends bool `json:"secure-backends"` + SecureCrtSecret string `json:"secure-crt-secret"` + SecureVerifyCASecret string `json:"secure-verify-ca-secret"` + SessionCookieName string `json:"session-cookie-name"` + SessionCookieStrategy string `json:"session-cookie-strategy"` + SSLRedirect bool `json:"ssl-redirect"` + TimeoutConnect string `json:"timeout-connect"` + TimeoutHTTPRequest string `json:"timeout-http-request"` + TimeoutKeepAlive string `json:"timeout-keep-alive"` + TimeoutQueue string `json:"timeout-queue"` + TimeoutServer string `json:"timeout-server"` + TimeoutServerFin string `json:"timeout-server-fin"` + TimeoutStop string `json:"timeout-stop"` + TimeoutTunnel string `json:"timeout-tunnel"` + UseResolver string `json:"use-resolver"` + WAF string `json:"waf"` + WhitelistSourceRange string `json:"whitelist-source-range"` +} + +// Source ... +type Source struct { + Namespace string + Name string + Type string +} + +func (s Source) String() string { + return s.Type + " '" + s.Namespace + "/" + s.Name + "'" +} diff --git a/pkg/converters/ingress/types/config.go b/pkg/converters/ingress/types/config.go new file mode 100644 index 000000000..79a023fdd --- /dev/null +++ b/pkg/converters/ingress/types/config.go @@ -0,0 +1,97 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +// ConfigDefaults ... +type ConfigDefaults struct { + BalanceAlgorithm string `json:"balance-algorithm"` + CookieKey string `json:"cookie-key"` + HSTS bool `json:"hsts"` + HSTSIncludeSubdomains bool `json:"hsts-include-subdomains"` + HSTSMaxAge string `json:"hsts-max-age"` + HSTSPreload bool `json:"hsts-preload"` + ProxyBodySize string `json:"proxy-body-size"` + SSLRedirect bool `json:"ssl-redirect"` + TimeoutClient string `json:"timeout-client"` + TimeoutClientFin string `json:"timeout-client-fin"` + TimeoutConnect string `json:"timeout-connect"` + TimeoutHTTPRequest string `json:"timeout-http-request"` + TimeoutKeepAlive string `json:"timeout-keep-alive"` + TimeoutQueue string `json:"timeout-queue"` + TimeoutServer string `json:"timeout-server"` + TimeoutServerFin string `json:"timeout-server-fin"` + TimeoutTunnel string `json:"timeout-tunnel"` +} + +// ConfigGlobals ... +type ConfigGlobals struct { + BackendCheckInterval string `json:"backend-check-interval"` + BackendServerSlotsIncrement int `json:"backend-server-slots-increment"` + BindIPAddrHealthz string `json:"bind-ip-addr-healthz"` + BindIPAddrHTTP string `json:"bind-ip-addr-http"` + BindIPAddrStats string `json:"bind-ip-addr-stats"` + BindIPAddrTCP string `json:"bind-ip-addr-tcp"` + ConfigFrontend string `json:"config-frontend"` + ConfigGlobal string `json:"config-global"` + DNSAcceptedPayloadSize int `json:"dns-accepted-payload-size"` + DNSClusterDomain string `json:"dns-cluster-domain"` + DNSHoldObsolete string `json:"dns-hold-obsolete"` + DNSHoldValid string `json:"dns-hold-valid"` + DNSResolvers string `json:"dns-resolvers"` + DNSTimeoutRetry string `json:"dns-timeout-retry"` + DrainSupport bool `json:"drain-support"` + DynamicScaling bool `json:"dynamic-scaling"` + Forwardfor string `json:"forwardfor"` + HealthzPort int `json:"healthz-port"` + HTTPLogFormat string `json:"http-log-format"` + HTTPPort int `json:"http-port"` + HTTPSLogFormat string `json:"https-log-format"` + HTTPSPort int `json:"https-port"` + HTTPStoHTTPPort int `json:"https-to-http-port"` + LoadServerState bool `json:"load-server-state"` + MaxConnections int `json:"max-connections"` + ModsecurityEndpoints string `json:"modsecurity-endpoints"` + ModsecurityTimeoutHello string `json:"modsecurity-timeout-hello"` + ModsecurityTimeoutIdle string `json:"modsecurity-timeout-idle"` + ModsecurityTimeoutProcessing string `json:"modsecurity-timeout-processing"` + NbprocBalance int `json:"nbproc-balance"` + NbprocSSL int `json:"nbproc-ssl"` + Nbthread int `json:"nbthread"` + NoTLSRedirectLocations string `json:"no-tls-redirect-locations"` + SSLCiphers string `json:"ssl-ciphers"` + SSLDHDefaultMaxSize int `json:"ssl-dh-default-max-size"` + SSLDHParam string `json:"ssl-dh-param"` + SSLEngine string `json:"ssl-engine"` + SSLHeadersPrefix string `json:"ssl-headers-prefix"` + SSLModeAsync bool `json:"ssl-mode-async"` + SSLOptions string `json:"ssl-options"` + StatsAuth string `json:"stats-auth"` + StatsPort int `json:"stats-port"` + StatsProxyProtocol bool `json:"stats-proxy-protocol"` + StatsSSLCert string `json:"stats-ssl-cert"` + StrictHost bool `json:"strict-host"` + Syslog string `json:"syslog-endpoint"` + TCPLogFormat string `json:"tcp-log-format"` + TimeoutStop string `json:"timeout-stop"` + UseProxyProtocol bool `json:"use-proxy-protocol"` +} + +// Config ... +type Config struct { + ConfigDefaults `json:",squash"` + ConfigGlobals `json:",squash"` +} diff --git a/pkg/converters/ingress/types/interfaces.go b/pkg/converters/ingress/types/interfaces.go new file mode 100644 index 000000000..0f637cf9a --- /dev/null +++ b/pkg/converters/ingress/types/interfaces.go @@ -0,0 +1,31 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + api "k8s.io/api/core/v1" +) + +// Cache ... +type Cache interface { + GetService(serviceName string) (*api.Service, error) + GetEndpoints(service *api.Service) (*api.Endpoints, error) + GetPod(podName string) (*api.Pod, error) + GetTLSSecretPath(secretName string) (string, error) + GetCASecretPath(secretName string) (string, error) + GetSecretContent(secretName, keyName string) ([]byte, error) +} diff --git a/pkg/converters/ingress/types/options.go b/pkg/converters/ingress/types/options.go new file mode 100644 index 000000000..eac64d5cf --- /dev/null +++ b/pkg/converters/ingress/types/options.go @@ -0,0 +1,30 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// ConverterOptions ... +type ConverterOptions struct { + Logger types.Logger + Cache Cache + DefaultBackend string + DefaultSSLSecret string + AnnotationPrefix string +} diff --git a/pkg/converters/ingress/utils/utils.go b/pkg/converters/ingress/utils/utils.go new file mode 100644 index 000000000..e91ded223 --- /dev/null +++ b/pkg/converters/ingress/utils/utils.go @@ -0,0 +1,121 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "reflect" + "strings" + + "github.com/mitchellh/mapstructure" +) + +// FullQualifiedName ... +func FullQualifiedName(namespace, name string) string { + // TODO cross namespace + return fmt.Sprintf("%s/%s", namespace, name) +} + +// GCD calculates the Greatest Common Divisor between a and b +func GCD(a, b int) int { + for b != 0 { + r := a % b + a, b = b, r + } + return a +} + +// LCM calculates the Least Common Multiple between a and b +func LCM(a, b int) int { + return a * (b / GCD(a, b)) +} + +// MergeMap copy keys from a `data` map to a `resultTo` tagged object +func MergeMap(data map[string]string, resultTo interface{}) error { + if data != nil { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + Result: resultTo, + TagName: "json", + }) + if err != nil { + return fmt.Errorf("error configuring decoder: %v", err) + } + if err = decoder.Decode(data); err != nil { + return fmt.Errorf("error decoding config: %v", err) + } + } + return nil +} + +// UpdateStruct ... +// +// out param need to receive with initialized data from defaults +func UpdateStruct(defaults, in, out interface{}) (skipped []string, err []error) { + defv := reflect.Indirect(reflect.ValueOf(defaults)) + inv := reflect.Indirect(reflect.ValueOf(in)) + outv := reflect.Indirect(reflect.ValueOf(out)) + for i := 0; i < inv.NumField(); i++ { + structField := inv.Type().Field(i) + name := structField.Name + inf := inv.Field(i) + deff := defv.FieldByName(name) + outf := outv.FieldByName(name) + if !outf.IsValid() { + // output not found + continue + } + // tagName := readFieldTagName(structField) + tagName := strings.Split(structField.Tag.Get("json"), ",")[0] + if tagName == "" { + tagName = structField.Name + } + if tagName == "-" { + // ignored field + continue + } + if inf.Type().Kind() != outf.Type().Kind() { + err = append(err, fmt.Errorf( + "type mismatch on field '%s' of types '%s' and '%s'", + tagName, inv.Type().String(), outv.Type().String())) + continue + } + if !inf.CanInterface() || !outf.CanInterface() { + // unexported or something + continue + } + if inf.Interface() == outf.Interface() { + // already the same value + continue + } + if deff.IsValid() { + if outf.Interface() != deff.Interface() { + if inf.Interface() != deff.Interface() { + skipped = append(skipped, tagName) + } + continue + } + } else if outf.Interface() != reflect.Zero(outf.Type()).Interface() { + if inf.Interface() != reflect.Zero(inf.Type()).Interface() { + skipped = append(skipped, tagName) + } + continue + } + outf.Set(inf) + } + return skipped, err +} diff --git a/pkg/converters/ingress/utils/utils_test.go b/pkg/converters/ingress/utils/utils_test.go new file mode 100644 index 000000000..0e6d1e945 --- /dev/null +++ b/pkg/converters/ingress/utils/utils_test.go @@ -0,0 +1,281 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "reflect" + "testing" +) + +func TestGCD(t *testing.T) { + testCases := []struct { + a int + b int + expected int + }{ + {10, 1, 1}, + {10, 3, 1}, + {10, 4, 2}, + {10, 5, 5}, + {10, 10, 10}, + {10, 12, 2}, + {10, 15, 5}, + {10, 20, 10}, + } + for _, test := range testCases { + res := GCD(test.a, test.b) + if res != test.expected { + t.Errorf("expected %v from %v and %v, but was %v", test.expected, test.a, test.b, res) + } + } +} + +func TestLCM(t *testing.T) { + testCases := []struct { + a int + b int + expected int + }{ + {10, 1, 10}, + {10, 3, 30}, + {10, 4, 20}, + {10, 5, 10}, + {10, 10, 10}, + {10, 12, 60}, + {10, 15, 30}, + {10, 20, 20}, + } + for _, test := range testCases { + res := LCM(test.a, test.b) + if res != test.expected { + t.Errorf("expected %v from %v and %v, but was %v", test.expected, test.a, test.b, res) + } + } +} + +func TestUpdateStructSame(t *testing.T) { + type data struct { + Name string `json:"the-name,option1,option2"` + Age int + } + testCase := []struct { + defaults data + in data + out data + expout data + expskip []string + }{ + {data{}, data{"jack", 28}, data{}, data{"jack", 28}, []string{}}, + {data{}, data{"jack", 28}, data{"joe", 19}, data{"joe", 19}, []string{"the-name", "Age"}}, + {data{}, data{"joe", 28}, data{"joe", 19}, data{"joe", 19}, []string{"Age"}}, + {data{}, data{"jack", 19}, data{"joe", 19}, data{"joe", 19}, []string{"the-name"}}, + {data{"joe", 19}, data{"joe", 19}, data{"jack", 28}, data{"jack", 28}, []string{}}, + {data{"joe", 19}, data{"jack", 28}, data{"joe", 19}, data{"jack", 28}, []string{}}, + {data{"jane", 19}, data{"jack", 28}, data{"joe", 19}, data{"joe", 28}, []string{"the-name"}}, + {data{"joe", 29}, data{"jack", 28}, data{"joe", 19}, data{"jack", 19}, []string{"Age"}}, + {data{"jane", 29}, data{"jack", 28}, data{"joe", 19}, data{"joe", 19}, []string{"the-name", "Age"}}, + } + for i, test := range testCase { + skipped, err := UpdateStruct(&test.defaults, &test.in, &test.out) + if len(err) != 0 { + t.Errorf("test %d expected no error but returned %v", i, err) + } + if !reflect.DeepEqual(test.out, test.expout) { + t.Errorf("test %d expected out %v but was %v", i, test.expout, test.out) + } + if len(skipped) == len(test.expskip) { + for j := range skipped { + if skipped[j] != test.expskip[j] { + t.Errorf("test %d expected skipped msg '%v' but was '%v'", i, test.expskip[j], skipped[j]) + } + } + } else { + t.Errorf("test %d expected skipped %v but was %v", i, test.expskip, skipped) + } + } +} + +func TestUpdateStructBool(t *testing.T) { + type data struct { + Evil bool `yaml:"the-evil"` + } + testCase := []struct { + defaults data + in data + out data + expout data + expskip []string + }{ + {data{false}, data{false}, data{false}, data{false}, []string{}}, + {data{false}, data{false}, data{true}, data{true}, []string{}}, + {data{false}, data{true}, data{false}, data{true}, []string{}}, + {data{false}, data{true}, data{true}, data{true}, []string{}}, + {data{true}, data{false}, data{false}, data{false}, []string{}}, + {data{true}, data{false}, data{true}, data{false}, []string{}}, + {data{true}, data{true}, data{false}, data{false}, []string{}}, + {data{true}, data{true}, data{true}, data{true}, []string{}}, + } + for i, test := range testCase { + skipped, err := UpdateStruct(&test.defaults, &test.in, &test.out) + if len(err) != 0 { + t.Errorf("test %d expected no error but returned %v", i, err) + } + if !reflect.DeepEqual(test.out, test.expout) { + t.Errorf("test %d expected out %v but was %v", i, test.expout, test.out) + } + if len(skipped) == len(test.expskip) { + for j := range skipped { + if skipped[j] != test.expskip[j] { + t.Errorf("test %d expected skipped msg '%v' but was '%v'", i, test.expskip[j], skipped[j]) + } + } + } else { + t.Errorf("test %d expected skipped %v but was %v", i, test.expskip, skipped) + } + } +} + +func TestUpdateStructDiff(t *testing.T) { + type dataDef struct { + Name string + Email string + } + type dataIn struct { + Name string `json:"the-name"` + Age int `json:"the-age,"` + Email string `json:",opt"` + } + type dataOut struct { + Name string + Age int + } + testCase := []struct { + defaults dataDef + in dataIn + out dataOut + expout dataOut + expskip []string + }{ + {dataDef{}, dataIn{}, dataOut{"jack", 28}, dataOut{"jack", 28}, []string{}}, + {dataDef{"joe", ""}, dataIn{}, dataOut{"jack", 28}, dataOut{"jack", 28}, []string{"the-name"}}, + {dataDef{"jack", ""}, dataIn{"joe", 28, ""}, dataOut{"jack", 28}, dataOut{"joe", 28}, []string{}}, + {dataDef{"jack", ""}, dataIn{"joe", 19, ""}, dataOut{"jack", 28}, dataOut{"joe", 28}, []string{"the-age"}}, + {dataDef{}, dataIn{"jack", 28, "jack@example.com"}, dataOut{}, dataOut{"jack", 28}, []string{}}, + {dataDef{}, dataIn{"jack", 28, "jack@example.com"}, dataOut{"joe", 0}, dataOut{"joe", 28}, []string{"the-name"}}, + {dataDef{}, dataIn{"jack", 28, "jack@example.com"}, dataOut{"", 19}, dataOut{"jack", 19}, []string{"the-age"}}, + } + for i, test := range testCase { + skipped, err := UpdateStruct(test.defaults, test.in, &test.out) + if len(err) != 0 { + t.Errorf("test %d expected no error but returned %v", i, err) + } + if !reflect.DeepEqual(test.out, test.expout) { + t.Errorf("test %d expected out %v but was %v", i, test.expout, test.out) + } + if len(skipped) == len(test.expskip) { + for j := range skipped { + if skipped[j] != test.expskip[j] { + t.Errorf("test %d expected skipped msg '%v' but was '%v'", i, test.expskip[j], skipped[j]) + } + } + } else { + t.Errorf("test %d expected skipped %v but was %v", i, test.expskip, skipped) + } + } +} + +func TestUpdateStructUnexported(t *testing.T) { + type dataUnexp struct { + name string + Age int + } + type dataExp struct { + Name string + Age int + } + UpdateStruct(dataUnexp{}, dataUnexp{}, dataUnexp{}) + UpdateStruct(dataUnexp{}, dataUnexp{}, dataExp{}) + UpdateStruct(dataUnexp{}, dataExp{}, dataUnexp{}) + UpdateStruct(dataUnexp{}, dataExp{}, dataExp{}) + UpdateStruct(dataExp{}, dataUnexp{}, dataUnexp{}) + UpdateStruct(dataExp{}, dataUnexp{}, dataExp{}) + UpdateStruct(dataExp{}, dataExp{}, dataUnexp{}) + UpdateStruct(dataExp{}, dataExp{}, dataExp{}) +} + +func TestUpdateStructMismatch(t *testing.T) { + err := make([][]error, 3) + experr := make([][]string, 3) + + // 0 + type data0a struct { + Name string + Age int + } + type data0b struct { + Name bool + Age string + } + _, err[0] = UpdateStruct(data0a{}, data0a{"joe", 19}, data0b{}) + experr[0] = []string{ + "type mismatch on field 'Name' of types '%s.data0a' and '%s.data0b'", + "type mismatch on field 'Age' of types '%s.data0a' and '%s.data0b'", + } + + // 1 + type data1a struct { + Name string `yaml:",opt"` + Age int + } + type data1b struct { + Name int + Age int + } + _, err[1] = UpdateStruct(data1a{}, data1a{"joe", 19}, &data1b{}) + experr[1] = []string{ + "type mismatch on field 'Name' of types '%s.data1a' and '%s.data1b'", + } + + // 2 + type data2a struct { + Name string + Age int `yaml:""` + } + type data2b struct { + Name string + Age bool + } + _, err[2] = UpdateStruct(data2a{}, data2a{"joe", 19}, &data2b{}) + experr[2] = []string{ + "type mismatch on field 'Age' of types '%s.data2a' and '%s.data2b'", + } + + pkg := "utils" + for i := range experr { + if len(err[i]) == len(experr[i]) { + for j := range err[i] { + exp := fmt.Sprintf(experr[i][j], pkg, pkg) + if err[i][j].Error() != exp { + t.Errorf("test %d expected error msg '%s' but was '%s'", i, exp, err[i][j].Error()) + } + } + } else { + t.Errorf("test %d expected err %v but was %v", i, experr[i], err[i]) + } + } +} diff --git a/pkg/haproxy/config.go b/pkg/haproxy/config.go new file mode 100644 index 000000000..9f04e852b --- /dev/null +++ b/pkg/haproxy/config.go @@ -0,0 +1,169 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package haproxy + +import ( + "fmt" + "reflect" + "sort" + + hatypes "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/types" +) + +// Config ... +type Config interface { + AcquireFrontend(hostname string) *hatypes.Frontend + FindFrontend(hostname string) *hatypes.Frontend + AcquireBackend(namespace, name string, port int) *hatypes.Backend + FindBackend(namespace, name string, port int) *hatypes.Backend + ConfigDefaultBackend(defaultBackend *hatypes.Backend) + AddUserlist(name string, users []hatypes.User) *hatypes.Userlist + FindUserlist(name string) *hatypes.Userlist + DefaultBackend() *hatypes.Backend + Frontends() []*hatypes.Frontend + Backends() []*hatypes.Backend + Userlists() []*hatypes.Userlist + Equals(other Config) bool +} + +type config struct { + frontends []*hatypes.Frontend + backends []*hatypes.Backend + userlists []*hatypes.Userlist + defaultBackend *hatypes.Backend +} + +func createConfig() Config { + return &config{} +} + +func (c *config) AcquireFrontend(hostname string) *hatypes.Frontend { + if frontend := c.FindFrontend(hostname); frontend != nil { + return frontend + } + frontend := createFrontend(hostname) + c.frontends = append(c.frontends, frontend) + sort.Slice(c.frontends, func(i, j int) bool { + return c.frontends[i].Hostname < c.frontends[j].Hostname + }) + return frontend +} + +func (c *config) FindFrontend(hostname string) *hatypes.Frontend { + for _, f := range c.frontends { + if f.Hostname == hostname { + return f + } + } + return nil +} + +func createFrontend(hostname string) *hatypes.Frontend { + return &hatypes.Frontend{ + Hostname: hostname, + } +} + +func (c *config) AcquireBackend(namespace, name string, port int) *hatypes.Backend { + if backend := c.FindBackend(namespace, name, port); backend != nil { + return backend + } + backend := createBackend(namespace, name, port) + c.backends = append(c.backends, backend) + sort.Slice(c.backends, func(i, j int) bool { + return c.backends[i].ID < c.backends[j].ID + }) + return backend +} + +func (c *config) FindBackend(namespace, name string, port int) *hatypes.Backend { + // TODO test missing `== port` + if c.defaultBackend != nil && c.defaultBackend.Namespace == namespace && c.defaultBackend.Name == name { + return c.defaultBackend + } + for _, b := range c.backends { + if b.Namespace == namespace && b.Name == name && b.Port == port { + return b + } + } + return nil +} + +func createBackend(namespace, name string, port int) *hatypes.Backend { + return &hatypes.Backend{ + ID: buildID(namespace, name, port), + Namespace: namespace, + Name: name, + Port: port, + Endpoints: []*hatypes.Endpoint{}, + } +} + +func buildID(namespace, name string, port int) string { + return fmt.Sprintf("%s_%s_%d", namespace, name, port) +} + +func (c *config) ConfigDefaultBackend(defaultBackend *hatypes.Backend) { + c.defaultBackend = defaultBackend + // remove the default backend from the list + for i, backend := range c.backends { + if backend.ID == defaultBackend.ID { + c.backends = append(c.backends[:i], c.backends[i+1:]...) + break + } + } +} + +func (c *config) AddUserlist(name string, users []hatypes.User) *hatypes.Userlist { + userlist := &hatypes.Userlist{ + Name: name, + Users: users, + } + c.userlists = append(c.userlists, userlist) + sort.Slice(c.userlists, func(i, j int) bool { + return c.userlists[i].Name < c.userlists[j].Name + }) + return userlist +} + +func (c *config) FindUserlist(name string) *hatypes.Userlist { + return nil +} + +func (c *config) DefaultBackend() *hatypes.Backend { + return c.defaultBackend +} + +func (c *config) Frontends() []*hatypes.Frontend { + return c.frontends +} + +func (c *config) Backends() []*hatypes.Backend { + return c.backends +} + +func (c *config) Userlists() []*hatypes.Userlist { + return c.userlists +} + +func (c *config) Equals(other Config) bool { + c2, ok := other.(*config) + if !ok { + return false + } + return reflect.DeepEqual(c, c2) +} diff --git a/pkg/haproxy/config_test.go b/pkg/haproxy/config_test.go new file mode 100644 index 000000000..23aad15b1 --- /dev/null +++ b/pkg/haproxy/config_test.go @@ -0,0 +1,60 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package haproxy + +import ( + "testing" +) + +func TestAcquireFrontendDiff(t *testing.T) { + c := createConfig() + f1 := c.AcquireFrontend("h1") + f2 := c.AcquireFrontend("h2") + if f1.Hostname != "h1" { + t.Errorf("expected %v but was %v", "h1", f1.Hostname) + } + if f2.Hostname != "h2" { + t.Errorf("expected %v but was %v", "h2", f2.Hostname) + } +} + +func TestAcquireFrontendSame(t *testing.T) { + c := createConfig() + f1 := c.AcquireFrontend("h1") + f2 := c.AcquireFrontend("h1") + if f1 != f2 { + t.Errorf("expected same frontend but was different") + } +} + +func TestBuildID(t *testing.T) { + testCases := []struct { + namespace string + name string + port int + expected string + }{ + { + "default", "echo", 8080, "default_echo_8080", + }, + } + for _, test := range testCases { + if actual := buildID(test.namespace, test.name, test.port); actual != test.expected { + t.Errorf("expected '%s' but was '%s'", test.expected, actual) + } + } +} diff --git a/pkg/haproxy/dynconfig/dynconfig.go b/pkg/haproxy/dynconfig/dynconfig.go new file mode 100644 index 000000000..c436e3830 --- /dev/null +++ b/pkg/haproxy/dynconfig/dynconfig.go @@ -0,0 +1,31 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynconfig + +import ( + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// Config ... +type Config struct { + Logger types.Logger +} + +// Update ... +func (c *Config) Update() bool { + return false +} diff --git a/pkg/haproxy/instance.go b/pkg/haproxy/instance.go new file mode 100644 index 000000000..2f75d938d --- /dev/null +++ b/pkg/haproxy/instance.go @@ -0,0 +1,134 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package haproxy + +import ( + "fmt" + "os/exec" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/dynconfig" + "github.com/jcmoraisjr/haproxy-ingress/pkg/haproxy/template" + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// InstanceOptions ... +type InstanceOptions struct { + HAProxyCmd string + ReloadCmd string + HAProxyConfigFile string + ReloadStrategy string +} + +// Instance ... +type Instance interface { + CreateConfig() Config + Config() Config + Templates() *template.Config + Update() +} + +// CreateInstance ... +func CreateInstance(logger types.Logger, options InstanceOptions) Instance { + tmpl := &template.Config{ + Logger: logger, + } + dynconf := &dynconfig.Config{ + Logger: logger, + } + return &instance{ + logger: logger, + options: &options, + templates: tmpl, + dynconfig: dynconf, + curConfig: createConfig(), + } +} + +type instance struct { + logger types.Logger + options *InstanceOptions + templates *template.Config + dynconfig *dynconfig.Config + oldConfig Config + curConfig Config +} + +func (i *instance) Templates() *template.Config { + return i.templates +} + +func (i *instance) CreateConfig() Config { + i.releaseConfig() + i.oldConfig = i.curConfig + i.curConfig = createConfig() + return i.curConfig +} + +func (i *instance) Config() Config { + return i.curConfig +} + +func (i *instance) Update() { + if i.curConfig.Equals(i.oldConfig) { + i.logger.InfoV(2, "old and new configurations match, skipping reload") + return + } + updated := i.dynconfig.Update() + if err := i.templates.Write(i.Config()); err != nil { + i.logger.Error("error writing configuration: %v", err) + return + } + if err := i.check(); err != nil { + i.logger.Error("error validating config file:\n%v", err) + return + } + if updated { + i.logger.Info("HAProxy updated without needing to reload") + return + } + if err := i.reload(); err != nil { + i.logger.Error("error reloading server:\n%v", err) + return + } + i.logger.Info("HAProxy successfully reloaded") +} + +func (i *instance) check() error { + i.logger.Info("VERIFIED! (skipped)") + return nil + out, err := exec.Command(i.options.HAProxyCmd, "-c", "-f", i.options.HAProxyConfigFile).CombinedOutput() + if err != nil { + return fmt.Errorf(string(out)) + } + return nil +} + +func (i *instance) reload() error { + i.logger.Info("RELOADED! (skipped)") + return nil + out, err := exec.Command(i.options.ReloadCmd, i.options.ReloadStrategy, i.options.HAProxyConfigFile).CombinedOutput() + if len(out) > 0 { + return fmt.Errorf(string(out)) + } else if err != nil { + return err + } + return nil +} + +func (i *instance) releaseConfig() { + // TODO +} diff --git a/pkg/haproxy/template/funcmap.go b/pkg/haproxy/template/funcmap.go new file mode 100644 index 000000000..6c0d43965 --- /dev/null +++ b/pkg/haproxy/template/funcmap.go @@ -0,0 +1,44 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + "fmt" + gotemplate "text/template" + + "github.com/Masterminds/sprig" + "github.com/golang/glog" + "github.com/imdario/mergo" +) + +func createFuncMap() gotemplate.FuncMap { + fnc := gotemplate.FuncMap{ + "map": func(v ...interface{}) map[string]interface{} { + d := make(map[string]interface{}, len(v)) + for i := range v { + d[fmt.Sprintf("p%d", i+1)] = v[i] + } + return d + }, + } + if err := mergo.Merge(&fnc, sprig.TxtFuncMap()); err != nil { + glog.Fatalf("Cannot merge funcMap and sprig.FuncMap(): %v", err) + } + return fnc +} + +var funcMap = createFuncMap() diff --git a/pkg/haproxy/template/template.go b/pkg/haproxy/template/template.go new file mode 100644 index 000000000..0951f311d --- /dev/null +++ b/pkg/haproxy/template/template.go @@ -0,0 +1,103 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + gotemplate "text/template" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/types" +) + +// Config ... +type Config struct { + Logger types.Logger + templates []*template +} + +// NewTemplate ... +func (c *Config) NewTemplate(name, file, output string, rotate, startingBufferSize int) { + tmpl, err := gotemplate.New(name).Funcs(funcMap).ParseFiles(file) + if err != nil { + c.Logger.Fatal("cannot read template file: %v", err) + return // unit tests need this + } + c.templates = append(c.templates, &template{ + tmpl: tmpl, + output: output, + rotate: rotate, + rawConfig: bytes.NewBuffer(make([]byte, 0, startingBufferSize)), + }) +} + +// Write ... +func (c *Config) Write(data interface{}) error { + for _, t := range c.templates { + t.rawConfig.Reset() + if err := t.tmpl.Execute(t.rawConfig, data); err != nil { + return err + } + } + for _, t := range c.templates { + if err := t.writeToDisk(); err != nil { + return err + } + } + return nil +} + +type template struct { + tmpl *gotemplate.Template + output string + rotate int + rawConfig *bytes.Buffer + configFiles []string +} + +func (t *template) writeToDisk() error { + if t.rotate > 0 { + // Include timestamp in rotated config file names to aid troubleshooting. + // When using a single, ever-changing config file it was difficult + // to know what config was loaded by any given haproxy process + // + // rename current config file, if exists + if f, err := os.Stat(t.output); f != nil { + rotateTo := t.output + "." + f.ModTime().Format("20060102-150405.000") + if err := os.Rename(t.output, rotateTo); err != nil { + return fmt.Errorf("cannot rotate %s: %v", t.output, err) + } + t.configFiles = append(t.configFiles, rotateTo) + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot rotate %s: %v", t.output, err) + } + // remove old config files + for len(t.configFiles) > t.rotate { + name := t.configFiles[0] + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove old config file %s: %v", name, err) + } + t.configFiles = t.configFiles[1:] + } + } + if err := ioutil.WriteFile(t.output, t.rawConfig.Bytes(), 0644); err != nil { + return fmt.Errorf("cannot write %s: %v", t.output, err) + } + return nil +} diff --git a/pkg/haproxy/template/template_test.go b/pkg/haproxy/template/template_test.go new file mode 100644 index 000000000..b802d566a --- /dev/null +++ b/pkg/haproxy/template/template_test.go @@ -0,0 +1,300 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package template + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jcmoraisjr/haproxy-ingress/pkg/types/helper_test" +) + +type testConfig struct { + t *testing.T + logger *helper_test.LoggerMock + templateConfig *Config + tempdir string + tempdirOutput string +} + +func TestNewTemplateFileNotFound(t *testing.T) { + c := setup(t) + defer c.teardown() + c.templateConfig.NewTemplate("h.cfg", "/file", "/tmp/out", 0, 1024) + c.logger.CompareLogging("FATAL cannot read template file: open /file: no such file or directory") +} + +func TestWrite(t *testing.T) { + type tmplContent struct { + content string + rotate int + outputs []string + logging string + } + type data1 struct { + Name string + } + type data2 struct { + Name string + Age int + } + testCases := []struct { + templates []tmplContent + datas []interface{} + tempdir string + }{ + // 0 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + outputs: []string{"jack1"}, + }, + }, + datas: []interface{}{ + data1{Name: "jack1"}, + }, + }, + // 1 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + rotate: 1, + outputs: []string{"james1"}, + }, + }, + datas: []interface{}{ + data1{Name: "james1"}, + }, + }, + // 2 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + rotate: 1, + outputs: []string{"joe2", "joe3"}, + }, + }, + datas: []interface{}{ + data1{Name: "joe1"}, + data1{Name: "joe2"}, + data1{Name: "joe3"}, + }, + }, + // 3 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + rotate: 3, + outputs: []string{"jane1", "jane2"}, + }, + }, + datas: []interface{}{ + data1{Name: "jane1"}, + data1{Name: "jane2"}, + }, + }, + // 4 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + rotate: 3, + outputs: []string{"joseph3", "joseph4", "joseph5", "joseph6"}, + }, + }, + datas: []interface{}{ + data1{Name: "joseph1"}, + data1{Name: "joseph2"}, + data1{Name: "joseph3"}, + data1{Name: "joseph4"}, + data1{Name: "joseph5"}, + data1{Name: "joseph6"}, + }, + }, + // 5 + { + templates: []tmplContent{ + { + content: `{{ $m := map "a" .Name }}{{ $m.p1 }} - {{ $m.p2 }}`, + outputs: []string{"a - john1"}, + }, + }, + datas: []interface{}{ + data1{Name: "john1"}, + }, + }, + // 6 + { + templates: []tmplContent{ + { + content: `{{ $i := int64 "525" }}{{ $j := int64 "a525" }}{{ $i }} - {{ $j }}`, + outputs: []string{"525 - 0"}, + }, + }, + datas: []interface{}{ + data1{Name: "john1"}, + }, + }, + // 7 + { + templates: []tmplContent{ + { + content: "{{ .NameFail }}", + outputs: []string{""}, + logging: `ERROR from writer: template: h1.tmpl:1:3: executing "h1.tmpl" at <.NameFail>: can't evaluate field NameFail in type template.data1`, + }, + }, + datas: []interface{}{ + data1{Name: "joe1"}, + }, + }, + // 8 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + outputs: []string{""}, + logging: `ERROR from writer: cannot write /tmp/haproxy-ingress/cannot/stat/here/h1.cfg: open /tmp/haproxy-ingress/cannot/stat/here/h1.cfg: no such file or directory`, + }, + }, + datas: []interface{}{ + data1{Name: "joe1"}, + }, + tempdir: "/tmp/haproxy-ingress/cannot/stat/here", + }, + // 9 + { + templates: []tmplContent{ + { + content: "{{ .Name }}", + rotate: 2, + outputs: []string{"joe3", "joe4", "joe5"}, + }, + { + content: "{{ .Age }}", + rotate: 1, + outputs: []string{"34", "35"}, + }, + }, + datas: []interface{}{ + data2{Name: "joe1", Age: 31}, + data2{Name: "joe2", Age: 32}, + data2{Name: "joe3", Age: 33}, + data2{Name: "joe4", Age: 34}, + data2{Name: "joe5", Age: 35}, + }, + }, + } + + for i, test := range testCases { + c := setup(t) + if test.tempdir != "" { + c.tempdirOutput = test.tempdir + } + defer c.teardown() + for _, tmpl := range test.templates { + c.newTemplate(tmpl.content, tmpl.rotate) + } + for _, data := range test.datas { + if err := c.templateConfig.Write(data); err != nil { + c.logger.Error("from writer: %v", err) + } + // writes would override older configs + // generated in the same millisecond + time.Sleep(10 * time.Millisecond) + } + for j, tmpl := range test.templates { + outs := len(tmpl.outputs) + if tmpl.rotate < outs-1 { + t.Errorf("test %d has len(outputs)=%d, expected rotate at least %d but was %d", i, outs, outs-1, tmpl.rotate) + continue + } + outputs := c.outputs(j) + expected := tmpl.rotate + 1 + if expected > outs { + expected = outs + } + if len(outputs) != expected { + t.Errorf("test %d expected %d rotated+actual configs but found %d", i, expected, len(outputs)) + continue + } + for k, out := range tmpl.outputs { + if outputs[k] != out { + t.Errorf("test %d expected content '%s' on item %d, but found '%v'", i, out, k, outputs[k]) + } + } + c.logger.CompareLogging(tmpl.logging) + } + } +} + +func (c *testConfig) newTemplate(content string, rotate int) { + cnt := len(c.templateConfig.templates) + 1 + templateFileName := fmt.Sprintf("h%d.tmpl", cnt) + templatePath := c.tempdir + string(os.PathSeparator) + templateFileName + outputFileName := fmt.Sprintf("h%d.cfg", cnt) + outputPath := c.tempdirOutput + string(os.PathSeparator) + outputFileName + if err := ioutil.WriteFile(templatePath, []byte(content), 0644); err != nil { + c.t.Errorf("error writing template file: %v", err) + } + c.templateConfig.NewTemplate(templateFileName, templatePath, outputPath, rotate, 1024) +} + +func (c *testConfig) outputs(index int) []string { + file := c.tempdir + string(os.PathSeparator) + fmt.Sprintf("h%d.cfg", index+1) + files, _ := filepath.Glob(file + ".*") + contents := []string{} + for _, f := range files { + cnt, _ := ioutil.ReadFile(f) + contents = append(contents, string(cnt)) + } + cnt, _ := ioutil.ReadFile(file) + contents = append(contents, string(cnt)) + return contents +} + +func setup(t *testing.T) *testConfig { + logger := &helper_test.LoggerMock{T: t} + tempdir, err := ioutil.TempDir("", "") + if err != nil { + t.Errorf("error creating tempdir: %v", err) + } + return &testConfig{ + t: t, + logger: logger, + templateConfig: &Config{ + Logger: logger, + }, + tempdir: tempdir, + tempdirOutput: tempdir, + } +} + +func (c *testConfig) teardown() { + c.logger.CompareLogging("") + if err := os.RemoveAll(c.tempdir); err != nil { + c.t.Errorf("error removing tempdir: %v", err) + } +} diff --git a/pkg/haproxy/types/backend.go b/pkg/haproxy/types/backend.go new file mode 100644 index 000000000..23a6c01cc --- /dev/null +++ b/pkg/haproxy/types/backend.go @@ -0,0 +1,47 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "fmt" + "sort" +) + +// NewEndpoint ... +func (b *Backend) NewEndpoint(ip string, port int, target string) *Endpoint { + endpoint := &Endpoint{ + IP: ip, + Port: port, + Target: target, + Weight: 1, + } + b.Endpoints = append(b.Endpoints, endpoint) + sort.Slice(b.Endpoints, func(i, j int) bool { + return b.Endpoints[i].IP < b.Endpoints[j].IP + }) + return endpoint +} + +// HreqValidateUserlist ... +func (b *Backend) HreqValidateUserlist(userlist *Userlist) { + // TODO implement + b.HTTPRequests = append(b.HTTPRequests, &HTTPRequest{}) +} + +func (h *HTTPRequest) String() string { + return fmt.Sprintf("%+v", *h) +} diff --git a/pkg/haproxy/types/frontend.go b/pkg/haproxy/types/frontend.go new file mode 100644 index 000000000..a25301b65 --- /dev/null +++ b/pkg/haproxy/types/frontend.go @@ -0,0 +1,43 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "sort" +) + +// FindPath ... +func (f *Frontend) FindPath(path string) *FrontendPath { + for _, p := range f.Paths { + if p.Path == path { + return p + } + } + return nil +} + +// AddPath ... +func (f *Frontend) AddPath(backend *Backend, path string) { + f.Paths = append(f.Paths, &FrontendPath{ + Path: path, + Backend: *backend, + BackendID: backend.ID, + }) + sort.Slice(f.Paths, func(i, j int) bool { + return f.Paths[i].Path > f.Paths[j].Path + }) +} diff --git a/pkg/haproxy/types/types.go b/pkg/haproxy/types/types.go new file mode 100644 index 000000000..59f8e5728 --- /dev/null +++ b/pkg/haproxy/types/types.go @@ -0,0 +1,116 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +// Frontend ... +// +// Wildcard `*` hostname is a catch all and will be used if no other hostname, +// alias or regex matches the request. If wildcard hostname is not declared, +// the default backend will be used. If the default backend is empty, +// a default 404 page generated by HAProxy will be used. +type Frontend struct { + Hostname string + Paths []*FrontendPath + RootRedirect string + Alias FrontendAliasConfig + HTTPPassthroughBackend *Backend + SSLPassthrough bool + Timeout FrontendTimeoutConfig + TLS FrontendTLSConfig +} + +// FrontendPath ... +// +// Root context `/` path is a catch all and will be used if no other path +// matches the request on this frontend. If a root context path is not +// declared, the default backend will be used. If the default backend is +// empty, a default 404 page generated by HAProxy will be used. +type FrontendPath struct { + Path string + Backend Backend + BackendID string +} + +// FrontendAliasConfig ... +type FrontendAliasConfig struct { + AliasName string + AliasRegex string +} + +// FrontendTimeoutConfig ... +type FrontendTimeoutConfig struct { + Client string + ClientFin string +} + +// FrontendTLSConfig ... +type FrontendTLSConfig struct { + TLSFilename string + TLSFileSHA256Sum string + CAFilename string + CAFileSHA256Sum string + AddCertHeader bool + ErrorPage string +} + +// Backend ... +type Backend struct { + ID string + Namespace string + Name string + Port int + Endpoints []*Endpoint + BalanceAlgorithm string + Cookie Cookie + MaxconnServer int + ModeTCP bool + ProxyBodySize string + SSLRedirect bool + HTTPRequests []*HTTPRequest +} + +// Endpoint ... +type Endpoint struct { + IP string + Port int + Weight int + Target string +} + +// Cookie ... +type Cookie struct { + Name string + Strategy string + Key string +} + +// HTTPRequest ... +type HTTPRequest struct { +} + +// Userlist ... +type Userlist struct { + Name string + Users []User +} + +// User ... +type User struct { + Name string + Passwd string + Encrypted bool +} diff --git a/pkg/haproxy/types/userlist.go b/pkg/haproxy/types/userlist.go new file mode 100644 index 000000000..bd6fb04a5 --- /dev/null +++ b/pkg/haproxy/types/userlist.go @@ -0,0 +1,25 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +import ( + "fmt" +) + +func (u *Userlist) String() string { + return fmt.Sprintf("%+v", *u) +} diff --git a/pkg/types/helper_test/loggermock.go b/pkg/types/helper_test/loggermock.go new file mode 100644 index 000000000..08110a763 --- /dev/null +++ b/pkg/types/helper_test/loggermock.go @@ -0,0 +1,74 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helper_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/kylelemons/godebug/diff" +) + +// LoggerMock ... +type LoggerMock struct { + Logging []string + T *testing.T +} + +// Info ... +func (l *LoggerMock) Info(msg string, args ...interface{}) { + l.log("INFO", msg, args...) +} + +// InfoV ... +func (l *LoggerMock) InfoV(v int, msg string, args ...interface{}) { + l.log(fmt.Sprintf("INFO-V(%d)", v), msg, args...) +} + +// Warn ... +func (l *LoggerMock) Warn(msg string, args ...interface{}) { + l.log("WARN", msg, args...) +} + +// Error ... +func (l *LoggerMock) Error(msg string, args ...interface{}) { + l.log("ERROR", msg, args...) +} + +// Fatal ... +func (l *LoggerMock) Fatal(msg string, args ...interface{}) { + l.log("FATAL", msg, args...) +} + +func (l *LoggerMock) log(level, msg string, args ...interface{}) { + l.Logging = append(l.Logging, fmt.Sprintf(level+" "+msg, args...)) +} + +// CompareLogging ... +func (l *LoggerMock) CompareLogging(expected string) { + l.compareText(strings.Join(l.Logging, "\n"), expected) + l.Logging = []string{} +} + +func (l *LoggerMock) compareText(actual, expected string) { + txt1 := "\n" + strings.Trim(expected, "\n") + txt2 := "\n" + strings.Trim(actual, "\n") + if txt1 != txt2 { + l.T.Error(diff.Diff(txt1, txt2)) + } +} diff --git a/pkg/types/logger.go b/pkg/types/logger.go new file mode 100644 index 000000000..5a7877a6c --- /dev/null +++ b/pkg/types/logger.go @@ -0,0 +1,26 @@ +/* +Copyright 2019 The HAProxy Ingress Controller Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package types + +// Logger ... +type Logger interface { + InfoV(v int, msg string, args ...interface{}) + Info(msg string, args ...interface{}) + Warn(msg string, args ...interface{}) + Error(msg string, args ...interface{}) + Fatal(msg string, args ...interface{}) +} diff --git a/rootfs/etc/haproxy/modsecurity/spoe-modsecurity.tmpl b/rootfs/etc/haproxy/modsecurity/spoe-modsecurity-v07.tmpl similarity index 100% rename from rootfs/etc/haproxy/modsecurity/spoe-modsecurity.tmpl rename to rootfs/etc/haproxy/modsecurity/spoe-modsecurity-v07.tmpl diff --git a/rootfs/etc/haproxy/template/haproxy.tmpl b/rootfs/etc/haproxy/template/haproxy-v07.tmpl similarity index 100% rename from rootfs/etc/haproxy/template/haproxy.tmpl rename to rootfs/etc/haproxy/template/haproxy-v07.tmpl diff --git a/vendor/github.com/kylelemons/godebug/LICENSE b/vendor/github.com/kylelemons/godebug/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/vendor/github.com/kylelemons/godebug/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/kylelemons/godebug/diff/diff.go b/vendor/github.com/kylelemons/godebug/diff/diff.go new file mode 100644 index 000000000..200e596c6 --- /dev/null +++ b/vendor/github.com/kylelemons/godebug/diff/diff.go @@ -0,0 +1,186 @@ +// Copyright 2013 Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package diff implements a linewise diff algorithm. +package diff + +import ( + "bytes" + "fmt" + "strings" +) + +// Chunk represents a piece of the diff. A chunk will not have both added and +// deleted lines. Equal lines are always after any added or deleted lines. +// A Chunk may or may not have any lines in it, especially for the first or last +// chunk in a computation. +type Chunk struct { + Added []string + Deleted []string + Equal []string +} + +func (c *Chunk) empty() bool { + return len(c.Added) == 0 && len(c.Deleted) == 0 && len(c.Equal) == 0 +} + +// Diff returns a string containing a line-by-line unified diff of the linewise +// changes required to make A into B. Each line is prefixed with '+', '-', or +// ' ' to indicate if it should be added, removed, or is correct respectively. +func Diff(A, B string) string { + aLines := strings.Split(A, "\n") + bLines := strings.Split(B, "\n") + + chunks := DiffChunks(aLines, bLines) + + buf := new(bytes.Buffer) + for _, c := range chunks { + for _, line := range c.Added { + fmt.Fprintf(buf, "+%s\n", line) + } + for _, line := range c.Deleted { + fmt.Fprintf(buf, "-%s\n", line) + } + for _, line := range c.Equal { + fmt.Fprintf(buf, " %s\n", line) + } + } + return strings.TrimRight(buf.String(), "\n") +} + +// DiffChunks uses an O(D(N+M)) shortest-edit-script algorithm +// to compute the edits required from A to B and returns the +// edit chunks. +func DiffChunks(a, b []string) []Chunk { + // algorithm: http://www.xmailserver.org/diff2.pdf + + // We'll need these quantities a lot. + alen, blen := len(a), len(b) // M, N + + // At most, it will require len(a) deletions and len(b) additions + // to transform a into b. + maxPath := alen + blen // MAX + if maxPath == 0 { + // degenerate case: two empty lists are the same + return nil + } + + // Store the endpoint of the path for diagonals. + // We store only the a index, because the b index on any diagonal + // (which we know during the loop below) is aidx-diag. + // endpoint[maxPath] represents the 0 diagonal. + // + // Stated differently: + // endpoint[d] contains the aidx of a furthest reaching path in diagonal d + endpoint := make([]int, 2*maxPath+1) // V + + saved := make([][]int, 0, 8) // Vs + save := func() { + dup := make([]int, len(endpoint)) + copy(dup, endpoint) + saved = append(saved, dup) + } + + var editDistance int // D +dLoop: + for editDistance = 0; editDistance <= maxPath; editDistance++ { + // The 0 diag(onal) represents equality of a and b. Each diagonal to + // the left is numbered one lower, to the right is one higher, from + // -alen to +blen. Negative diagonals favor differences from a, + // positive diagonals favor differences from b. The edit distance to a + // diagonal d cannot be shorter than d itself. + // + // The iterations of this loop cover either odds or evens, but not both, + // If odd indices are inputs, even indices are outputs and vice versa. + for diag := -editDistance; diag <= editDistance; diag += 2 { // k + var aidx int // x + switch { + case diag == -editDistance: + // This is a new diagonal; copy from previous iter + aidx = endpoint[maxPath-editDistance+1] + 0 + case diag == editDistance: + // This is a new diagonal; copy from previous iter + aidx = endpoint[maxPath+editDistance-1] + 1 + case endpoint[maxPath+diag+1] > endpoint[maxPath+diag-1]: + // diagonal d+1 was farther along, so use that + aidx = endpoint[maxPath+diag+1] + 0 + default: + // diagonal d-1 was farther (or the same), so use that + aidx = endpoint[maxPath+diag-1] + 1 + } + // On diagonal d, we can compute bidx from aidx. + bidx := aidx - diag // y + // See how far we can go on this diagonal before we find a difference. + for aidx < alen && bidx < blen && a[aidx] == b[bidx] { + aidx++ + bidx++ + } + // Store the end of the current edit chain. + endpoint[maxPath+diag] = aidx + // If we've found the end of both inputs, we're done! + if aidx >= alen && bidx >= blen { + save() // save the final path + break dLoop + } + } + save() // save the current path + } + if editDistance == 0 { + return nil + } + chunks := make([]Chunk, editDistance+1) + + x, y := alen, blen + for d := editDistance; d > 0; d-- { + endpoint := saved[d] + diag := x - y + insert := diag == -d || (diag != d && endpoint[maxPath+diag-1] < endpoint[maxPath+diag+1]) + + x1 := endpoint[maxPath+diag] + var x0, xM, kk int + if insert { + kk = diag + 1 + x0 = endpoint[maxPath+kk] + xM = x0 + } else { + kk = diag - 1 + x0 = endpoint[maxPath+kk] + xM = x0 + 1 + } + y0 := x0 - kk + + var c Chunk + if insert { + c.Added = b[y0:][:1] + } else { + c.Deleted = a[x0:][:1] + } + if xM < x1 { + c.Equal = a[xM:][:x1-xM] + } + + x, y = x0, y0 + chunks[d] = c + } + if x > 0 { + chunks[0].Equal = a[:x] + } + if chunks[0].empty() { + chunks = chunks[1:] + } + if len(chunks) == 0 { + return nil + } + return chunks +}