diff --git a/pkg/attributes/attributes.go b/pkg/attributes/attributes.go index 3ba8f69c..389a3696 100644 --- a/pkg/attributes/attributes.go +++ b/pkg/attributes/attributes.go @@ -152,6 +152,20 @@ func SetAPIResource(s *types.APISchema, resource v1.APIResource) { SetNamespaced(s, resource.Namespaced) } +func MarkCRD(s *types.APISchema) { + if s.Attributes == nil { + s.Attributes = map[string]interface{}{} + } + s.Attributes["crd"] = true +} + +func IsCRD(s *types.APISchema) bool { + if crd, ok := s.Attributes["crd"]; ok { + return crd.(bool) + } + return false +} + func SetColumns(s *types.APISchema, columns interface{}) { if s.Attributes == nil { s.Attributes = map[string]interface{}{} diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 6de8df2d..e6e5f25c 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -172,7 +172,8 @@ func formatter(summarycache common.SummaryCache, asl accesscontrol.AccessSetLook excludeValues(request, unstr) if options.InSQLMode { - convertMetadataTimestampFields(request, gvk, unstr) + isCRD := attributes.IsCRD(resource.Schema) + convertMetadataTimestampFields(request, gvk, unstr, isCRD) } } @@ -233,12 +234,15 @@ func excludeFields(request *types.APIRequest, unstr *unstructured.Unstructured) // to the client. Internally, fields are stored as Unix timestamps; on each request, we calculate the elapsed time since // those timestamps by subtracting them from time.Now(), then format the resulting duration into a human-friendly string. // This prevents cached durations (e.g. ā€œ2dā€ - 2 days) from becoming stale over time. -func convertMetadataTimestampFields(request *types.APIRequest, gvk schema2.GroupVersionKind, unstr *unstructured.Unstructured) { +func convertMetadataTimestampFields(request *types.APIRequest, gvk schema2.GroupVersionKind, unstr *unstructured.Unstructured, isCRD bool) { if request.Schema != nil { cols := GetColumnDefinitions(request.Schema) for _, col := range cols { - gvkDateFields, gvkFound := DateFieldsByGVKBuiltins[gvk] - if col.Type == "date" || (gvkFound && slices.Contains(gvkDateFields, col.Name)) { + gvkDateFields, gvkFound := DateFieldsByGVK[gvk] + + hasCRDDateField := isCRD && col.Type == "date" + hasGVKDateFieldMapping := gvkFound && slices.Contains(gvkDateFields, col.Name) + if hasCRDDateField || hasGVKDateFieldMapping { index := GetIndexValueFromString(col.Field) if index == -1 { logrus.Errorf("field index not found at column.Field struct variable: %s", col.Field) diff --git a/pkg/resources/common/gvkdatefields.go b/pkg/resources/common/gvkdatefields.go index 4d024be7..5361abe2 100644 --- a/pkg/resources/common/gvkdatefields.go +++ b/pkg/resources/common/gvkdatefields.go @@ -4,7 +4,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -var DateFieldsByGVKBuiltins = map[schema.GroupVersionKind][]string{ +var DateFieldsByGVK = map[schema.GroupVersionKind][]string{ {Group: "", Version: "v1", Kind: "ConfigMap"}: {"Age"}, {Group: "", Version: "v1", Kind: "Endpoints"}: {"Age"}, {Group: "", Version: "v1", Kind: "Event"}: {"Last Seen", "First Seen"}, @@ -37,6 +37,9 @@ var DateFieldsByGVKBuiltins = map[schema.GroupVersionKind][]string{ {Group: "autoscaling", Version: "v1", Kind: "Scale"}: {"Age"}, {Group: "autoscaling", Version: "v2beta1", Kind: "HorizontalPodAutoscaler"}: {"Age"}, + {Group: "ext.cattle.io", Version: "v1", Kind: "Token"}: {"Age"}, + {Group: "ext.cattle.io", Version: "v1", Kind: "Kubeconfig"}: {"Age"}, + {Group: "batch", Version: "v1", Kind: "Job"}: {"Duration", "Age"}, {Group: "batch", Version: "v1beta1", Kind: "CronJob"}: {"Last Schedule", "Age"}, diff --git a/pkg/resources/virtual/virtual.go b/pkg/resources/virtual/virtual.go index ec277f5b..4114fedc 100644 --- a/pkg/resources/virtual/virtual.go +++ b/pkg/resources/virtual/virtual.go @@ -35,7 +35,7 @@ func NewTransformBuilder(cache common.SummaryCache) *TransformBuilder { } // GetTransformFunc returns the func to transform a raw object into a fixed object, if needed -func (t *TransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind, columns []rescommon.ColumnDefinition) cache.TransformFunc { +func (t *TransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind, columns []rescommon.ColumnDefinition, isCRD bool) cache.TransformFunc { converters := make([]func(*unstructured.Unstructured) (*unstructured.Unstructured, error), 0) if gvk.Kind == "Event" && gvk.Group == "" && gvk.Version == "v1" { converters = append(converters, events.TransformEventObject) @@ -45,8 +45,10 @@ func (t *TransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind, columns // Detecting if we need to convert date fields for _, col := range columns { - gvkDateFields, gvkFound := rescommon.DateFieldsByGVKBuiltins[gvk] - if col.Type == "date" || (gvkFound && slices.Contains(gvkDateFields, col.Name)) { + gvkDateFields, gvkFound := rescommon.DateFieldsByGVK[gvk] + hasCRDDate := isCRD && col.Type == "date" + hasBuiltInDate := gvkFound && slices.Contains(gvkDateFields, col.Name) + if hasCRDDate || hasBuiltInDate { converters = append(converters, func(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { index := rescommon.GetIndexValueFromString(col.Field) if index == -1 { diff --git a/pkg/resources/virtual/virtual_test.go b/pkg/resources/virtual/virtual_test.go index 41660e8d..535c0eb0 100644 --- a/pkg/resources/virtual/virtual_test.go +++ b/pkg/resources/virtual/virtual_test.go @@ -25,6 +25,7 @@ func TestTransformChain(t *testing.T) { hasSummary *summary.SummarizedObject hasRelationships []summarycache.Relationship columns []rescommon.ColumnDefinition + isCRD bool wantOutput any wantError bool }{ @@ -130,6 +131,7 @@ func TestTransformChain(t *testing.T) { "id": "old-id", }, }, + isCRD: true, columns: []rescommon.ColumnDefinition{ { Field: "metadata.fields[0]", @@ -224,6 +226,69 @@ func TestTransformChain(t *testing.T) { }, }, }, + { + name: "built-in type metadata.fields has a date field - should NOT convert to timestamp", + hasSummary: &summary.SummarizedObject{ + PartialObjectMetadata: v1.PartialObjectMetadata{ + ObjectMeta: v1.ObjectMeta{ + Name: "testobj", + Namespace: "test-ns", + }, + TypeMeta: v1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + }, + Summary: summary.Summary{ + State: "success", + Transitioning: false, + Error: false, + }, + }, + input: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{ + "name": "testobj", + "namespace": "test-ns", + "fields": []interface{}{"2025-07-03T18:54:57Z"}, + }, + "id": "old-id", + }, + }, + columns: []rescommon.ColumnDefinition{ + { + TableColumnDefinition: v1.TableColumnDefinition{ + Name: "Created At", + Type: "date", + }, + Field: "metadata.fields[0]", + }, + }, + wantOutput: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{ + "name": "testobj", + "namespace": "test-ns", + "relationships": []any(nil), + "state": map[string]interface{}{ + "name": "success", + "error": false, + "transitioning": false, + "message": "", + }, + "fields": []interface{}{ + "2025-07-03T18:54:57Z", + }, + }, + "id": "test-ns/testobj", + "_id": "old-id", + }, + }, + }, { name: "processable event", input: &unstructured.Unstructured{ @@ -497,7 +562,7 @@ func TestTransformChain(t *testing.T) { if test.name == "a non-ready cluster" { fmt.Printf("Stop here") } - output, err := tb.GetTransformFunc(gvk, test.columns)(test.input) + output, err := tb.GetTransformFunc(gvk, test.columns, test.isCRD)(test.input) require.Equal(t, test.wantOutput, output) if test.wantError { require.Error(t, err) diff --git a/pkg/schema/converter/crd.go b/pkg/schema/converter/crd.go index fb6bb1f7..17bfa4de 100644 --- a/pkg/schema/converter/crd.go +++ b/pkg/schema/converter/crd.go @@ -71,6 +71,7 @@ func forVersion(group, kind string, version v1.CustomResourceDefinitionVersion, if schema == nil { return } + attributes.MarkCRD(schema) if len(versionColumns) > 0 { attributes.SetColumns(schema, versionColumns) } diff --git a/pkg/schema/converter/crd_test.go b/pkg/schema/converter/crd_test.go index ff1885bb..07b7ebb7 100644 --- a/pkg/schema/converter/crd_test.go +++ b/pkg/schema/converter/crd_test.go @@ -167,6 +167,7 @@ func TestAddCustomResources(t *testing.T) { Schema: &wranglerSchema.Schema{ ID: "testgroup.v1.testresource", Attributes: map[string]interface{}{ + "crd": true, "columns": []table.Column{ { Name: "TestColumn", diff --git a/pkg/schema/converter/k8stonorman_test.go b/pkg/schema/converter/k8stonorman_test.go index 5911c930..2bbe9840 100644 --- a/pkg/schema/converter/k8stonorman_test.go +++ b/pkg/schema/converter/k8stonorman_test.go @@ -160,6 +160,7 @@ func TestToSchemas(t *testing.T) { ID: "testgroup.v1.testresource", PluralName: "TestGroup.v1.testResources", Attributes: map[string]interface{}{ + "crd": true, "group": "TestGroup", "version": "v1", "kind": "TestResource", diff --git a/pkg/stores/sqlproxy/proxy_mocks_test.go b/pkg/stores/sqlproxy/proxy_mocks_test.go index 40828f5d..d954ede8 100644 --- a/pkg/stores/sqlproxy/proxy_mocks_test.go +++ b/pkg/stores/sqlproxy/proxy_mocks_test.go @@ -396,15 +396,15 @@ func (m *MockTransformBuilder) EXPECT() *MockTransformBuilderMockRecorder { } // GetTransformFunc mocks base method. -func (m *MockTransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind, colDefs []common.ColumnDefinition) cache.TransformFunc { +func (m *MockTransformBuilder) GetTransformFunc(gvk schema.GroupVersionKind, colDefs []common.ColumnDefinition, isCRD bool) cache.TransformFunc { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTransformFunc", gvk, colDefs) + ret := m.ctrl.Call(m, "GetTransformFunc", gvk, colDefs, isCRD) ret0, _ := ret[0].(cache.TransformFunc) return ret0 } // GetTransformFunc indicates an expected call of GetTransformFunc. -func (mr *MockTransformBuilderMockRecorder) GetTransformFunc(gvk, colDefs any) *gomock.Call { +func (mr *MockTransformBuilderMockRecorder) GetTransformFunc(gvk, colDefs, isCRD any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransformFunc", reflect.TypeOf((*MockTransformBuilder)(nil).GetTransformFunc), gvk, colDefs) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransformFunc", reflect.TypeOf((*MockTransformBuilder)(nil).GetTransformFunc), gvk, colDefs, isCRD) } diff --git a/pkg/stores/sqlproxy/proxy_store.go b/pkg/stores/sqlproxy/proxy_store.go index 723a1682..cbf20445 100644 --- a/pkg/stores/sqlproxy/proxy_store.go +++ b/pkg/stores/sqlproxy/proxy_store.go @@ -262,7 +262,7 @@ type RelationshipNotifier interface { } type TransformBuilder interface { - GetTransformFunc(gvk schema.GroupVersionKind, colDefs []common.ColumnDefinition) cache.TransformFunc + GetTransformFunc(gvk schema.GroupVersionKind, colDefs []common.ColumnDefinition, isCRD bool) cache.TransformFunc } type Store struct { @@ -360,7 +360,7 @@ func (s *Store) initializeNamespaceCache() error { cols := common.GetColumnDefinitions(&nsSchema) // get the type-specific transform func - transformFunc := s.transformBuilder.GetTransformFunc(gvk, cols) + transformFunc := s.transformBuilder.GetTransformFunc(gvk, cols, attributes.IsCRD(&nsSchema)) // get the ns informer tableClient := &tablelistconvert.Client{ResourceInterface: client} @@ -567,7 +567,8 @@ func (s *Store) watch(apiOp *types.APIRequest, schema *types.APISchema, w types. gvk := attributes.GVK(schema) fields := getFieldsFromSchema(schema) fields = append(fields, getFieldForGVK(gvk)...) - transformFunc := s.transformBuilder.GetTransformFunc(gvk, nil) + cols := common.GetColumnDefinitions(schema) + transformFunc := s.transformBuilder.GetTransformFunc(gvk, cols, attributes.IsCRD(schema)) tableClient := &tablelistconvert.Client{ResourceInterface: client} ns := attributes.Namespaced(schema) inf, err := s.cacheFactory.CacheFor(s.ctx, fields, externalGVKDependencies[gvk], selfGVKDependencies[gvk], transformFunc, tableClient, gvk, ns, controllerschema.IsListWatchable(schema)) @@ -779,7 +780,7 @@ func (s *Store) ListByPartitions(apiOp *types.APIRequest, apiSchema *types.APISc fields = append(fields, getFieldForGVK(gvk)...) cols := common.GetColumnDefinitions(apiSchema) - transformFunc := s.transformBuilder.GetTransformFunc(gvk, cols) + transformFunc := s.transformBuilder.GetTransformFunc(gvk, cols, attributes.IsCRD(apiSchema)) tableClient := &tablelistconvert.Client{ResourceInterface: client} ns := attributes.Namespaced(apiSchema) inf, err := s.cacheFactory.CacheFor(s.ctx, fields, externalGVKDependencies[gvk], selfGVKDependencies[gvk], transformFunc, tableClient, gvk, ns, controllerschema.IsListWatchable(apiSchema)) diff --git a/pkg/stores/sqlproxy/proxy_store_test.go b/pkg/stores/sqlproxy/proxy_store_test.go index 5135828a..8624d732 100644 --- a/pkg/stores/sqlproxy/proxy_store_test.go +++ b/pkg/stores/sqlproxy/proxy_store_test.go @@ -244,7 +244,7 @@ func TestListByPartitions(t *testing.T) { cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(ri, nil) // This tests that fields are being extracted from schema columns and the type specific fields map cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {`id`}, {`metadata`, `state`, `name`}, {"gvk", "specific", "fields"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema), true).Return(c, nil) - tb.EXPECT().GetTransformFunc(attributes.GVK(schema), []common.ColumnDefinition{{Field: "some.field"}}).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(schema), []common.ColumnDefinition{{Field: "some.field"}}, false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) bloi.EXPECT().ListByOptions(req.Context(), &opts, partitions, req.Namespace).Return(listToReturn, len(listToReturn.Items), "", nil) list, total, contToken, err := s.ListByPartitions(req, schema, partitions) assert.Nil(t, err) @@ -461,7 +461,7 @@ func TestListByPartitions(t *testing.T) { // note also the watchable bool is expected to be false cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {`id`}, {`metadata`, `state`, `name`}, {"gvk", "specific", "fields"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema), false).Return(c, nil) - tb.EXPECT().GetTransformFunc(attributes.GVK(schema), []common.ColumnDefinition{{Field: "some.field"}}).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(schema), []common.ColumnDefinition{{Field: "some.field"}}, false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) bloi.EXPECT().ListByOptions(req.Context(), &opts, partitions, req.Namespace).Return(listToReturn, len(listToReturn.Items), "", nil) list, total, contToken, err := s.ListByPartitions(req, schema, partitions) assert.Nil(t, err) @@ -533,7 +533,7 @@ func TestListByPartitions(t *testing.T) { assert.Nil(t, err) cg.EXPECT().TableAdminClient(req, schema, "", &WarningBuffer{}).Return(ri, nil) // This tests that fields are being extracted from schema columns and the type specific fields map - tb.EXPECT().GetTransformFunc(attributes.GVK(schema), gomock.Any()).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(schema), gomock.Any(), false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {`id`}, {`metadata`, `state`, `name`}, {"gvk", "specific", "fields"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema), true).Return(factory.Cache{}, fmt.Errorf("error")) _, _, _, err = s.ListByPartitions(req, schema, partitions) @@ -612,7 +612,7 @@ func TestListByPartitions(t *testing.T) { // This tests that fields are being extracted from schema columns and the type specific fields map cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {`id`}, {`metadata`, `state`, `name`}, {"gvk", "specific", "fields"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(schema), attributes.Namespaced(schema), true).Return(c, nil) bloi.EXPECT().ListByOptions(req.Context(), &opts, partitions, req.Namespace).Return(nil, 0, "", fmt.Errorf("error")) - tb.EXPECT().GetTransformFunc(attributes.GVK(schema), gomock.Any()).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(schema), gomock.Any(), false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) _, _, _, err = s.ListByPartitions(req, schema, partitions) assert.NotNil(t, err) @@ -724,7 +724,7 @@ func TestListByPartitionWithUserAccess(t *testing.T) { attributes.SetGVK(theSchema, gvk) cg.EXPECT().TableAdminClient(apiOp, theSchema, "", &WarningBuffer{}).Return(ri, nil) cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {"id"}, {"metadata", "state", "name"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(theSchema), attributes.Namespaced(theSchema), true).Return(c, nil) - tb.EXPECT().GetTransformFunc(attributes.GVK(theSchema), gomock.Any()).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(theSchema), gomock.Any(), false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) listToReturn := &unstructured.UnstructuredList{ Items: make([]unstructured.Unstructured, 0, 0), @@ -766,7 +766,7 @@ func TestReset(t *testing.T) { cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(nil) cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) cf.EXPECT().CacheFor(context.Background(), [][]string{{`id`}, {`metadata`, `state`, `name`}, {"spec", "displayName"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false, true).Return(nsc2, nil) - tb.EXPECT().GetTransformFunc(attributes.GVK(&nsSchema), gomock.Any()).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(&nsSchema), gomock.Any(), false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) err := s.Reset() assert.Nil(t, err) assert.Equal(t, nsc2, s.namespaceCache) @@ -873,7 +873,7 @@ func TestReset(t *testing.T) { cs.EXPECT().SetColumns(gomock.Any(), gomock.Any()).Return(nil) cg.EXPECT().TableAdminClient(nil, &nsSchema, "", &WarningBuffer{}).Return(ri, nil) cf.EXPECT().CacheFor(context.Background(), [][]string{{`id`}, {`metadata`, `state`, `name`}, {"spec", "displayName"}}, gomock.Any(), gomock.Any(), gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(&nsSchema), false, true).Return(factory.Cache{}, fmt.Errorf("error")) - tb.EXPECT().GetTransformFunc(attributes.GVK(&nsSchema), gomock.Any()).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) + tb.EXPECT().GetTransformFunc(attributes.GVK(&nsSchema), gomock.Any(), false).Return(func(obj interface{}) (interface{}, error) { return obj, nil }) err := s.Reset() assert.NotNil(t, err) },