diff --git a/Makefile b/Makefile index f2c1f44c..623026d8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # Image URL to use all building/pushing image targets IMG ?= ghcr.io/telekom/das-schiff-network-operator:latest +# Sidecar image URL to use all building/pushing image targets +SIDECAR_IMG ?= ghcr.io/telekom/frr-exporter:latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.25 @@ -69,7 +71,7 @@ test: manifests generate fmt vet envtest ## Run tests. build: generate fmt vet ## Build manager binary. go build -o bin/manager cmd/manager/main.go -.PHONY: build +.PHONY: sidecar-build sidecar-build: build go build -o bin/frr-exporter cmd/frr-exporter/main.go @@ -81,10 +83,19 @@ run: manifests generate fmt vet ## Run a controller from your host. docker-build: test ## Build docker image with the manager. docker build -t ${IMG} . +.PHONY: docker-build-sidecar +docker-build-sidecar: test ## Build docker image with the manager. + docker build -t ${SIDECAR_IMG} -f frr-exporter.Dockerfile . + .PHONY: docker-push docker-push: ## Push docker image with the manager. docker push ${IMG} +.PHONY: docker-push-sidecar +docker-push-sidecar: ## Push docker image with the manager. + docker push ${SIDECAR_IMG} + + ##@ Release RELEASE_DIR ?= out @@ -108,13 +119,22 @@ endif install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. $(KUSTOMIZE) build config/crd | kubectl apply -f - +.PHONY: install-certs +install-certs: manifests kustomize ## Install certs + $(KUSTOMIZE) build config/certmanager | kubectl apply -f - + .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. $(KUSTOMIZE) build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f - +.PHONY: uninstall-certs +uninstall-certs: manifests kustomize ## Uninstall certs + $(KUSTOMIZE) build config/certmanager | kubectl delete --ignore-not-found=$(ignore-not-found) -f - + .PHONY: deploy deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + cd config/manager && $(KUSTOMIZE) edit set image frr-exporter=${SIDECAR_IMG} $(KUSTOMIZE) build config/default | kubectl apply -f - .PHONY: undeploy diff --git a/cmd/frr-exporter/main.go b/cmd/frr-exporter/main.go index c180fdfc..4a36a769 100644 --- a/cmd/frr-exporter/main.go +++ b/cmd/frr-exporter/main.go @@ -10,8 +10,10 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/telekom/das-schiff-network-operator/pkg/frr" "github.com/telekom/das-schiff-network-operator/pkg/monitoring" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log/zap" ) @@ -19,6 +21,10 @@ const ( twenty = 20 ) +var ( + setupLog = ctrl.Log.WithName("setup") +) + func main() { var addr string flag.StringVar(&addr, "listen-address", ":7082", "The address to listen on for HTTP requests.") @@ -29,6 +35,42 @@ func main() { flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + // Setup a new registry. + reg, err := setupPrometheusRegistry() + if err != nil { + log.Fatal(fmt.Errorf("prometheus registry setup error: %w", err)) + } + + setupLog.Info("configured Prometheus registry") + + endpoint, err := setupMonitoringEndpoint() + if err != nil { + log.Fatal(fmt.Errorf("error configuring monitoring endpoint: %w", err)) + } + + setupLog.Info("configured monitoring endpoint") + + // Expose the registered metrics and monitoring endpoint via HTTP. + mux := setupMux(reg, endpoint) + + server := http.Server{ + Addr: addr, + ReadHeaderTimeout: twenty * time.Second, + ReadTimeout: time.Minute, + Handler: mux, + } + + setupLog.Info("created server, starting...", "Addr", server.Addr, + "ReadHeaderTimeout", server.ReadHeaderTimeout, "ReadTimeout", server.ReadTimeout) + + // Run server + err = server.ListenAndServe() + if err != nil { + log.Fatal(fmt.Errorf("failed to start server: %w", err)) + } +} + +func setupPrometheusRegistry() (*prometheus.Registry, error) { // Create a new registry. reg := prometheus.NewRegistry() @@ -42,12 +84,16 @@ func main() { "bpf": false, }) if err != nil { - log.Fatal(fmt.Errorf("failed to create collector %w", err)) + return nil, fmt.Errorf("failed to create collector %w", err) } reg.MustRegister(collector) - // Expose the registered metrics via HTTP. - http.Handle("/metrics", promhttp.HandlerFor( + return reg, nil +} + +func setupMux(reg *prometheus.Registry, e *monitoring.Endpoint) *http.ServeMux { + mux := e.CreateMux() + mux.Handle("/metrics", promhttp.HandlerFor( reg, promhttp.HandlerOpts{ // Opt into OpenMetrics to support exemplars. @@ -55,14 +101,23 @@ func main() { Timeout: time.Minute, }, )) - server := http.Server{ - Addr: addr, - ReadHeaderTimeout: twenty * time.Second, - ReadTimeout: time.Minute, + return mux +} + +func setupMonitoringEndpoint() (*monitoring.Endpoint, error) { + clientConfig := ctrl.GetConfigOrDie() + c, err := client.New(clientConfig, client.Options{}) + if err != nil { + return nil, fmt.Errorf("error creating controller-runtime client: %w", err) } - err = server.ListenAndServe() - // Run server + + setupLog.Info("loaded kubernetes config") + + svcName, svcNamespace, err := monitoring.GetStatusServiceConfig() if err != nil { - log.Fatal(fmt.Errorf("failed to start server: %w", err)) + return nil, fmt.Errorf("error getting status service info: %w", err) } + setupLog.Info("loaded status service config") + + return monitoring.NewEndpoint(c, frr.NewCli(), svcName, svcNamespace), nil } diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index c3344e1c..e30e1a4f 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -1,6 +1,7 @@ resources: - manager.yaml - manager_master.yaml +- service.yaml # - namespace.yaml generatorOptions: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index df26ce72..16497def 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -85,6 +85,12 @@ spec: valueFrom: fieldRef: fieldPath: spec.nodeName + - name: STATUS_SVC_NAME + value: network-operator-status + - name: STATUS_SVC_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace image: frr-exporter:latest name: frr-exporter securityContext: diff --git a/config/manager/manager_master.yaml b/config/manager/manager_master.yaml index 58c08c99..cd3a4917 100644 --- a/config/manager/manager_master.yaml +++ b/config/manager/manager_master.yaml @@ -87,6 +87,12 @@ spec: valueFrom: fieldRef: fieldPath: spec.nodeName + - name: STATUS_SVC_NAME + value: network-operator-status + - name: STATUS_SVC_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace image: frr-exporter:latest name: frr-exporter securityContext: diff --git a/config/manager/service.yaml b/config/manager/service.yaml new file mode 100644 index 00000000..f85b7cad --- /dev/null +++ b/config/manager/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: status + namespace: system +spec: + type: ClusterIP + clusterIP: None + selector: + app.kubernetes.io/component: worker + app.kubernetes.io/name: network-operator + ports: + - protocol: TCP + port: 7083 + targetPort: 7083 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4521ca1c..b58d5a05 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -13,6 +13,18 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - list +- apiGroups: + - "" + resources: + - services + verbs: + - get - apiGroups: - network.schiff.telekom.de resources: diff --git a/pkg/frr/cli.go b/pkg/frr/cli.go index c1f2c314..9f48d34e 100644 --- a/pkg/frr/cli.go +++ b/pkg/frr/cli.go @@ -30,7 +30,7 @@ func getVRFInfo(vrf string) (name string, isMulti bool) { return vrf, false } -func (frr *Cli) executeWithJSON(args []string) []byte { +func (frr *Cli) ExecuteWithJSON(args []string) []byte { // Ensure JSON is always appended args = append(args, "json") return frr.execute(args) @@ -51,7 +51,7 @@ func (frr *Cli) execute(args []string) []byte { func (frr *Cli) ShowEVPNVNIDetail() (EVPNVniDetail, error) { evpnInfo := EVPNVniDetail{} - data := frr.executeWithJSON([]string{ + data := frr.ExecuteWithJSON([]string{ "show", "evpn", "vni", @@ -66,7 +66,7 @@ func (frr *Cli) ShowEVPNVNIDetail() (EVPNVniDetail, error) { func (frr *Cli) ShowBGPSummary(vrf string) (BGPVrfSummary, error) { vrfName, multiVRF := getVRFInfo(vrf) - data := frr.executeWithJSON([]string{ + data := frr.ExecuteWithJSON([]string{ "show", "bgp", "vrf", @@ -93,7 +93,7 @@ func (frr *Cli) ShowBGPSummary(vrf string) (BGPVrfSummary, error) { func (frr *Cli) showVRFVnis() (VrfVni, error) { vrfInfo := VrfVni{} - vrfVniData := frr.executeWithJSON([]string{ + vrfVniData := frr.ExecuteWithJSON([]string{ "show", "vrf", "vni", @@ -204,7 +204,7 @@ func (frr *Cli) ShowVRFs(vrfName string) (VrfVni, error) { } func (frr *Cli) getDualStackRouteSummaries(vrf string) (routeSummariesV4, routeSummariesV6 RouteSummaries, err error) { - dataV4 := frr.executeWithJSON([]string{ + dataV4 := frr.ExecuteWithJSON([]string{ "show", "ip", "route", @@ -212,7 +212,7 @@ func (frr *Cli) getDualStackRouteSummaries(vrf string) (routeSummariesV4, routeS vrf, "summary", }) - dataV6 := frr.executeWithJSON([]string{ + dataV6 := frr.ExecuteWithJSON([]string{ "show", "ipv6", "route", diff --git a/pkg/monitoring/endpoint.go b/pkg/monitoring/endpoint.go new file mode 100644 index 00000000..23efebe4 --- /dev/null +++ b/pkg/monitoring/endpoint.go @@ -0,0 +1,482 @@ +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/go-logr/logr" + "github.com/telekom/das-schiff-network-operator/pkg/healthcheck" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + all = "all" + protocolIP = "ip" + protocolIPv4 = "ipv4" + protocolIPv6 = "ipv6" + + StatusSvcNameEnv = "STATUS_SVC_NAME" + StatusSvcNamespaceEnv = "STATUS_SVC_NAMESPACE" + + vniBitLength = 24 +) + +var ( + validVRF = regexp.MustCompile("^[a-zA-Z0-9-_.]+$") + validVNI = regexp.MustCompile("^[0-9]{0,8}$") +) + +//go:generate mockgen -destination ./mock/mock_endpoint.go . FRRClient +type FRRClient interface { + ExecuteWithJSON(args []string) []byte +} + +type Endpoint struct { + cli FRRClient + c client.Client + + statusSvcName string + statusSvcNamespace string + logr.Logger +} + +// NewEndpoint creates new endpoint object. +func NewEndpoint(k8sClient client.Client, frrcli FRRClient, svcName, svcNamespace string) *Endpoint { + return &Endpoint{ + cli: frrcli, + c: k8sClient, + statusSvcName: svcName, + statusSvcNamespace: svcNamespace, + Logger: log.Log.WithName("monitoring"), + } +} + +// CreateMux configures HTTP handlers. +func (e *Endpoint) CreateMux() *http.ServeMux { + sm := http.NewServeMux() + sm.HandleFunc("/show/route", e.ShowRoute) + sm.HandleFunc("/show/bgp", e.ShowBGP) + sm.HandleFunc("/show/evpn", e.ShowEVPN) + sm.HandleFunc("/all/show/route", e.QueryAll) + sm.HandleFunc("/all/show/bgp", e.QueryAll) + sm.HandleFunc("/all/show/evpn", e.QueryAll) + e.Logger.Info("created ServeMux") + return sm +} + +// ShowRoute returns result of show ip/ipv6 route command. +// show ip/ipv6 route (vrf ) (longer-prefixes). +func (e *Endpoint) ShowRoute(w http.ResponseWriter, r *http.Request) { + e.Logger.Info("got ShowRoute request") + + vrf := r.URL.Query().Get("vrf") + if vrf == "" { + vrf = all + } + + if !validVRF.MatchString(vrf) { + e.Logger.Error(fmt.Errorf("invalid VRF value"), "error validating value") + http.Error(w, "invalid VRF value", http.StatusBadRequest) + return + } + + protocol := r.URL.Query().Get("protocol") + if protocol == "" { + protocol = protocolIP + } else if protocol != protocolIP && protocol != protocolIPv6 { + e.Logger.Error(fmt.Errorf("protocol '%s' is not supported", protocol), "protocol not supported") + http.Error(w, fmt.Sprintf("protocol '%s' is not supported", protocol), http.StatusBadRequest) + return + } + + command := []string{ + "show", + protocol, + "route", + "vrf", + vrf, + } + + if err := setInput(r, &command); err != nil { + e.Logger.Error(err, "unable to set input") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := setLongerPrefixes(r, &command); err != nil { + e.Logger.Error(err, "unable to set longer prefixes") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + e.Logger.Info("command to be executed", "command", command) + + data := e.cli.ExecuteWithJSON(command) + + result, err := withNodename(&data) + if err != nil { + e.Logger.Error(err, "unable to add nodename") + http.Error(w, fmt.Sprintf("error adding nodename: %s", err.Error()), http.StatusBadRequest) + return + } + + e.writeResponse(result, w, "ShowRoute") +} + +// ShowBGP returns a result of show bgp command. +// show bgp (vrf ) ipv4/ipv6 unicast (longer-prefixes). +// show bgp vrf summary. +func (e *Endpoint) ShowBGP(w http.ResponseWriter, r *http.Request) { + e.Logger.Info("got ShowBGP request") + vrf := r.URL.Query().Get("vrf") + if vrf == "" { + vrf = all + } + + if !validVRF.MatchString(vrf) { + e.Logger.Error(fmt.Errorf("invalid VRF value"), "error validating value") + http.Error(w, "invalid VRF value", http.StatusBadRequest) + return + } + + command, err := prepareBGPCommand(r, vrf) + if err != nil { + e.Logger.Error(err, "error preparing ShowBGP command") + http.Error(w, "error preparing ShowBGP command: "+err.Error(), http.StatusBadRequest) + return + } + + e.Logger.Info("command to be executed", "command", command) + data := e.cli.ExecuteWithJSON(command) + + result, err := withNodename(&data) + if err != nil { + e.Logger.Error(err, "error adding nodename") + http.Error(w, fmt.Sprintf("error adding nodename: %s", err.Error()), http.StatusBadRequest) + return + } + + e.writeResponse(result, w, "ShowBGP") +} + +func prepareBGPCommand(r *http.Request, vrf string) ([]string, error) { + var command []string + requestType := r.URL.Query().Get("type") + switch requestType { + case "summary": + command = []string{ + "show", + "bgp", + "vrf", + vrf, + "summary", + } + case "": + protocol := r.URL.Query().Get("protocol") + if protocol == "" || protocol == protocolIP { + protocol = protocolIPv4 + } else if protocol != protocolIPv4 && protocol != protocolIPv6 { + return nil, fmt.Errorf("protocol %s is not supported", protocol) + } + command = []string{ + "show", + "bgp", + "vrf", + vrf, + protocol, + "unicast", + } + + if err := setInput(r, &command); err != nil { + return nil, fmt.Errorf("unable to set input: %w", err) + } + + if err := setLongerPrefixes(r, &command); err != nil { + return nil, fmt.Errorf("unable to set longer prefixes: %w", err) + } + default: + return nil, fmt.Errorf("request of type '%s' is not supported", requestType) + } + + return command, nil +} + +// ShowEVPN returns result of show evpn command. +// show evpn vni json. +// show evpn rmac vni . +// show evpn mac vni . +// show evpn next-hops vni json. +func (e *Endpoint) ShowEVPN(w http.ResponseWriter, r *http.Request) { + e.Logger.Info("got ShowEVPN request") + var command []string + requestType := r.URL.Query().Get("type") + switch requestType { + case "": + command = []string{ + "show", + "evpn", + "vni", + } + case "rmac", "mac", "next-hops": + vni := r.URL.Query().Get("vni") + if vni == "" { + vni = all + } else if err := validateVNI(vni); err != nil { + e.Logger.Error(fmt.Errorf("invalid VNI value: %w", err), "error validating value") + http.Error(w, "invalid VNI value", http.StatusBadRequest) + return + } + + command = []string{ + "show", + "evpn", + requestType, + "vni", + vni, + } + default: + e.Logger.Error(fmt.Errorf("request of type '%s' is not supported", requestType), "request type not supported") + http.Error(w, fmt.Sprintf("request of type '%s' is not supported", requestType), http.StatusBadRequest) + return + } + + e.Logger.Info("command to be executed", "command", command) + data := e.cli.ExecuteWithJSON(command) + + result, err := withNodename(&data) + if err != nil { + e.Logger.Error(err, "error adding nodename") + http.Error(w, fmt.Sprintf("error adding nodename: %s", err.Error()), http.StatusBadRequest) + return + } + + e.writeResponse(result, w, "ShowEVPN") +} + +func validateVNI(vni string) error { + if !validVNI.MatchString(vni) { + return fmt.Errorf("VNI does not match regular expression") + } + value, err := strconv.Atoi(vni) + if err != nil { + return fmt.Errorf("VNI cannot be paresd to int: %w", err) + } + + if uint(value) > uint(1< 0 { + for _, err := range errs { + e.Logger.Error(err, "error querying endpoint") + } + if len(errs) == 1 { + http.Error(w, fmt.Sprintf("error querying endpoints - %s", errs[0].Error()), http.StatusInternalServerError) + return + } + http.Error(w, "multiple errors occurred while querying endpoints - please check logs for the details", http.StatusInternalServerError) + return + } + + e.writeResponse(&response, w, "QueryAll") +} + +func (e *Endpoint) writeResponse(data *[]byte, w http.ResponseWriter, requestType string) { + _, err := w.Write(*data) + if err != nil { + http.Error(w, "failed to write response: "+err.Error(), http.StatusInternalServerError) + return + } + e.Logger.Info("response written", "type", requestType) +} + +func setLongerPrefixes(r *http.Request, command *[]string) error { + longerPrefixes := r.URL.Query().Get("longer_prefixes") + if longerPrefixes != "" { + useLongerPrefixes, err := strconv.ParseBool(longerPrefixes) + if err != nil { + return fmt.Errorf("longer_prefixes value is not valid: %w", err) + } + if useLongerPrefixes { + *command = append(*command, "longer-prefixes") + } + } + return nil +} + +func setInput(r *http.Request, command *[]string) error { + input := r.URL.Query().Get("input") + if input != "" { + if _, _, err := net.ParseCIDR(input); err != nil { + return fmt.Errorf("input value is not valid: %w", err) + } + *command = append(*command, input) + } + return nil +} + +func withNodename(data *[]byte) (*[]byte, error) { + res := map[string]json.RawMessage{} + nodename := os.Getenv(healthcheck.NodenameEnv) + + result := data + if nodename != "" { + res[nodename] = json.RawMessage(*data) + var err error + *result, err = json.MarshalIndent(res, "", "\t") + if err != nil { + return nil, fmt.Errorf("error marshaling data: %w", err) + } + } + + return result, nil +} + +func passRequest(r *http.Request, addr, query string, results chan []byte, errors chan error) { + s := strings.Split(r.Host, ":") + port := "" + if len(s) > 1 { + port = s[1] + } + + protocol := "http" + if r.TLS != nil { + protocol = "https" + } + + url := fmt.Sprintf("%s://%s:%s%s", protocol, addr, port, query) + resp, err := http.Get(url) //nolint + if err != nil { + errors <- fmt.Errorf("error getting data from %s: %w", addr, err) + return + } + + data, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + errors <- fmt.Errorf("error reading response from %s: %w", addr, err) + return + } + + results <- data +} + +//+kubebuilder:rbac:groups=core,resources=pods,verbs=list + +func (e *Endpoint) getAddresses(ctx context.Context, svc *corev1.Service) ([]string, error) { + var serviceLabels labels.Set = svc.Spec.Selector + pods := &corev1.PodList{} + if err := e.c.List(ctx, pods, &client.ListOptions{ + LabelSelector: serviceLabels.AsSelector(), + Namespace: svc.Namespace, + }); err != nil { + return nil, fmt.Errorf("error getting pods: %w", err) + } + + addresses := []string{} + for i := range pods.Items { + addresses = append(addresses, pods.Items[i].Status.PodIP) + } + + return addresses, nil +} + +func queryEndpoints(r *http.Request, addr []string) ([]byte, []error) { + query := strings.ReplaceAll(r.URL.RequestURI(), "all/", "") + responses := []json.RawMessage{} + + var wg sync.WaitGroup + results := make(chan []byte, len(addr)) + requestErrors := make(chan error, len(addr)) + + for i := range addr { + wg.Add(1) + go func(i int) { + defer wg.Done() + passRequest(r, addr[i], query, results, requestErrors) + }(i) + } + + wg.Wait() + close(results) + close(requestErrors) + + if len(requestErrors) > 0 { + err := []error{} + for e := range requestErrors { + err = append(err, e) + } + return nil, err + } + + for result := range results { + responses = append(responses, json.RawMessage(result)) + } + + jsn, err := json.MarshalIndent(responses, "", "\t") + if err != nil { + return nil, []error{fmt.Errorf("error marshaling data: %w", err)} + } + + return jsn, nil +} + +// GetStatusServiceConfig gets status service's name and namespace from the environment. +func GetStatusServiceConfig() (name, namespace string, err error) { + name = os.Getenv(StatusSvcNameEnv) + if name == "" { + err = fmt.Errorf("environment variable %s is not set", StatusSvcNameEnv) + return name, namespace, err + } + + namespace = os.Getenv(StatusSvcNamespaceEnv) + if namespace == "" { + err = fmt.Errorf("environment variable %s is not set", StatusSvcNamespaceEnv) + return name, namespace, err + } + + return name, namespace, err +} diff --git a/pkg/monitoring/endpoint_test.go b/pkg/monitoring/endpoint_test.go new file mode 100644 index 00000000..13e163c2 --- /dev/null +++ b/pkg/monitoring/endpoint_test.go @@ -0,0 +1,350 @@ +package monitoring + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/telekom/das-schiff-network-operator/pkg/healthcheck" + monmock "github.com/telekom/das-schiff-network-operator/pkg/monitoring/mock" + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const ( + testSvcName = "svcName" + testSvcNamespace = "svcNamespace" +) + +var ( + fakePodsJSON = `{ + "items": [ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "labels": { + "app.kubernetes.io/component": "worker", + "app.kubernetes.io/name": "network-operator" + }, + "name": "network-operator-worker-1", + "namespace": "test-namespace" + }, + "status": { + "hostIP": "127.0.0.1", + "podIP": "127.0.0.1", + "podIPs": [ + { + "ip": "127.0.0.1" + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "labels": { + "app.kubernetes.io/component": "worker", + "app.kubernetes.io/name": "network-operator" + }, + "name": "network-operator-worker-2", + "namespace": "test-namespace" + }, + "status": { + "hostIP": "127.0.0.1", + "podIP": "127.0.0.1", + "podIPs": [ + { + "ip": "127.0.0.1" + } + ] + } + } + ] + }` + + fakeServicesJSON = `{ + "items": [ + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "test-service", + "namespace": "test-namespace", + "uid": "ca97f774-7b91-47fd-a333-5fa7ee87f940" + } + + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "test-service-no-endpoints", + "namespace": "test-namespace", + "uid": "ca97f774-7b91-47fd-a333-5fa7ee87f941" + }, + "spec": { + "selector": { + "app.kubernetes.io/component": "bad-selector", + "app.kubernetes.io/name": "bad-selector" + } + } + } + ] + }` + + fakePods *corev1.PodList + fakeServices *corev1.ServiceList + mockCtrl *gomock.Controller +) + +var _ = BeforeSuite(func() { + fakePods = &corev1.PodList{} + err := json.Unmarshal([]byte(fakePodsJSON), fakePods) + Expect(err).ShouldNot(HaveOccurred()) + fakeServices = &corev1.ServiceList{} + err = json.Unmarshal([]byte(fakeServicesJSON), fakeServices) + Expect(err).ShouldNot(HaveOccurred()) +}) + +func TestHealthCheck(t *testing.T) { + RegisterFailHandler(Fail) + mockCtrl = gomock.NewController(t) + defer mockCtrl.Finish() + t.Setenv(StatusSvcNameEnv, testSvcName) + t.Setenv(StatusSvcNamespaceEnv, testSvcNamespace) + RunSpecs(t, + "Endpoint Suite") +} + +var _ = Describe("Endpoint", func() { + fcm := monmock.NewMockFRRClient(mockCtrl) + c := fake.NewClientBuilder().Build() + e := NewEndpoint(c, fcm, "test-service", "test-namespace") + e.CreateMux() + + Context("ShowRoute()", func() { + It("returns no error", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns error if protocol is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv42", http.NoBody) + res := httptest.NewRecorder() + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if input CIDR is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv6&input=192.168.1.1/42", http.NoBody) + res := httptest.NewRecorder() + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if longer_prefixes value is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv6&input=192.168.1.1/32&longer_prefixes=notABool", http.NoBody) + res := httptest.NewRecorder() + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if VRF is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv6&input=192.168.1.1/32&longer_prefixes=true&vrf=invalid$vrf", http.NoBody) + res := httptest.NewRecorder() + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns no error", func() { + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv6&input=192.168.1.1/32&longer_prefixes=true", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowRoute(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns no error and add node name to the response if "+healthcheck.NodenameEnv+" env is set", func() { + testNodename := "test-nodename" + err := os.Setenv(healthcheck.NodenameEnv, testNodename) + Expect(err).ToNot(HaveOccurred()) + defer os.Unsetenv(healthcheck.NodenameEnv) + req := httptest.NewRequest(http.MethodGet, "/show/route?protocol=ipv6&input=192.168.1.1/32&longer_prefixes=true", http.NoBody) + resp := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowRoute(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + data, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + m := map[string]json.RawMessage{} + err = json.Unmarshal(data, &m) + Expect(err).ToNot(HaveOccurred()) + _, exists := m[testNodename] + Expect(exists).To(BeTrue()) + }) + }) + + Context("ShowBGP()", func() { + It("returns no error if type is not specified (default)", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns no error if type is summary", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?type=summary", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns error if type is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?type=ivalidType", http.NoBody) + res := httptest.NewRecorder() + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if protocol is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?protocol=ipv42", http.NoBody) + res := httptest.NewRecorder() + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if input CIDR is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?protocol=ipv4&input=192.168.1.1/42", http.NoBody) + res := httptest.NewRecorder() + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if longer_prefixes value is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?protocol=ipv4&input=192.168.1.1/32&longer_prefixes=notABool", http.NoBody) + res := httptest.NewRecorder() + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if VRF is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/bgp?vrf=invalid$VRF", http.NoBody) + res := httptest.NewRecorder() + e.ShowBGP(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Context("ShowEVPN()", func() { + It("returns no error if type is not specified (default)", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns no error if type is rmac", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=rmac", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns no error if type is mac", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=mac", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns no error if type is next-hops", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=next-hops", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + It("returns error if type is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=invalidType", http.NoBody) + res := httptest.NewRecorder() + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if VNI is invalid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=rmac&vni=invalidVNI", http.NoBody) + res := httptest.NewRecorder() + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error if VNI value is bigger than 24bit uint", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=rmac&vni=96777215", http.NoBody) + res := httptest.NewRecorder() + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusBadRequest)) + }) + It("returns error no error if VNI is valid", func() { + req := httptest.NewRequest(http.MethodGet, "/show/evpn?type=rmac&vni=42", http.NoBody) + res := httptest.NewRecorder() + fcm.EXPECT().ExecuteWithJSON(gomock.Any()).Return([]byte{'{', '}'}) + e.ShowEVPN(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + }) + Context("PassRequest()", func() { + It("returns error if there are no instances to query", func() { + c := fake.NewClientBuilder().WithRuntimeObjects(fakePods, fakeServices).Build() + e := NewEndpoint(c, fcm, "test-service-no-endpoints", "test-namespace") + req := httptest.NewRequest(http.MethodGet, "/all/show/route", http.NoBody) + res := httptest.NewRecorder() + e.QueryAll(res, req) + Expect(res.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("returns error if cannot get data from the endpoint", func() { + c := fake.NewClientBuilder().WithRuntimeObjects(fakePods, fakeServices).Build() + e := NewEndpoint(c, fcm, "test-service", "test-namespace") + req := httptest.NewRequest(http.MethodGet, "/all/show/route", http.NoBody) + res := httptest.NewRecorder() + e.QueryAll(res, req) + Expect(res.Code).To(Equal(http.StatusInternalServerError)) + }) + It("returns error if request was properly passed to the endpoint but the response is malformed", func() { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "invalidJson") + })) + defer svr.Close() + + c := fake.NewClientBuilder().WithRuntimeObjects(fakePods, fakeServices).Build() + e := NewEndpoint(c, fcm, "test-service", "test-namespace") + req := httptest.NewRequest(http.MethodGet, svr.URL, http.NoBody) + res := httptest.NewRecorder() + + e.QueryAll(res, req) + Expect(res.Code).To(Equal(http.StatusInternalServerError)) + }) + It("returns no error if request was properly passed to the endpoint", func() { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "{}") + })) + defer svr.Close() + + c := fake.NewClientBuilder().WithRuntimeObjects(fakePods, fakeServices).Build() + e := NewEndpoint(c, fcm, "test-service", "test-namespace") + req := httptest.NewRequest(http.MethodGet, svr.URL+"?service=test-service&namespace=test-namespace", http.NoBody) + res := httptest.NewRecorder() + + e.QueryAll(res, req) + Expect(res.Code).To(Equal(http.StatusOK)) + }) + }) + Context("GetStatusServiceConfig()", func() { + It("returns no error if envs are set", func() { + name, namespace, err := GetStatusServiceConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(name).To(Equal(testSvcName)) + Expect(namespace).To(Equal(testSvcNamespace)) + }) + }) +}) diff --git a/pkg/monitoring/mock/mock_endpoint.go b/pkg/monitoring/mock/mock_endpoint.go new file mode 100644 index 00000000..7b0f4481 --- /dev/null +++ b/pkg/monitoring/mock/mock_endpoint.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/telekom/das-schiff-network-operator/pkg/monitoring (interfaces: FRRClient) + +// Package mock_monitoring is a generated GoMock package. +package mock_monitoring + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockFRRClient is a mock of FRRClient interface. +type MockFRRClient struct { + ctrl *gomock.Controller + recorder *MockFRRClientMockRecorder +} + +// MockFRRClientMockRecorder is the mock recorder for MockFRRClient. +type MockFRRClientMockRecorder struct { + mock *MockFRRClient +} + +// NewMockFRRClient creates a new mock instance. +func NewMockFRRClient(ctrl *gomock.Controller) *MockFRRClient { + mock := &MockFRRClient{ctrl: ctrl} + mock.recorder = &MockFRRClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFRRClient) EXPECT() *MockFRRClientMockRecorder { + return m.recorder +} + +// ExecuteWithJSON mocks base method. +func (m *MockFRRClient) ExecuteWithJSON(arg0 []string) []byte { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecuteWithJSON", arg0) + ret0, _ := ret[0].([]byte) + return ret0 +} + +// ExecuteWithJSON indicates an expected call of ExecuteWithJSON. +func (mr *MockFRRClientMockRecorder) ExecuteWithJSON(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteWithJSON", reflect.TypeOf((*MockFRRClient)(nil).ExecuteWithJSON), arg0) +}