diff --git a/pkg/handler/k8s.go b/pkg/handler/k8s.go new file mode 100644 index 000000000..a40ec9496 --- /dev/null +++ b/pkg/handler/k8s.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/netobserv/network-observability-console-plugin/pkg/kubernetes/auth" + "github.com/netobserv/network-observability-console-plugin/pkg/kubernetes/resources" + "github.com/netobserv/network-observability-console-plugin/pkg/utils" + + kerr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func (h *Handlers) GetUDNIdss(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + token, err := auth.GetUserToken(r.Header) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + } + + cudns, err := resources.List(ctx, token, schema.GroupVersionResource{ + Group: "k8s.ovn.org", + Version: "v1", + Resource: "clusteruserdefinednetworks", + }) + if err != nil { + var k8sErr *kerr.StatusError + if errors.As(err, &k8sErr) { + writeError(w, int(k8sErr.ErrStatus.Code), err.Error()) + } else { + writeError(w, http.StatusInternalServerError, err.Error()) + } + } + + udns, err := resources.List(ctx, token, schema.GroupVersionResource{ + Group: "k8s.ovn.org", + Version: "v1", + Resource: "userdefinednetworks", + }) + if err != nil { + var k8sErr *kerr.StatusError + if errors.As(err, &k8sErr) { + writeError(w, int(k8sErr.ErrStatus.Code), err.Error()) + } else { + writeError(w, http.StatusInternalServerError, err.Error()) + } + } + + values := []string{} + for _, cudn := range cudns { + md := cudn.Object["metadata"].(map[string]interface{}) + values = append(values, fmt.Sprintf("%s", md["name"])) + } + for _, udn := range udns { + md := udn.Object["metadata"].(map[string]interface{}) + values = append(values, fmt.Sprintf("%s.%s", md["namespace"], md["name"])) + } + writeJSON(w, http.StatusOK, utils.NonEmpty(utils.Dedup(values))) + } +} diff --git a/pkg/kubernetes/auth/check_auth.go b/pkg/kubernetes/auth/check_auth.go index 7a8874f5b..81ffbfccb 100644 --- a/pkg/kubernetes/auth/check_auth.go +++ b/pkg/kubernetes/auth/check_auth.go @@ -73,7 +73,7 @@ func (b *DenyAllChecker) CheckAdmin(_ context.Context, _ http.Header) error { return errors.New("deny all auth mode selected") } -func getUserToken(header http.Header) (string, error) { +func GetUserToken(header http.Header) (string, error) { authValue := header.Get(AuthHeader) if authValue != "" { parts := strings.Split(authValue, "Bearer ") @@ -130,7 +130,7 @@ type BearerTokenChecker struct { func (c *BearerTokenChecker) CheckAuth(ctx context.Context, header http.Header) error { hlog.Debug("Checking authenticated user") - token, err := getUserToken(header) + token, err := GetUserToken(header) if err != nil { return err } @@ -145,7 +145,7 @@ func (c *BearerTokenChecker) CheckAuth(ctx context.Context, header http.Header) func (c *BearerTokenChecker) CheckAdmin(ctx context.Context, header http.Header) error { hlog.Debug("Checking admin user") - token, err := getUserToken(header) + token, err := GetUserToken(header) if err != nil { return err } diff --git a/pkg/kubernetes/resources/resources.go b/pkg/kubernetes/resources/resources.go new file mode 100644 index 000000000..fcfe273aa --- /dev/null +++ b/pkg/kubernetes/resources/resources.go @@ -0,0 +1,32 @@ +package resources + +import ( + "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" +) + +func List(ctx context.Context, token string, gvr schema.GroupVersionResource) ([]unstructured.Unstructured, error) { + config, err := rest.InClusterConfig() + if err != nil { + return nil, err + } + config.BearerToken = token + config.BearerTokenFile = "" + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, err + } + + // Retrieve the custom resource + list, err := dynamicClient.Resource(gvr).List(ctx, v1.ListOptions{}) + if err != nil { + return nil, err + } + return list.Items, err +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 09e6b7c16..a4f0a6eb5 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -56,6 +56,9 @@ func setupRoutes(ctx context.Context, cfg *config.Config, authChecker auth.Check api.HandleFunc("/resources/namespaces", h.GetNamespaces(ctx)) api.HandleFunc("/resources/names", h.GetNames(ctx)) + // K8S endpoints + api.HandleFunc("/k8s/resources/udnIds", h.GetUDNIdss(ctx)) + // Frontend files api.HandleFunc("/frontend-config", h.GetFrontendConfig()) r.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/dist/"))) diff --git a/vendor/k8s.io/client-go/dynamic/interface.go b/vendor/k8s.io/client-go/dynamic/interface.go new file mode 100644 index 000000000..a310b63e5 --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/interface.go @@ -0,0 +1,63 @@ +/* +Copyright 2016 The Kubernetes 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 dynamic + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" +) + +type Interface interface { + Resource(resource schema.GroupVersionResource) NamespaceableResourceInterface +} + +type ResourceInterface interface { + Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) + Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) + UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) + Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error + DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error + Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) + List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) + Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) + Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) + ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) +} + +type NamespaceableResourceInterface interface { + Namespace(string) ResourceInterface + ResourceInterface +} + +// APIPathResolverFunc knows how to convert a groupVersion to its API path. The Kind field is optional. +// TODO find a better place to move this for existing callers +type APIPathResolverFunc func(kind schema.GroupVersionKind) string + +// LegacyAPIPathResolverFunc can resolve paths properly with the legacy API. +// TODO find a better place to move this for existing callers +func LegacyAPIPathResolverFunc(kind schema.GroupVersionKind) string { + if len(kind.Group) == 0 { + return "/api" + } + return "/apis" +} diff --git a/vendor/k8s.io/client-go/dynamic/scheme.go b/vendor/k8s.io/client-go/dynamic/scheme.go new file mode 100644 index 000000000..3168c872c --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/scheme.go @@ -0,0 +1,108 @@ +/* +Copyright 2018 The Kubernetes 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 dynamic + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +var watchScheme = runtime.NewScheme() +var basicScheme = runtime.NewScheme() +var deleteScheme = runtime.NewScheme() +var parameterScheme = runtime.NewScheme() +var deleteOptionsCodec = serializer.NewCodecFactory(deleteScheme) +var dynamicParameterCodec = runtime.NewParameterCodec(parameterScheme) + +var versionV1 = schema.GroupVersion{Version: "v1"} + +func init() { + metav1.AddToGroupVersion(watchScheme, versionV1) + metav1.AddToGroupVersion(basicScheme, versionV1) + metav1.AddToGroupVersion(parameterScheme, versionV1) + metav1.AddToGroupVersion(deleteScheme, versionV1) +} + +// basicNegotiatedSerializer is used to handle discovery and error handling serialization +type basicNegotiatedSerializer struct{} + +func (s basicNegotiatedSerializer) SupportedMediaTypes() []runtime.SerializerInfo { + return []runtime.SerializerInfo{ + { + MediaType: "application/json", + MediaTypeType: "application", + MediaTypeSubType: "json", + EncodesAsText: true, + Serializer: json.NewSerializer(json.DefaultMetaFactory, unstructuredCreater{basicScheme}, unstructuredTyper{basicScheme}, false), + PrettySerializer: json.NewSerializer(json.DefaultMetaFactory, unstructuredCreater{basicScheme}, unstructuredTyper{basicScheme}, true), + StreamSerializer: &runtime.StreamSerializerInfo{ + EncodesAsText: true, + Serializer: json.NewSerializer(json.DefaultMetaFactory, basicScheme, basicScheme, false), + Framer: json.Framer, + }, + }, + } +} + +func (s basicNegotiatedSerializer) EncoderForVersion(encoder runtime.Encoder, gv runtime.GroupVersioner) runtime.Encoder { + return runtime.WithVersionEncoder{ + Version: gv, + Encoder: encoder, + ObjectTyper: unstructuredTyper{basicScheme}, + } +} + +func (s basicNegotiatedSerializer) DecoderToVersion(decoder runtime.Decoder, gv runtime.GroupVersioner) runtime.Decoder { + return decoder +} + +type unstructuredCreater struct { + nested runtime.ObjectCreater +} + +func (c unstructuredCreater) New(kind schema.GroupVersionKind) (runtime.Object, error) { + out, err := c.nested.New(kind) + if err == nil { + return out, nil + } + out = &unstructured.Unstructured{} + out.GetObjectKind().SetGroupVersionKind(kind) + return out, nil +} + +type unstructuredTyper struct { + nested runtime.ObjectTyper +} + +func (t unstructuredTyper) ObjectKinds(obj runtime.Object) ([]schema.GroupVersionKind, bool, error) { + kinds, unversioned, err := t.nested.ObjectKinds(obj) + if err == nil { + return kinds, unversioned, nil + } + if _, ok := obj.(runtime.Unstructured); ok && !obj.GetObjectKind().GroupVersionKind().Empty() { + return []schema.GroupVersionKind{obj.GetObjectKind().GroupVersionKind()}, false, nil + } + return nil, false, err +} + +func (t unstructuredTyper) Recognizes(gvk schema.GroupVersionKind) bool { + return true +} diff --git a/vendor/k8s.io/client-go/dynamic/simple.go b/vendor/k8s.io/client-go/dynamic/simple.go new file mode 100644 index 000000000..326da7cbd --- /dev/null +++ b/vendor/k8s.io/client-go/dynamic/simple.go @@ -0,0 +1,480 @@ +/* +Copyright 2018 The Kubernetes 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 dynamic + +import ( + "context" + "fmt" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/consistencydetector" + "k8s.io/client-go/util/watchlist" + "k8s.io/klog/v2" +) + +type DynamicClient struct { + client rest.Interface +} + +var _ Interface = &DynamicClient{} + +// ConfigFor returns a copy of the provided config with the +// appropriate dynamic client defaults set. +func ConfigFor(inConfig *rest.Config) *rest.Config { + config := rest.CopyConfig(inConfig) + config.AcceptContentTypes = "application/json" + config.ContentType = "application/json" + config.NegotiatedSerializer = basicNegotiatedSerializer{} // this gets used for discovery and error handling types + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + return config +} + +// New creates a new DynamicClient for the given RESTClient. +func New(c rest.Interface) *DynamicClient { + return &DynamicClient{client: c} +} + +// NewForConfigOrDie creates a new DynamicClient for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *DynamicClient { + ret, err := NewForConfig(c) + if err != nil { + panic(err) + } + return ret +} + +// NewForConfig creates a new dynamic client or returns an error. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(inConfig *rest.Config) (*DynamicClient, error) { + config := ConfigFor(inConfig) + + httpClient, err := rest.HTTPClientFor(config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(config, httpClient) +} + +// NewForConfigAndClient creates a new dynamic client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(inConfig *rest.Config, h *http.Client) (*DynamicClient, error) { + config := ConfigFor(inConfig) + // for serializing the options + config.GroupVersion = &schema.GroupVersion{} + config.APIPath = "/if-you-see-this-search-for-the-break" + + restClient, err := rest.RESTClientForConfigAndClient(config, h) + if err != nil { + return nil, err + } + return &DynamicClient{client: restClient}, nil +} + +type dynamicResourceClient struct { + client *DynamicClient + namespace string + resource schema.GroupVersionResource +} + +func (c *DynamicClient) Resource(resource schema.GroupVersionResource) NamespaceableResourceInterface { + return &dynamicResourceClient{client: c, resource: resource} +} + +func (c *dynamicResourceClient) Namespace(ns string) ResourceInterface { + ret := *c + ret.namespace = ns + return &ret +} + +func (c *dynamicResourceClient) Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + outBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + return nil, err + } + name := "" + if len(subresources) > 0 { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + name = accessor.GetName() + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + + result := c.client.client. + Post(). + AbsPath(append(c.makeURLSegments(name), subresources...)...). + SetHeader("Content-Type", runtime.ContentTypeJSON). + Body(outBytes). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} + +func (c *dynamicResourceClient) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + name := accessor.GetName() + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + outBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + return nil, err + } + + result := c.client.client. + Put(). + AbsPath(append(c.makeURLSegments(name), subresources...)...). + SetHeader("Content-Type", runtime.ContentTypeJSON). + Body(outBytes). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} + +func (c *dynamicResourceClient) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions) (*unstructured.Unstructured, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + name := accessor.GetName() + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + outBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + return nil, err + } + + result := c.client.client. + Put(). + AbsPath(append(c.makeURLSegments(name), "status")...). + SetHeader("Content-Type", runtime.ContentTypeJSON). + Body(outBytes). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} + +func (c *dynamicResourceClient) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error { + if len(name) == 0 { + return fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return err + } + deleteOptionsByte, err := runtime.Encode(deleteOptionsCodec.LegacyCodec(schema.GroupVersion{Version: "v1"}), &opts) + if err != nil { + return err + } + + result := c.client.client. + Delete(). + AbsPath(append(c.makeURLSegments(name), subresources...)...). + SetHeader("Content-Type", runtime.ContentTypeJSON). + Body(deleteOptionsByte). + Do(ctx) + return result.Error() +} + +func (c *dynamicResourceClient) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOptions metav1.ListOptions) error { + if err := validateNamespaceWithOptionalName(c.namespace); err != nil { + return err + } + + deleteOptionsByte, err := runtime.Encode(deleteOptionsCodec.LegacyCodec(schema.GroupVersion{Version: "v1"}), &opts) + if err != nil { + return err + } + + result := c.client.client. + Delete(). + AbsPath(c.makeURLSegments("")...). + SetHeader("Content-Type", runtime.ContentTypeJSON). + Body(deleteOptionsByte). + SpecificallyVersionedParams(&listOptions, dynamicParameterCodec, versionV1). + Do(ctx) + return result.Error() +} + +func (c *dynamicResourceClient) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + result := c.client.client.Get().AbsPath(append(c.makeURLSegments(name), subresources...)...).SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1).Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} + +func (c *dynamicResourceClient) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + if watchListOptions, hasWatchListOptionsPrepared, watchListOptionsErr := watchlist.PrepareWatchListOptionsFromListOptions(opts); watchListOptionsErr != nil { + klog.Warningf("Failed preparing watchlist options for %v, falling back to the standard LIST semantics, err = %v", c.resource, watchListOptionsErr) + } else if hasWatchListOptionsPrepared { + result, err := c.watchList(ctx, watchListOptions) + if err == nil { + consistencydetector.CheckWatchListFromCacheDataConsistencyIfRequested(ctx, fmt.Sprintf("watchlist request for %v", c.resource), c.list, opts, result) + return result, nil + } + klog.Warningf("The watchlist request for %v ended with an error, falling back to the standard LIST semantics, err = %v", c.resource, err) + } + result, err := c.list(ctx, opts) + if err == nil { + consistencydetector.CheckListFromCacheDataConsistencyIfRequested(ctx, fmt.Sprintf("list request for %v", c.resource), c.list, opts, result) + } + return result, err +} + +func (c *dynamicResourceClient) list(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + if err := validateNamespaceWithOptionalName(c.namespace); err != nil { + return nil, err + } + result := c.client.client.Get().AbsPath(c.makeURLSegments("")...).SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1).Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + if list, ok := uncastObj.(*unstructured.UnstructuredList); ok { + return list, nil + } + + list, err := uncastObj.(*unstructured.Unstructured).ToList() + if err != nil { + return nil, err + } + return list, nil +} + +// watchList establishes a watch stream with the server and returns an unstructured list. +func (c *dynamicResourceClient) watchList(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + if err := validateNamespaceWithOptionalName(c.namespace); err != nil { + return nil, err + } + + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + + result := &unstructured.UnstructuredList{} + err := c.client.client.Get().AbsPath(c.makeURLSegments("")...). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Timeout(timeout). + WatchList(ctx). + Into(result) + + return result, err +} + +func (c *dynamicResourceClient) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + opts.Watch = true + if err := validateNamespaceWithOptionalName(c.namespace); err != nil { + return nil, err + } + return c.client.client.Get().AbsPath(c.makeURLSegments("")...). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Watch(ctx) +} + +func (c *dynamicResourceClient) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + result := c.client.client. + Patch(pt). + AbsPath(append(c.makeURLSegments(name), subresources...)...). + Body(data). + SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1). + Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} + +func (c *dynamicResourceClient) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(name) == 0 { + return nil, fmt.Errorf("name is required") + } + if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil { + return nil, err + } + outBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + return nil, err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + managedFields := accessor.GetManagedFields() + if len(managedFields) > 0 { + return nil, fmt.Errorf(`cannot apply an object with managed fields already set. + Use the client-go/applyconfigurations "UnstructructuredExtractor" to obtain the unstructured ApplyConfiguration for the given field manager that you can use/modify here to apply`) + } + patchOpts := opts.ToPatchOptions() + + result := c.client.client. + Patch(types.ApplyPatchType). + AbsPath(append(c.makeURLSegments(name), subresources...)...). + Body(outBytes). + SpecificallyVersionedParams(&patchOpts, dynamicParameterCodec, versionV1). + Do(ctx) + if err := result.Error(); err != nil { + return nil, err + } + retBytes, err := result.Raw() + if err != nil { + return nil, err + } + uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes) + if err != nil { + return nil, err + } + return uncastObj.(*unstructured.Unstructured), nil +} +func (c *dynamicResourceClient) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + return c.Apply(ctx, name, obj, opts, "status") +} + +func validateNamespaceWithOptionalName(namespace string, name ...string) error { + if msgs := rest.IsValidPathSegmentName(namespace); len(msgs) != 0 { + return fmt.Errorf("invalid namespace %q: %v", namespace, msgs) + } + if len(name) > 1 { + panic("Invalid number of names") + } else if len(name) == 1 { + if msgs := rest.IsValidPathSegmentName(name[0]); len(msgs) != 0 { + return fmt.Errorf("invalid resource name %q: %v", name[0], msgs) + } + } + return nil +} + +func (c *dynamicResourceClient) makeURLSegments(name string) []string { + url := []string{} + if len(c.resource.Group) == 0 { + url = append(url, "api") + } else { + url = append(url, "apis", c.resource.Group) + } + url = append(url, c.resource.Version) + + if len(c.namespace) > 0 { + url = append(url, "namespaces", c.namespace) + } + url = append(url, c.resource.Resource) + + if len(name) > 0 { + url = append(url, name) + } + + return url +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 5adefce65..907b49d67 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -403,6 +403,7 @@ k8s.io/client-go/applyconfigurations/storage/v1alpha1 k8s.io/client-go/applyconfigurations/storage/v1beta1 k8s.io/client-go/applyconfigurations/storagemigration/v1alpha1 k8s.io/client-go/discovery +k8s.io/client-go/dynamic k8s.io/client-go/features k8s.io/client-go/gentype k8s.io/client-go/kubernetes diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index c58e1a0e4..0d51291ae 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -11,6 +11,7 @@ "No information available for this content. Change scope to get more details.": "No information available for this content. Change scope to get more details.", "Cluster name": "Cluster name", "UDN": "UDN", + "Can't find metrics for this element. Check your capture filters to ensure we can monitor it. Else it probably means there is no traffic here.": "Can't find metrics for this element. Check your capture filters to ensure we can monitor it. Else it probably means there is no traffic here.", "Source": "Source", "Destination": "Destination", "Stats": "Stats", @@ -153,6 +154,7 @@ "Edges": "Edges", "Edges label": "Edges label", "Badges": "Badges", + "Empty": "Empty", "Collapse groups": "Collapse groups", "XL": "XL", "L": "L", diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index f8aa4c0be..3f5e7182f 100644 --- a/web/src/api/routes.ts +++ b/web/src/api/routes.ts @@ -118,6 +118,15 @@ export const getResources = (namespace: string, kind: string, forcedNamespace?: }); }; +export const getK8SUDNIds = (): Promise => { + return axios.get(ContextSingleton.getHost() + '/api/k8s/resources/udnIds').then(r => { + if (r.status >= 400) { + throw new Error(`${r.statusText} [code=${r.status}]`); + } + return r.data; + }); +}; + export const getFlowMetrics = (params: FlowQuery, range: number | TimeRange): Promise => { return getFlowMetricsGeneric(params, res => { return parseTopologyMetrics( diff --git a/web/src/components/drawer/element/element-panel-content.tsx b/web/src/components/drawer/element/element-panel-content.tsx index 558f12da1..4c53c3f88 100644 --- a/web/src/components/drawer/element/element-panel-content.tsx +++ b/web/src/components/drawer/element/element-panel-content.tsx @@ -103,6 +103,25 @@ export const ElementPanelContent: React.FC = ({ [filterDefinitions, filters, setFilters, t] ); + const metricsInfo = React.useCallback( + (d: NodeData) => { + if (!d.noMetrics) { + return <>; + } + + return ( + + + {t( + "Can't find metrics for this element. Check your capture filters to ensure we can monitor it. Else it probably means there is no traffic here." + )} + + + ); + }, + [t] + ); + if (element instanceof BaseNode && data) { return ( <> @@ -116,6 +135,7 @@ export const ElementPanelContent: React.FC = ({ setFilters={setFilters} filterDefinitions={filterDefinitions} /> + {metricsInfo(data)} ); } else if (element instanceof BaseEdge) { diff --git a/web/src/components/drawer/element/element-panel.tsx b/web/src/components/drawer/element/element-panel.tsx index 8c2583fd1..e52dc2bec 100644 --- a/web/src/components/drawer/element/element-panel.tsx +++ b/web/src/components/drawer/element/element-panel.tsx @@ -57,6 +57,7 @@ export const ElementPanel: React.FC = ({ const [activeTab, setActiveTab] = React.useState('details'); const data = element.getData(); + const noMetrics = data && data.noMetrics === true; let aData: NodeData; let bData: NodeData | undefined; if (element instanceof BaseEdge) { @@ -117,7 +118,7 @@ export const ElementPanel: React.FC = ({ filterDefinitions={filterDefinitions} /> - {!_.isEmpty(metrics) && ( + {!noMetrics && !_.isEmpty(metrics) && ( {t('Metrics')}}> = ({ /> )} - {!_.isEmpty(droppedMetrics) && ( + {!noMetrics && !_.isEmpty(droppedMetrics) && ( {t('Drops')}}> void; topologyOptions: TopologyOptions; @@ -297,6 +298,7 @@ export const NetflowTrafficDrawer: React.FC = React.f metricFunction={props.topologyMetricFunction} metricType={props.topologyMetricType} metricScope={props.metricScope} + expectedNodes={[...props.topologyUDNIds]} // concat all expected nodes here setMetricScope={props.setMetricScope} metrics={getTopologyMetrics() || []} droppedMetrics={getTopologyDroppedMetrics() || []} diff --git a/web/src/components/dropdowns/topology-display-options.tsx b/web/src/components/dropdowns/topology-display-options.tsx index 296d0347d..86b5e4a0d 100644 --- a/web/src/components/dropdowns/topology-display-options.tsx +++ b/web/src/components/dropdowns/topology-display-options.tsx @@ -189,6 +189,17 @@ export const TopologyDisplayOptions: React.FC = ({ }) } /> + + setTopologyOptions({ + ...topologyOptions, + showEmpty: !topologyOptions.showEmpty + }) + } + />
diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index c06f0a3a8..1626a84b3 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -453,6 +453,21 @@ export const NetflowTraffic: React.FC = ({ model.setWarning, clearFlows ); + + if (model.topologyOptions.showEmpty && model.metricScope === 'network') { + drawerRef.current + ?.getTopologyHandle() + ?.fetchUDNs() + .then(ids => { + model.setTopologyUDNIds(ids); + }) + .catch(err => { + console.error('fetchUDNs error', err); + model.setTopologyUDNIds([]); + }); + } else { + model.setTopologyUDNIds([]); + } break; default: console.error('tick called on not implemented view Id', model.selectedViewId); @@ -526,6 +541,7 @@ export const NetflowTraffic: React.FC = ({ model.config.features, model.topologyMetricType, model.topologyMetricFunction, + model.topologyOptions.showEmpty, model.selectedViewId, buildFlowQuery, manageWarnings, diff --git a/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx b/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx index 10e10faf5..28c44de07 100644 --- a/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx +++ b/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx @@ -2,6 +2,7 @@ import { ClusterIcon, CubeIcon, CubesIcon, + NetworkIcon, OutlinedHddIcon, QuestionCircleIcon, ServiceIcon, @@ -63,6 +64,8 @@ const getTypeIcon = (resourceKind?: string): React.ComponentClass => { return ClusterIcon; case 'Zone': return ZoneIcon; + case 'UDN': + return NetworkIcon; case 'CatalogSource': case 'DaemonSet': case 'Deployment': diff --git a/web/src/components/tabs/netflow-topology/2d/topology-content.tsx b/web/src/components/tabs/netflow-topology/2d/topology-content.tsx index b79ed5603..1e5b89cec 100644 --- a/web/src/components/tabs/netflow-topology/2d/topology-content.tsx +++ b/web/src/components/tabs/netflow-topology/2d/topology-content.tsx @@ -55,6 +55,7 @@ const fitPadding = 80; export interface TopologyContentProps { k8sModels: { [key: string]: K8sModel }; + expectedNodes: string[]; metricFunction: StatFunction; metricType: MetricType; metricScope: FlowScope; @@ -78,6 +79,7 @@ export interface TopologyContentProps { export const TopologyContent: React.FC = ({ k8sModels, + expectedNodes, metricFunction, metricType, metricScope, @@ -345,6 +347,7 @@ export const TopologyContent: React.FC = ({ t, filterDefinitions, k8sModels, + expectedNodes, isDark ); const allIds = [...(updatedModel.nodes || []), ...(updatedModel.edges || [])].map(item => item.id); @@ -387,6 +390,7 @@ export const TopologyContent: React.FC = ({ t, filterDefinitions, k8sModels, + expectedNodes, isDark ]); diff --git a/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx b/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx index ade29ecf4..c63a009d2 100644 --- a/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx +++ b/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx @@ -38,7 +38,8 @@ describe('', () => { onSelect: jest.fn(), searchHandle: null, searchEvent: undefined, - scopes: ScopeDefSample + scopes: ScopeDefSample, + expectedNodes: [] }; it('should render component', async () => { diff --git a/web/src/components/tabs/netflow-topology/netflow-topology.tsx b/web/src/components/tabs/netflow-topology/netflow-topology.tsx index 74a613d07..73c5393b2 100644 --- a/web/src/components/tabs/netflow-topology/netflow-topology.tsx +++ b/web/src/components/tabs/netflow-topology/netflow-topology.tsx @@ -14,6 +14,7 @@ import { Stats, TopologyMetrics } from '../../../api/loki'; +import { getK8SUDNIds } from '../../../api/routes'; import { Config, Feature } from '../../../model/config'; import { FilterDefinition, Filters } from '../../../model/filters'; import { @@ -51,6 +52,7 @@ export type NetflowTopologyHandle = { setWarning: (v?: Warning) => void, initFunction: () => void ) => Promise | undefined; + fetchUDNs: () => Promise; }; export interface NetflowTopologyProps { @@ -60,6 +62,7 @@ export interface NetflowTopologyProps { metricFunction: StatFunction; metricType: MetricType; metricScope: FlowScope; + expectedNodes: string[]; setMetricScope: (ms: FlowScope) => void; metrics: TopologyMetrics[]; droppedMetrics: TopologyMetrics[]; @@ -181,8 +184,13 @@ export const NetflowTopology: React.FC = React.forwardRef( [t] ); + const fetchUDNs = React.useCallback(() => { + return getK8SUDNIds(); + }, []); + React.useImperativeHandle(ref, () => ({ - fetch + fetch, + fetchUDNs })); const getContent = React.useCallback(() => { @@ -199,6 +207,7 @@ export const NetflowTopology: React.FC = React.forwardRef( ([]); const [interval, setInterval] = useLocalStorage(localStorageRefreshKey); const [showViewOptions, setShowViewOptions] = useLocalStorage(localStorageShowOptionsKey, false); const [showHistogram, setShowHistogram] = useLocalStorage(localStorageShowHistogramKey, false); @@ -204,6 +205,8 @@ export function netflowTrafficModel() { setTopologyMetricFunction, topologyMetricType, setTopologyMetricType: updateTopologyMetricType, + topologyUDNIds, + setTopologyUDNIds, interval, setInterval, showViewOptions, diff --git a/web/src/model/topology.ts b/web/src/model/topology.ts index 961513ab7..6936a6004 100644 --- a/web/src/model/topology.ts +++ b/web/src/model/topology.ts @@ -55,6 +55,7 @@ export interface TopologyOptions { medScale: number; metricFunction: StatFunction; metricType: MetricType; + showEmpty?: boolean; } export const DefaultOptions: TopologyOptions = { @@ -69,7 +70,8 @@ export const DefaultOptions: TopologyOptions = { lowScale: 0.3, medScale: 0.5, metricFunction: defaultMetricFunction, - metricType: defaultMetricType + metricType: defaultMetricType, + showEmpty: true }; export type GraphElementPeer = GraphElement; @@ -241,6 +243,7 @@ export type NodeData = { peer: TopologyMetricPeer; canStepInto?: boolean; badgeColor?: string; + noMetrics?: boolean; }; const generateNode = ( @@ -400,6 +403,7 @@ export const generateDataModel = ( t: TFunction, filterDefinitions: FilterDefinition[], k8sModels: { [key: string]: K8sModel }, + expectedNodes: string[], isDark?: boolean ): Model => { let nodes: NodeModel[] = []; @@ -603,5 +607,17 @@ export const generateDataModel = ( //remove empty groups nodes = nodes.filter(n => n.type !== 'group' || (n.children && n.children.length)); + + // add missing nodes to the view if available + if (!_.isEmpty(expectedNodes)) { + const currentNodes = nodes.map(n => n.label); + const missingNodes = expectedNodes.filter(n => !currentNodes.includes(n)); + missingNodes.forEach(n => { + const fields: Partial = { id: n }; + fields[metricScope] = n; + addNode({ peer: createPeer(fields), nodeType: metricScope, canStepInto: false, noMetrics: true }); + }); + } + return { nodes, edges }; };