diff --git a/README.md b/README.md index d356bd1f..fbacd86f 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,10 @@ builder-token-9f5cx Secret 47h ca-bundle CN=*.apps.example.com builder-token-9f5cx Secret 47h ca-bundle CN=ingress-operator@1683105658 2023-05-03 09:20:57 +0000 UTC  2025-05-02 09:20:58 +0000 UTC <...> ``` +- Retreive HAProxy backends (of any namespace) from the ingresscontroller (HAProxy) config in the must-gather: +``` +$ omc haproxy backends +NAMESPACE NAME INGRESSCONTROLLER SERVICES PORT TERMINATION +testdata rails-postgresql-example default rails-postgresql-example web(8080) http +other-testdata hello-node-secure default hello-node 8080 edge/Redirect +``` diff --git a/cmd/haproxy/backends.go b/cmd/haproxy/backends.go new file mode 100644 index 00000000..887d1ee6 --- /dev/null +++ b/cmd/haproxy/backends.go @@ -0,0 +1,213 @@ +/* +Copyright © 2023 Bram Verschueren + +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 ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "text/tabwriter" + + "github.com/gmeghnag/omc/vars" + "github.com/spf13/cobra" +) + +const haproxy_config_glob = "/ingress_controllers/*/*/haproxy.config" + +var includeOpenShiftNamespaces bool + +var Backends = &cobra.Command{ + Use: "backends", + Short: "Inspect haproxy configured backends.", + Run: func(cmd *cobra.Command, args []string) { + + // in general if omc is not invoked with a specific --namespace / -n + // option, it defaults to the user's current context project (see + // root/root.go) + // the approach for the `haproxy backends` subcommand + // differs from omc's default behaviour as here we list backends for all + // namespaces unless a specific namespace is provided through the root + // flag + var wantedNamespace string + if cmd.Flags().Changed("namespace") { + wantedNamespace = vars.Namespace + } + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', tabwriter.AlignRight) + fmt.Fprintln(writer, "NAMESPACE\tNAME\tINGRESSCONTROLLER\tSERVICES\tPORT\tTERMINATION") + for _, configfile := range haproxyConfigFiles(vars.MustGatherRootPath) { + backends := parseHAProxyConfig(configfile, wantedNamespace) + for _, b := range backends { + fmt.Fprintln(writer, b) + } + } + writer.Flush() + }, +} + +func haproxyConfigFiles(root string) []string { + pattern := root + haproxy_config_glob + files, err := filepath.Glob(pattern) + if err != nil { + fmt.Println("Error:", err) + return nil + } + return files +} + +// parse backend lines from a haproxy config file +// if a namespace is provided, only backends in that namespace are considered +func parseHAProxyConfig(filename string, wantedNamespace string) []*backend { + ic := icFromFileName(filename) + file, err := os.Open(filename) + if err != nil { + fmt.Println(err) + return nil + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + var backends []*backend + for scanner.Scan() { + line := scanner.Text() + backend := isBackendBlock(line) + if backend != nil { + if wantedNamespace == "" || backend.namespace == wantedNamespace { + for scanner.Scan() { + backendLine := scanner.Text() + serverLine := isServerLine(backendLine) + if serverLine != "" { + backend.service = serviceFromServerLine(serverLine) + backend.ingressController = ic + break + } + } + backends = append(backends, backend) + } + } + } + + if err := scanner.Err(); err != nil { + fmt.Println(err) + } + return backends +} + +func icFromFileName(filename string) string { + icRe := `ingress_controllers/([a-z0-9\-\_]*)/` + re := regexp.MustCompile(icRe) + + matches := re.FindStringSubmatch(filename) + if len(matches) != 2 { + return "" + } + return matches[1] +} + +type backend struct { + termination, namespace, routeName, ingressController string + service *service +} + +func (b backend) String() string { + terminationType := func(s string) string { + mapping := map[string]string{ + "be_edge_http": "edge/Redirect", + "be_secure": "reencrypt/Redirect", + "be_tcp": "passthrough/Redirect", + "be_http": "http", + } + return mapping[s] + } + return fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t", b.namespace, b.routeName, b.ingressController, b.service.serviceName, b.service.port, terminationType(b.termination)) +} + +func newBackendFromLine(raw []string) *backend { + return &backend{ + termination: raw[1], + namespace: raw[2], + routeName: raw[3], + } +} + +func isBackendBlock(line string) *backend { + backendRe := `^backend ([a-z0-9\-\_]*):([a-z0-9\-\_]*):([a-z0-9\-\_]*)$` + re := regexp.MustCompile(backendRe) + + matches := re.FindStringSubmatch(line) + if len(matches) != 4 { + return nil + } + + if !includeOpenShiftNamespaces { + matched, _ := regexp.MatchString(`openshift-.*`, matches[2]) + if matched { + return nil + } + } + return newBackendFromLine(matches) +} + +type service struct { + serviceName string + port *port +} + +type port struct { + portNr int + portName string +} + +func (p port) String() string { + if p.portName != "" { + return fmt.Sprintf("%s(%d)", p.portName, p.portNr) + } + return fmt.Sprintf("%d", p.portNr) +} + +// test if a line starts with ' server pod:' and return up up to the key/value as a string; empty string if no match +func isServerLine(line string) string { + serverRe := `^ server pod:([a-z0-9\-\_\:\.]*) ` + re := regexp.MustCompile(serverRe) + + matches := re.FindStringSubmatch(line) + if len(matches) != 2 { + return "" + } + return matches[1] +} + +func serviceFromServerLine(line string) *service { + parts := strings.Split(line, ":") + portNr, err := strconv.Atoi(parts[4]) + if err != nil { + fmt.Printf("Failed to convert port value (%+v) to an int.\n", parts[4]) + return &service{ + serviceName: parts[1], + port: &port{portName: parts[2]}, + } + } + + return &service{ + serviceName: parts[1], + port: &port{portNr: portNr, portName: parts[2]}, + } +} diff --git a/cmd/haproxy/backends_test.go b/cmd/haproxy/backends_test.go new file mode 100644 index 00000000..a5f627c9 --- /dev/null +++ b/cmd/haproxy/backends_test.go @@ -0,0 +1,254 @@ +/* +Copyright © 2023 Bram Verschueren + +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 ( + "reflect" + "testing" +) + +const testdata = "../../testdata/" + +func TestParseHAProxyConfig(t *testing.T) { + tests := []struct { + name, configFile, wantedNamespace string + includeOpenShiftNamespaces bool + expected []*backend + }{ + { + name: "Parse HAProxy config and extract backends", + configFile: "../../testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config", + wantedNamespace: "", + includeOpenShiftNamespaces: false, + expected: []*backend{ + &backend{namespace: "testdata", routeName: "rails-postgresql-example", ingressController: "default", service: &service{serviceName: "rails-postgresql-example", port: &port{portNr: 8080, portName: "web"}}, termination: "be_http"}, + &backend{namespace: "other-testdata", routeName: "hello-node-secure", ingressController: "default", service: &service{serviceName: "hello-node", port: &port{portNr: 8080, portName: ""}}, termination: "be_edge_http"}}, + }, + { + name: "Parse HAProxy config and extract backends including openshift-*", + configFile: "../../testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config", + wantedNamespace: "", + includeOpenShiftNamespaces: true, + expected: []*backend{ + &backend{namespace: "openshift-monitoring", routeName: "thanos-querier", ingressController: "default", service: &service{serviceName: "thanos-querier", port: &port{portNr: 9091, portName: "web"}}, termination: "be_secure"}, + &backend{namespace: "testdata", routeName: "rails-postgresql-example", ingressController: "default", service: &service{serviceName: "rails-postgresql-example", port: &port{portNr: 8080, portName: "web"}}, termination: "be_http"}, + &backend{namespace: "other-testdata", routeName: "hello-node-secure", ingressController: "default", service: &service{serviceName: "hello-node", port: &port{portNr: 8080, portName: ""}}, termination: "be_edge_http"}}, + }, + { + name: "Parse HAProxy config and extract backends matching a namespace", + configFile: "../../testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config", + wantedNamespace: "testdata", + includeOpenShiftNamespaces: true, + expected: []*backend{&backend{namespace: "testdata", routeName: "rails-postgresql-example", ingressController: "default", service: &service{serviceName: "rails-postgresql-example", port: &port{portNr: 8080, portName: "web"}}, termination: "be_http"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + includeOpenShiftNamespaces = tc.includeOpenShiftNamespaces + found := parseHAProxyConfig(tc.configFile, tc.wantedNamespace) + + if !reflect.DeepEqual(found, tc.expected) { + t.Fatalf("Expected : %+v, got: %+v", tc.expected, found) + } + }) + } +} + +func TestHaproxyConfigFiles(t *testing.T) { + tests := []struct { + name string + root string + expected []string + }{ + { + name: "Find haproxy config files using glob pattern", + root: testdata, + expected: []string{"./testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config", "./testdata/ingress_controllers/shard/router-default-xyz789-x7y8z9/haproxy.config"}, + }, + { + name: "Return empty slice if no config files found.", + root: testdata + "/fake", + expected: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := haproxyConfigFiles(tc.root) + + if len(found) != len(tc.expected) { + t.Fatalf("Expected : %v, got: %v", tc.expected, found) + } + }) + } +} + +func TestIcFromFileName(t *testing.T) { + tests := []struct { + name string + filename string + expected string + }{ + { + name: "Find IngressController name from haproxy.config file location", + filename: "./testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config", + expected: "default", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := icFromFileName(tc.filename) + + if len(found) != len(tc.expected) { + t.Fatalf("Expected : %v, got: %v", tc.expected, found) + } + }) + } +} + +func TestIsBackendBlock(t *testing.T) { + tests := []struct { + name string + line string + includeOpenShift bool + expected *backend + }{ + { + name: "return backend from valid backend block", + line: "backend be_edge_http:testdata:hello-node", + includeOpenShift: true, + expected: &backend{termination: "be_edge_http", namespace: "testdata", routeName: "hello-node", ingressController: "", service: (*service)(nil)}, + }, + { + name: "return nil from invalid backend block", + line: "nonbackend be_edge_http:testdata:hello-node", + includeOpenShift: true, + expected: nil, + }, + { + name: "return nil from valid backend block for openshift-managed route", + line: "backend be_edge_http:openshift-namespace:hello-node", + includeOpenShift: false, + expected: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + includeOpenShiftNamespaces = tc.includeOpenShift + found := isBackendBlock(tc.line) + + if !reflect.DeepEqual(tc.expected, found) { + t.Fatalf("Expected : %#v, got: %#v", tc.expected, found) + } + }) + } +} + +func TestIsServerLine(t *testing.T) { + tests := []struct { + name string + line string + expected string + }{ + { + name: "return service from valid server line", + line: " server pod:hello-node-595bfd9b77-4rm94:hello-node::10.129.2.15:8080 10.129.2.15:8080 cookie 863159b6f80f224951e08d6c052520a4 weight 1", + expected: "hello-node-595bfd9b77-4rm94:hello-node::10.129.2.15:8080", + }, + { + name: "return nil from invalid server line", + line: "nonserver po", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := isServerLine(tc.line) + + if tc.expected != found { + t.Fatalf("Expected : %#v, got: %#v", tc.expected, found) + } + }) + } +} + +func TestServiceFromServerLine(t *testing.T) { + tests := []struct { + name string + line string + expected *service + }{ + { + name: "return service from server line with named port", + line: "hello-node-595bfd9b77-4rm94:hello-node:web:10.129.2.15:8080", + expected: &service{serviceName: "hello-node", port: &port{portNr: 8080, portName: "web"}}, + }, + { + name: "return service from server line without named port", + line: "hello-node-595bfd9b77-4rm94:hello-node::10.129.2.15:8080", + expected: &service{serviceName: "hello-node", port: &port{portNr: 8080}}, + }, + { + name: "return service with portName only when portNumber is not an int", + line: "hello-node-595bfd9b77-4rm94:hello-node:web:10.129.2.15:eighthy-eighty", + expected: &service{serviceName: "hello-node", port: &port{portName: "web"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := serviceFromServerLine(tc.line) + + if !reflect.DeepEqual(tc.expected, found) { + t.Fatalf("Expected : %#v, got: %#v", tc.expected, found) + } + }) + } +} + +func TestServiceString(t *testing.T) { + tests := []struct { + name string + line string + expected string + }{ + { + name: "service.String() prints portName if included in server line", + line: "hello-node-595bfd9b77-4rm94:hello-node:web:10.129.2.15:8080", + expected: "web(8080)", + }, + { + name: "service.String() emits portName if missing from server line", + line: "hello-node-595bfd9b77-4rm94:hello-node::10.129.2.15:8080", + expected: "8080", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := serviceFromServerLine(tc.line) + printed := found.port.String() + + if tc.expected != printed { + t.Fatalf("Expected : %s, got: %s", tc.expected, printed) + } + }) + } +} diff --git a/cmd/haproxy/haproxy.go b/cmd/haproxy/haproxy.go new file mode 100644 index 00000000..4617e612 --- /dev/null +++ b/cmd/haproxy/haproxy.go @@ -0,0 +1,39 @@ +/* +Copyright © 2023 Bram Verschueren + +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 ( + "os" + + "github.com/spf13/cobra" +) + +var Haproxy = &cobra.Command{ + Use: "haproxy", + Short: "Inspect haproxy config.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + os.Exit(0) + }, +} + +func init() { + Haproxy.AddCommand( + Backends, + ) + Backends.PersistentFlags().BoolVarP(&includeOpenShiftNamespaces, "include-openshift", "", false, "Include default backends from openshift-* namespaces (excluded by default.)") +} diff --git a/root/root.go b/root/root.go index b308e231..8c4b12d2 100644 --- a/root/root.go +++ b/root/root.go @@ -27,6 +27,7 @@ import ( "github.com/gmeghnag/omc/cmd/describe" "github.com/gmeghnag/omc/cmd/etcd" "github.com/gmeghnag/omc/cmd/get" + "github.com/gmeghnag/omc/cmd/haproxy" "github.com/gmeghnag/omc/cmd/helpers" "github.com/gmeghnag/omc/cmd/logs" "github.com/gmeghnag/omc/cmd/machineconfig" @@ -86,6 +87,7 @@ func init() { // Cobra also supports local flags, which will only run // when this action is called directly. RootCmd.AddCommand( + haproxy.Haproxy, certs.Certs, cmd.VersionCmd, cmd.ProjectCmd, diff --git a/testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config b/testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config new file mode 100644 index 00000000..f4010549 --- /dev/null +++ b/testdata/ingress_controllers/default/router-default-abc123-a1b1c3/haproxy.config @@ -0,0 +1,55 @@ +# Plain http backend or backend with TLS terminated at the edge or a +# secure backend with re-encryption. +backend be_secure:openshift-monitoring:thanos-querier + mode http + option redispatch + option forwardfor + balance random + + timeout check 5000ms + http-request add-header X-Forwarded-Host %[req.hdr(host)] + http-request add-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto http if !{ ssl_fc } + http-request add-header X-Forwarded-Proto https if { ssl_fc } + http-request add-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 } + http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)] + cookie ee4d5f50aeaffc63a5a5fc30a3072a27 insert indirect nocache httponly secure attr SameSite=None + server pod:thanos-querier-7df5585db4-bdr6x:thanos-querier:web:10.128.2.13:9091 10.128.2.13:9091 cookie a01c27fee8411567757848e2fe85633b weight 1 ssl verifyhost thanos-querier.openshift-monitoring.svc verify required ca-file /var/run/configmaps/service-ca/service-ca.crt check inter 5000ms + server pod:thanos-querier-7df5585db4-wwjtd:thanos-querier:web:10.131.0.14:9091 10.131.0.14:9091 cookie 98d5cb39c441333479011f3fa9359008 weight 1 ssl verifyhost thanos-querier.openshift-monitoring.svc verify required ca-file /var/run/configmaps/service-ca/service-ca.crt check inter 5000ms + +# Plain http backend or backend with TLS terminated at the edge or a +# secure backend with re-encryption. +backend be_http:testdata:rails-postgresql-example + mode http + option redispatch + option forwardfor + balance random + + timeout check 5000ms + http-request add-header X-Forwarded-Host %[req.hdr(host)] + http-request add-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto http if !{ ssl_fc } + http-request add-header X-Forwarded-Proto https if { ssl_fc } + http-request add-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 } + http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)] + cookie 94806193aae7eda5cffd44a87b61d794 insert indirect nocache httponly + server pod:rails-postgresql-example-1-vq49n:rails-postgresql-example:web:10.129.2.11:8080 10.129.2.11:8080 cookie bece57f8fd3ee9e776b3f1746960e4d5 weight 1 + +# Plain http backend or backend with TLS terminated at the edge or a +# secure backend with re-encryption. +backend be_edge_http:other-testdata:hello-node-secure + mode http + option redispatch + option forwardfor + balance random + + timeout check 5000ms + http-request add-header X-Forwarded-Host %[req.hdr(host)] + http-request add-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto http if !{ ssl_fc } + http-request add-header X-Forwarded-Proto https if { ssl_fc } + http-request add-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 } + http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)] + cookie 82031d78198c05e5e92c34370518eba1 insert indirect nocache httponly secure attr SameSite=None + server pod:hello-node-595bfd9b77-gzsgg:hello-node::10.129.2.20:8080 10.129.2.20:8080 cookie 783548221d55df2f6bc65465b40ea3f1 weight 1 + diff --git a/testdata/ingress_controllers/shard/router-default-xyz789-x7y8z9/haproxy.config b/testdata/ingress_controllers/shard/router-default-xyz789-x7y8z9/haproxy.config new file mode 100644 index 00000000..d706897a --- /dev/null +++ b/testdata/ingress_controllers/shard/router-default-xyz789-x7y8z9/haproxy.config @@ -0,0 +1,36 @@ +# Plain http backend or backend with TLS terminated at the edge or a +# secure backend with re-encryption. +backend be_http:sharded:rails-postgresql-example + mode http + option redispatch + option forwardfor + balance random + + timeout check 5000ms + http-request add-header X-Forwarded-Host %[req.hdr(host)] + http-request add-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto http if !{ ssl_fc } + http-request add-header X-Forwarded-Proto https if { ssl_fc } + http-request add-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 } + http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)] + cookie 94806193aae7eda5cffd44a87b61d794 insert indirect nocache httponly + server pod:rails-postgresql-example-1-vq49n:rails-postgresql-example:web:10.129.2.11:8080 10.129.2.11:8080 cookie bece57f8fd3ee9e776b3f1746960e4d5 weight 1 + +# Plain http backend or backend with TLS terminated at the edge or a +# secure backend with re-encryption. +backend be_edge_http:other-sharded:hello-node-secure + mode http + option redispatch + option forwardfor + balance random + + timeout check 5000ms + http-request add-header X-Forwarded-Host %[req.hdr(host)] + http-request add-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto http if !{ ssl_fc } + http-request add-header X-Forwarded-Proto https if { ssl_fc } + http-request add-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 } + http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)] + cookie 82031d78198c05e5e92c34370518eba1 insert indirect nocache httponly secure attr SameSite=None + server pod:hello-node-595bfd9b77-gzsgg:hello-node::10.129.2.20:8080 10.129.2.20:8080 cookie 783548221d55df2f6bc65465b40ea3f1 weight 1 +