1
0
mirror of https://github.com/rancher/steve.git synced 2025-08-24 09:00:26 +00:00

Fix CRD Created At field (#723)

* Add Tokens and Kubeconfig fields to date mapping

* Fix missing cols in transform func for watch

* Mark CRDs as.. CRD

* Don't transform CRD's date field
This commit is contained in:
Tom Lebreux 2025-07-11 13:28:52 -04:00 committed by GitHub
parent 127d37391d
commit faa5ad63e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 116 additions and 24 deletions

View File

@ -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{}{}

View File

@ -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)

View File

@ -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"},

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}

View File

@ -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",

View File

@ -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",

View File

@ -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)
}

View File

@ -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))

View File

@ -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)
},