diff --git a/modules/api/pkg/resource/customresourcedefinition/types/types.go b/modules/api/pkg/resource/customresourcedefinition/types/types.go index 42e3e38d0022..86e15d647a7d 100644 --- a/modules/api/pkg/resource/customresourcedefinition/types/types.go +++ b/modules/api/pkg/resource/customresourcedefinition/types/types.go @@ -17,9 +17,9 @@ package types import ( "encoding/json" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/dashboard/api/pkg/api" "k8s.io/dashboard/api/pkg/resource/common" @@ -59,16 +59,26 @@ type CustomResourceDefinitionDetail struct { Errors []error `json:"errors"` } +type AdditionalPrinterColumn struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Priority int32 `json:"priority,omitempty"` + JSONPath string `json:"jsonPath"` +} + type CustomResourceDefinitionVersion struct { - Name string `json:"name"` - Served bool `json:"served"` - Storage bool `json:"storage"` + Name string `json:"name"` + Served bool `json:"served"` + Storage bool `json:"storage"` + AdditionalPrinterColumns []AdditionalPrinterColumn `json:"additionalPrinterColumns"` } // CustomResourceObject represents a custom resource object. type CustomResourceObject struct { - TypeMeta api.TypeMeta `json:"typeMeta"` - ObjectMeta api.ObjectMeta `json:"objectMeta"` + AdditionalPrinterColumns map[string]interface{} `json:"additionalPrinterColumns,omitempty"` + RawObject unstructured.Unstructured `json:"-"` // the raw object as map[string]interface{} to grep the value for AdditionalPrinterColumnWithValue which is present on the CRD Details + TypeMeta api.TypeMeta `json:"typeMeta"` + ObjectMeta api.ObjectMeta `json:"objectMeta"` } func (r *CustomResourceObject) UnmarshalJSON(data []byte) error { @@ -76,14 +86,21 @@ func (r *CustomResourceObject) UnmarshalJSON(data []byte) error { metav1.TypeMeta `json:",inline"` ObjectMeta metav1.ObjectMeta `json:"metadata,omitempty"` }{} + tempUnstruct := &unstructured.Unstructured{} err := json.Unmarshal(data, &tempStruct) if err != nil { return err } + err = tempUnstruct.UnmarshalJSON(data) + if err != nil { + return err + } + r.TypeMeta = api.NewTypeMeta(api.ResourceKind(tempStruct.TypeMeta.Kind)) r.ObjectMeta = api.NewObjectMeta(tempStruct.ObjectMeta) + r.RawObject = *tempUnstruct return nil } diff --git a/modules/api/pkg/resource/customresourcedefinition/v1/detail.go b/modules/api/pkg/resource/customresourcedefinition/v1/detail.go index e1b413126a11..7888ad87d2f1 100644 --- a/modules/api/pkg/resource/customresourcedefinition/v1/detail.go +++ b/modules/api/pkg/resource/customresourcedefinition/v1/detail.go @@ -71,10 +71,20 @@ func getCRDVersions(crd *apiextensions.CustomResourceDefinition) []types.CustomR crdVersions := make([]types.CustomResourceDefinitionVersion, 0, len(crd.Spec.Versions)) if len(crd.Spec.Versions) > 0 { for _, version := range crd.Spec.Versions { + tempColumns := make([]types.AdditionalPrinterColumn, 0) + for _, column := range version.AdditionalPrinterColumns { + tempColumns = append(tempColumns, types.AdditionalPrinterColumn{ + Name: column.Name, + Type: column.Type, + Priority: column.Priority, + JSONPath: column.JSONPath, + }) + } crdVersions = append(crdVersions, types.CustomResourceDefinitionVersion{ - Name: version.Name, - Served: version.Served, - Storage: version.Storage, + Name: version.Name, + Served: version.Served, + Storage: version.Storage, + AdditionalPrinterColumns: tempColumns, }) } } diff --git a/modules/api/pkg/resource/customresourcedefinition/v1/detail_test.go b/modules/api/pkg/resource/customresourcedefinition/v1/detail_test.go new file mode 100644 index 000000000000..a3f0d19f675e --- /dev/null +++ b/modules/api/pkg/resource/customresourcedefinition/v1/detail_test.go @@ -0,0 +1,187 @@ +// Copyright 2017 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 v1 + +import ( + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/dashboard/api/pkg/resource/customresourcedefinition/types" + "reflect" + "strings" + "testing" + + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/dashboard/api/pkg/resource/dataselect" +) + +type SampleCustomResourceSpec struct { + Field1 string `json:"field1"` + Field2 int32 `json:"field2"` +} + +type SampleCustomResourceStatus struct{} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +type SampleCustomResource struct { + metaV1.TypeMeta `json:",inline"` + metaV1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"` + + Spec SampleCustomResourceSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"` + + Status SampleCustomResourceStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"` +} + +func (s SampleCustomResource) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (s SampleCustomResource) DeepCopyObject() runtime.Object { + return s +} + +func createCustomResourceDefinition(fqName string) *apiextensions.CustomResourceDefinition { + splitName := strings.Split(fqName, ".") + name := splitName[0] + group := splitName[1] + return &apiextensions.CustomResourceDefinition{ + TypeMeta: metaV1.TypeMeta{ + Kind: "CustomResourceDefinition", + APIVersion: "apiextensions.k8s.io/v1", + }, + ObjectMeta: metaV1.ObjectMeta{ + Name: fqName, + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: group, + Names: apiextensions.CustomResourceDefinitionNames{ + Kind: "SampleCustomResource", + Plural: name, + ShortNames: []string{"scr"}, + }, + Scope: apiextensions.NamespaceScoped, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: true, + Schema: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "field1": { + Type: "string", + }, + "field2": { + Type: "integer", + }, + }, + }, + }, + AdditionalPrinterColumns: []apiextensions.CustomResourceColumnDefinition{ + { + Name: "Field1", + JSONPath: ".spec.field1", + Priority: 2, + }, + { + Name: "Field2", + JSONPath: ".spec.field2", + Type: "integer", + }, + }, + }, + }, + }, + Status: apiextensions.CustomResourceDefinitionStatus{}, + } +} + +func createCustomResourceObject(name, namespace, field1 string, field2 int32) *SampleCustomResource { + return &SampleCustomResource{ + ObjectMeta: metaV1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + TypeMeta: metaV1.TypeMeta{ + Kind: "SampleCustomResource", + APIVersion: "sample-controller.k8s.io/v1alpha1", + }, + Spec: SampleCustomResourceSpec{ + Field1: field1, + Field2: field2, + }, + } +} + +func TestGetCustomResourceDefinitionDetail(t *testing.T) { + crd := createCustomResourceDefinition("sample-controller.k8s.io") + crdObject := createCustomResourceObject("ns-1", "scr-1", "string-field-1", 100) + + gv := schema.GroupVersion{ + Group: "sample-controller.k8s.io", + Version: "v1alpha1", + } + + // Register the type and GroupVersion with the scheme. + metaV1.AddToGroupVersion(scheme.Scheme, gv) + scheme.Scheme.AddKnownTypes(gv, crd) // Register your custom resource type + + cases := []struct { + namespace, name string + expectedActions []string + crdObject *SampleCustomResource + expected *types.CustomResourceDefinitionDetail + }{ + { + "ns-1", "scr1", + []string{"get"}, + crdObject, + &types.CustomResourceDefinitionDetail{ + CustomResourceDefinition: types.CustomResourceDefinition{}, + }, + }, + } + + for _, c := range cases { + fakeClient := fake.NewSimpleClientset(crd, c.crdObject) + fakeRestConfig := &rest.Config{} + dataselect.DefaultDataSelectWithMetrics.MetricQuery = dataselect.NoMetrics + actual, _ := GetCustomResourceDefinitionDetail(fakeClient, fakeRestConfig, c.name) + + actions := fakeClient.Actions() + if len(actions) != len(c.expectedActions) { + t.Errorf("Unexpected actions: %v, expected %d actions got %d", actions, + len(c.expectedActions), len(actions)) + continue + } + + for i, verb := range c.expectedActions { + if actions[i].GetVerb() != verb { + t.Errorf("Unexpected action: %+v, expected %s", actions[i], verb) + } + } + + if !reflect.DeepEqual(actual, c.expected) { + t.Errorf("GetCustomResourceDefinitionDetail(client, config, name) == \ngot: %#v, \nexpected %#v", + actual, c.expected) + } + } +} diff --git a/modules/api/pkg/resource/customresourcedefinition/v1/objects.go b/modules/api/pkg/resource/customresourcedefinition/v1/objects.go index 5d9b1f911178..7cb95977989e 100644 --- a/modules/api/pkg/resource/customresourcedefinition/v1/objects.go +++ b/modules/api/pkg/resource/customresourcedefinition/v1/objects.go @@ -18,10 +18,12 @@ import ( "context" "encoding/json" "fmt" + "strings" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/rest" "k8s.io/dashboard/api/pkg/api" @@ -128,4 +130,19 @@ func toCRDObject(object *types.CustomResourceObject, crd *apiextensionsv1.Custom object.TypeMeta.Kind = api.ResourceKind(crd.Name) crdSubresources := crd.Spec.Versions[0].Subresources object.TypeMeta.Scalable = crdSubresources != nil && crdSubresources.Scale != nil + object.AdditionalPrinterColumns = make(map[string]interface{}) + for _, col := range crd.Spec.Versions[0].AdditionalPrinterColumns { + val, _, _ := unstructured.NestedString(object.RawObject.Object, splitJsonPath(col.JSONPath)...) + object.AdditionalPrinterColumns[col.Name] = val + } +} + +func splitJsonPath(path string) []string { + var paths []string + for _, p := range strings.Split(path, ".") { + if p != "" { + paths = append(paths, p) + } + } + return paths } diff --git a/modules/web/src/common/components/resourcelist/crdobject/component.ts b/modules/web/src/common/components/resourcelist/crdobject/component.ts index c0f4ce8afb51..a2c479d26373 100644 --- a/modules/web/src/common/components/resourcelist/crdobject/component.ts +++ b/modules/web/src/common/components/resourcelist/crdobject/component.ts @@ -34,6 +34,7 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; export class CRDObjectListComponent extends ResourceListBase { @Input() endpoint: string; @Input() namespaced = false; + @Input() additionalDisplayColumns: string[] = []; constructor( private readonly crdObject_: NamespacedResourceService, @@ -62,7 +63,12 @@ export class CRDObjectListComponent extends ResourceListBase !defaultColumns.includes(v)); } areMultipleNamespacesSelected(): boolean { diff --git a/modules/web/src/common/components/resourcelist/crdobject/template.html b/modules/web/src/common/components/resourcelist/crdobject/template.html index f77bd9f7838b..0ac96f5356d9 100644 --- a/modules/web/src/common/components/resourcelist/crdobject/template.html +++ b/modules/web/src/common/components/resourcelist/crdobject/template.html @@ -53,7 +53,7 @@ {{ object.objectMeta.namespace }} - + Created @@ -62,6 +62,16 @@ + + {{ additionalColumn }} + + + {{ object.additionalPrinterColumns[additionalColumn] }} + + + diff --git a/modules/web/src/crd/detail/component.ts b/modules/web/src/crd/detail/component.ts index a739dea39bff..17cb6a1beab3 100644 --- a/modules/web/src/crd/detail/component.ts +++ b/modules/web/src/crd/detail/component.ts @@ -59,4 +59,10 @@ export class CRDDetailComponent implements OnInit, OnDestroy { isNamespaced(): boolean { return this.crd && this.crd.scope === 'Namespaced'; } + + getAdditionalDisplayColumns(): string[] { + return this.crd.versions[0]?.additionalPrinterColumns + .sort((a, b) => (a.priority < b.priority ? -1 : 1)) + .map(c => c.name); + } } diff --git a/modules/web/src/crd/detail/template.html b/modules/web/src/crd/detail/template.html index 5890ce9cd375..f38d5526a45b 100644 --- a/modules/web/src/crd/detail/template.html +++ b/modules/web/src/crd/detail/template.html @@ -90,7 +90,9 @@ - +