From 8cdba1528dc00c13a98217330ede5cb562c3d6d6 Mon Sep 17 00:00:00 2001 From: Tao Yi Date: Wed, 3 Jul 2024 23:53:44 +0800 Subject: [PATCH] Fix(custom entity): Generate multiple entities if KongCustomEntity attached to multiple foreign entities (#6280) * generate multiple entities if KCE attached to multiple foreign entities * Add unit tests and integration tests for multiple custom entities * update changelog * update compare custom entity * move custom entities related code * address comments --- CHANGELOG.md | 3 + internal/dataplane/kongstate/customentity.go | 289 ++++++- .../dataplane/kongstate/customentity_test.go | 736 ++++++++++++++++++ internal/dataplane/kongstate/kongstate.go | 169 ---- .../dataplane/kongstate/kongstate_test.go | 285 ------- .../isolated/custom_entity_test.go | 62 ++ 6 files changed, 1084 insertions(+), 460 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c26acadb..433db8ef9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,9 @@ Adding a new version? You'll need three changes: - Services using `Secret`s containing the same certificate as client certificates by annotation `konghq.com/client-cert` can be correctly translated. [#6228](https://github.com/Kong/kubernetes-ingress-controller/pull/6228) +- Generate one entity for each attached foreign entity if a `KongCustomEntity` + resource is attached to multiple foreign Kong entities. + [#6280](https://github.com/Kong/kubernetes-ingress-controller/pull/6280) ## 3.2.2 diff --git a/internal/dataplane/kongstate/customentity.go b/internal/dataplane/kongstate/customentity.go index 9ded140971..3177daf054 100644 --- a/internal/dataplane/kongstate/customentity.go +++ b/internal/dataplane/kongstate/customentity.go @@ -2,11 +2,18 @@ package kongstate import ( "context" + "encoding/json" + "fmt" "sort" + "github.com/go-logr/logr" "github.com/kong/go-kong/kong" "github.com/kong/go-kong/kong/custom" + "github.com/samber/lo" + "github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/failures" + "github.com/kong/kubernetes-ingress-controller/v3/internal/store" + "github.com/kong/kubernetes-ingress-controller/v3/internal/util" kongv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1alpha1" ) @@ -130,6 +137,8 @@ type CustomEntity struct { custom.Object // K8sKongCustomEntity refers to the KongCustomEntity resource that translate to it. K8sKongCustomEntity *kongv1alpha1.KongCustomEntity + // ForeignEntityIDs stores the IDs of the foreign Kong entities attached to the entity. + ForeignEntityIDs map[kong.EntityType]string } // SchemaGetter is the interface to fetch the schema of a Kong entity by its type. @@ -138,6 +147,96 @@ type SchemaGetter interface { Get(ctx context.Context, entityType string) (kong.Schema, error) } +type entityForeignFieldValue struct { + fieldName string + foreignEntityType kong.EntityType + foreignEntityID string +} + +// FillCustomEntities fills custom entities in KongState. +func (ks *KongState) FillCustomEntities( + logger logr.Logger, + s store.Storer, + failuresCollector *failures.ResourceFailuresCollector, + schemaGetter SchemaGetter, + workspace string, +) { + entities := s.ListKongCustomEntities() + if len(entities) == 0 { + return + } + logger = logger.WithName("fillCustomEntities") + + if ks.CustomEntities == nil { + ks.CustomEntities = map[string]*KongCustomEntityCollection{} + } + // Fetch relations between plugins and services/routes/consumers and store the pointer to translated Kong entities. + // Used for fetching entity referred by a custom entity and fill the ID of referred entity. + pluginRels := ks.getPluginRelatedEntitiesRef(s, logger) + + for _, entity := range entities { + // reject the custom entity if its type is in "known" entity types that are already processed. + if IsKnownEntityType(entity.Spec.EntityType) { + failuresCollector.PushResourceFailure( + fmt.Sprintf("cannot use known entity type %s in custom entity", entity.Spec.EntityType), + entity, + ) + continue + } + // Fetch the entity schema. + schema, err := ks.fetchEntitySchema(schemaGetter, entity.Spec.EntityType) + if err != nil { + failuresCollector.PushResourceFailure( + fmt.Sprintf("failed to fetch entity schema for entity type %s: %v", entity.Spec.EntityType, err), + entity, + ) + continue + } + + // Fill the "foreign" fields if the entity has such fields referencing services/routes/consumers. + // First Find out possible foreign field combinations attached to the KCE resource. + foreignFieldCombinations := findCustomEntityForeignFields(logger, entity, schema, pluginRels, workspace) + // generate Kong entities from the fields in the KCE itself and attached foreign entities. + generatedEntities, err := generateCustomEntities(entity, foreignFieldCombinations) + if err != nil { + failuresCollector.PushResourceFailure(fmt.Sprintf("failed to generate entities from itself and attach foreign entities: %v", err), entity) + continue + } + for _, generatedEntity := range generatedEntities { + ks.addCustomEntity(entity.Spec.EntityType, schema, generatedEntity) + } + } + + ks.sortCustomEntities() +} + +// addCustomEntity adds a custom entity into the collection of its type. +func (ks *KongState) addCustomEntity(entityType string, schema EntitySchema, e CustomEntity) { + // Put the entity into the custom collection to store the entities of its type. + if _, ok := ks.CustomEntities[entityType]; !ok { + ks.CustomEntities[entityType] = &KongCustomEntityCollection{ + Schema: schema, + } + } + collection := ks.CustomEntities[entityType] + collection.Entities = append(collection.Entities, e) +} + +// fetchEntitySchema fetches schema of an entity by its type and stores the schema in its custom entity collection +// as a cache to avoid excessive calling of Kong admin APIs. +func (ks *KongState) fetchEntitySchema(schemaGetter SchemaGetter, entityType string) (EntitySchema, error) { + collection, ok := ks.CustomEntities[entityType] + if ok { + return collection.Schema, nil + } + // Use `context.Background()` here because `BuildKongConfig` does not provide a context. + schema, err := schemaGetter.Get(context.Background(), entityType) + if err != nil { + return EntitySchema{}, err + } + return ExtractEntityFieldDefinitions(schema), nil +} + // sortCustomEntities sorts the custom entities of each type. // Since there may not be a consistent field to identify an entity, here we sort them by the k8s namespace/name. func (ks *KongState) sortCustomEntities() { @@ -146,14 +245,192 @@ func (ks *KongState) sortCustomEntities() { e1 := collection.Entities[i] e2 := collection.Entities[j] // Compare namespace first. - if e1.K8sKongCustomEntity.Namespace < e2.K8sKongCustomEntity.Namespace { - return true + if e1.K8sKongCustomEntity.Namespace != e2.K8sKongCustomEntity.Namespace { + return e1.K8sKongCustomEntity.Namespace < e2.K8sKongCustomEntity.Namespace + } + // If namespace are the same, compare names. + if e1.K8sKongCustomEntity.Name != e2.K8sKongCustomEntity.Name { + return e1.K8sKongCustomEntity.Name < e2.K8sKongCustomEntity.Name } - if e1.K8sKongCustomEntity.Namespace > e2.K8sKongCustomEntity.Namespace { - return false + // Namespace and name are all the same. + // This means the two entities are generated from the same KCE resource but attached to different foreign entities. + // So we need to compare foreign entities. + if e1.ForeignEntityIDs != nil && e2.ForeignEntityIDs != nil { + // Compare IDs of attached entities in services, routes, consumers order. + foreignEntityTypeList := []kong.EntityType{ + kong.EntityTypeServices, + kong.EntityTypeRoutes, + kong.EntityTypeConsumers, + } + for _, t := range foreignEntityTypeList { + if e1.ForeignEntityIDs[t] != e2.ForeignEntityIDs[t] { + return e1.ForeignEntityIDs[t] < e2.ForeignEntityIDs[t] + } + } } - // If namespace are the same, compare name. - return e1.K8sKongCustomEntity.Name < e2.K8sKongCustomEntity.Name + // Should not reach here when k8s namespace/names are the same, and foreign entities are also the same. + // This means we generated two Kong entities from one KCE (and attached to the same foreign entities if any). + return true }) } } + +func findCustomEntityRelatedPlugin(k8sEntity *kongv1alpha1.KongCustomEntity) (string, bool) { + // Find referred entity via the plugin in its spec.parentRef. + // Then we can fetch the referred service/route/consumer from the reference relations of the plugin. + parentRef := k8sEntity.Spec.ParentRef + var namespace string + // Abort if the parentRef is empty or does not refer to a plugin. + if parentRef == nil || + (parentRef.Group == nil || *parentRef.Group != kongv1alpha1.GroupVersion.Group) { + return "", false + } + if parentRef.Kind == nil || (*parentRef.Kind != "KongPlugin" && *parentRef.Kind != "KongClusterPlugin") { + return "", false + } + // Extract the plugin key to get the plugin relations. + if parentRef.Namespace == nil || *parentRef.Namespace == "" { + namespace = k8sEntity.Namespace + } else { + namespace = *parentRef.Namespace + } + return namespace + ":" + parentRef.Name, true +} + +func findCustomEntityForeignFields( + logger logr.Logger, + k8sEntity *kongv1alpha1.KongCustomEntity, + schema EntitySchema, + pluginRelEntities PluginRelatedEntitiesRefs, + workspace string, +) [][]entityForeignFieldValue { + pluginKey, ok := findCustomEntityRelatedPlugin(k8sEntity) + if !ok { + return nil + } + // Get the relations with other entities of the plugin. + rels, ok := pluginRelEntities.RelatedEntities[pluginKey] + if !ok { + return nil + } + + var ( + foreignRelations util.ForeignRelations + foreignServiceFields []string + foreignRouteFields []string + foreignConsumerFields []string + ) + + ret := [][]entityForeignFieldValue{} + for fieldName, field := range schema.Fields { + if field.Type != EntityFieldTypeForeign { + continue + } + switch field.Reference { + case string(kong.EntityTypeServices): + foreignServiceFields = append(foreignServiceFields, fieldName) + foreignRelations.Service = getServiceIDFromPluginRels(logger, rels, pluginRelEntities.RouteAttachedService, workspace) + case string(kong.EntityTypeRoutes): + foreignRouteFields = append(foreignRouteFields, fieldName) + foreignRelations.Route = lo.FilterMap(rels.Routes, func(r *Route, _ int) (string, bool) { + if err := r.FillID(workspace); err != nil { + return "", false + } + return *r.ID, true + }) + case string(kong.EntityTypeConsumers): + foreignConsumerFields = append(foreignConsumerFields, fieldName) + foreignRelations.Consumer = lo.FilterMap(rels.Consumers, func(c *Consumer, _ int) (string, bool) { + if err := c.FillID(workspace); err != nil { + return "", false + } + return *c.ID, true + }) + } // end of switch + } + + // TODO: Here we inherited the logic of generating combinations of attached foreign entities for plugins. + // Actually there are no such case that a custom entity required multiple "foreign" fields in current Kong plugins. + // So it is still uncertain how to generate foreign field combinations for custom entities. + for _, combination := range foreignRelations.GetCombinations() { + foreignFieldValues := []entityForeignFieldValue{} + for _, fieldName := range foreignServiceFields { + foreignFieldValues = append(foreignFieldValues, entityForeignFieldValue{ + fieldName: fieldName, + foreignEntityType: kong.EntityTypeServices, + foreignEntityID: combination.Service, + }) + } + for _, fieldName := range foreignRouteFields { + foreignFieldValues = append(foreignFieldValues, entityForeignFieldValue{ + fieldName: fieldName, + foreignEntityType: kong.EntityTypeRoutes, + foreignEntityID: combination.Route, + }) + } + for _, fieldName := range foreignConsumerFields { + foreignFieldValues = append(foreignFieldValues, entityForeignFieldValue{ + fieldName: fieldName, + foreignEntityType: kong.EntityTypeConsumers, + foreignEntityID: combination.Consumer, + }) + } + ret = append(ret, foreignFieldValues) + } + + return ret +} + +// generateCustomEntities generates Kong entities from KongCustomEntity resource and combinations of attached foreign entities. +// If the KCE is attached to any foreign entities, it generates one entity per combination of foreign entities. +// If the KCE is not attached, generate one entity for itself. +func generateCustomEntities( + entity *kongv1alpha1.KongCustomEntity, + foreignFieldCombinations [][]entityForeignFieldValue, +) ([]CustomEntity, error) { + copyEntityFields := func() (map[string]any, error) { + // Unmarshal the fields of the entity to have a fresh copy for each combination as we may modify them. + fields := map[string]any{} + if err := json.Unmarshal(entity.Spec.Fields.Raw, &fields); err != nil { + return nil, fmt.Errorf("failed to unmarshal entity fields: %w", err) + } + return fields, nil + } + // If there are any foreign fields, generate one entity per each foreign entity combination. + if len(foreignFieldCombinations) > 0 { + var customEntities []CustomEntity + for _, combination := range foreignFieldCombinations { + entityFields, err := copyEntityFields() + if err != nil { + return nil, err + } + generatedEntity := CustomEntity{ + K8sKongCustomEntity: entity, + ForeignEntityIDs: make(map[kong.EntityType]string), + Object: entityFields, + } + // Fill the fields referring to foreign entities. + for _, foreignField := range combination { + entityFields[foreignField.fieldName] = map[string]any{ + "id": foreignField.foreignEntityID, + } + // Save the referred foreign entity IDs for sorting. + generatedEntity.ForeignEntityIDs[foreignField.foreignEntityType] = foreignField.foreignEntityID + } + customEntities = append(customEntities, generatedEntity) + } + return customEntities, nil + } + + // Otherwise (no foreign fields), generate a single entity. + entityFields, err := copyEntityFields() + if err != nil { + return nil, err + } + return []CustomEntity{ + { + K8sKongCustomEntity: entity, + Object: entityFields, + }, + }, nil +} diff --git a/internal/dataplane/kongstate/customentity_test.go b/internal/dataplane/kongstate/customentity_test.go index 64ff4d049e..c0917cb2a7 100644 --- a/internal/dataplane/kongstate/customentity_test.go +++ b/internal/dataplane/kongstate/customentity_test.go @@ -1,13 +1,26 @@ package kongstate import ( + "fmt" + "sort" "testing" + "github.com/go-logr/logr" "github.com/kong/go-kong/kong" "github.com/kong/go-kong/kong/custom" + "github.com/samber/lo" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/kong/kubernetes-ingress-controller/v3/internal/annotations" + "github.com/kong/kubernetes-ingress-controller/v3/internal/dataplane/failures" + "github.com/kong/kubernetes-ingress-controller/v3/internal/store" + "github.com/kong/kubernetes-ingress-controller/v3/internal/util" + kongv1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1" kongv1alpha1 "github.com/kong/kubernetes-ingress-controller/v3/pkg/apis/configuration/v1alpha1" ) @@ -208,3 +221,726 @@ func TestSortCustomEntities(t *testing.T) { }) } } + +func TestFindCustomEntityForeignFields(t *testing.T) { + testCustomEntity := &kongv1alpha1.KongCustomEntity{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "fake-entity", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "fake_entities", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"uri":"/api/me"}`), + }, + ParentRef: &kongv1alpha1.ObjectReference{ + Group: kong.String(kongv1.GroupVersion.Group), + Kind: kong.String("KongPlugin"), + Name: "fake-plugin", + }, + }, + } + kongService1 := kong.Service{ + Name: kong.String("service1"), + ID: kong.String("service1"), + } + kongService2 := kong.Service{ + Name: kong.String("service2"), + ID: kong.String("service2"), + } + kongRoute1 := kong.Route{ + Name: kong.String("route1"), + ID: kong.String("route1"), + } + kongRoute2 := kong.Route{ + Name: kong.String("route2"), + ID: kong.String("route2"), + } + kongConsumer1 := kong.Consumer{ + Username: kong.String("consumer1"), + ID: kong.String("consumer1"), + } + kongConsumer2 := kong.Consumer{ + Username: kong.String("consumer2"), + ID: kong.String("consumer2"), + } + testCases := []struct { + name string + customEntity *kongv1alpha1.KongCustomEntity + schema EntitySchema + pluginRelEntities PluginRelatedEntitiesRefs + foreignFieldCombinations [][]entityForeignFieldValue + }{ + { + name: "attached to single entity: service", + customEntity: testCustomEntity, + schema: EntitySchema{ + Fields: map[string]EntityField{ + "foo": {Name: "foo", Type: EntityFieldTypeString, Required: true}, + "service": {Name: "service", Type: EntityFieldTypeForeign, Reference: "services"}, + }, + }, + pluginRelEntities: PluginRelatedEntitiesRefs{ + RelatedEntities: map[string]RelatedEntitiesRef{ + "default:fake-plugin": { + Services: []*Service{ + { + Service: kongService1, + }, + { + Service: kongService2, + }, + }, + }, + }, + }, + foreignFieldCombinations: [][]entityForeignFieldValue{ + { + {fieldName: "service", foreignEntityType: kong.EntityTypeServices, foreignEntityID: "service1"}, + }, + { + {fieldName: "service", foreignEntityType: kong.EntityTypeServices, foreignEntityID: "service2"}, + }, + }, + }, + { + name: "attached to routes and consumers", + customEntity: testCustomEntity, + schema: EntitySchema{ + Fields: map[string]EntityField{ + "foo": {Name: "foo", Type: EntityFieldTypeString, Required: true}, + "route": {Name: "route", Type: EntityFieldTypeForeign, Reference: "routes"}, + "consumer": {Name: "consumer", Type: EntityFieldTypeForeign, Reference: "consumers"}, + }, + }, + pluginRelEntities: PluginRelatedEntitiesRefs{ + RelatedEntities: map[string]RelatedEntitiesRef{ + "default:fake-plugin": { + Routes: []*Route{ + { + Route: kongRoute1, + }, + { + Route: kongRoute2, + }, + }, + Consumers: []*Consumer{ + { + Consumer: kongConsumer1, + }, + { + Consumer: kongConsumer2, + }, + }, + }, + }, + RouteAttachedService: map[string]*Service{ + "route1": {Service: kongService1}, + "route2": {Service: kongService2}, + }, + }, + foreignFieldCombinations: [][]entityForeignFieldValue{ + { + {fieldName: "consumer", foreignEntityType: kong.EntityTypeConsumers, foreignEntityID: "consumer1"}, + {fieldName: "route", foreignEntityType: kong.EntityTypeRoutes, foreignEntityID: "route1"}, + }, + { + {fieldName: "consumer", foreignEntityType: kong.EntityTypeConsumers, foreignEntityID: "consumer1"}, + {fieldName: "route", foreignEntityType: kong.EntityTypeRoutes, foreignEntityID: "route2"}, + }, + { + {fieldName: "consumer", foreignEntityType: kong.EntityTypeConsumers, foreignEntityID: "consumer2"}, + {fieldName: "route", foreignEntityType: kong.EntityTypeRoutes, foreignEntityID: "route1"}, + }, + { + {fieldName: "consumer", foreignEntityType: kong.EntityTypeConsumers, foreignEntityID: "consumer2"}, + {fieldName: "route", foreignEntityType: kong.EntityTypeRoutes, foreignEntityID: "route2"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + combinations := findCustomEntityForeignFields( + logr.Discard(), + tc.customEntity, + tc.schema, + tc.pluginRelEntities, + "", + ) + for index, combination := range combinations { + sort.SliceStable(combination, func(i, j int) bool { + return combination[i].fieldName < combination[j].fieldName + }) + combinations[index] = combination + } + for _, expectedCombination := range tc.foreignFieldCombinations { + require.Contains(t, combinations, expectedCombination) + } + }) + } +} + +func TestKongState_FillCustomEntities(t *testing.T) { + customEntityTypeMeta := metav1.TypeMeta{ + APIVersion: kongv1alpha1.GroupVersion.Group + "/" + kongv1alpha1.GroupVersion.Version, + Kind: "KongCustomEntity", + } + kongService1 := kong.Service{ + Name: kong.String("service1"), + ID: kong.String("service1"), + } + kongService2 := kong.Service{ + Name: kong.String("service2"), + ID: kong.String("service2"), + } + ksService1 := Service{ + Service: kongService1, + K8sServices: map[string]*corev1.Service{ + "default/service1": { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "service1", + Annotations: map[string]string{ + annotations.AnnotationPrefix + annotations.PluginsKey: "degraphql-1", + }, + }, + }, + }, + } + ksService2 := Service{ + Service: kongService2, + K8sServices: map[string]*corev1.Service{ + "default/service2": { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "service2", + Annotations: map[string]string{ + annotations.AnnotationPrefix + annotations.PluginsKey: "degraphql-1", + }, + }, + }, + }, + } // Service: service2 + + testCases := []struct { + name string + initialState *KongState + customEntities []*kongv1alpha1.KongCustomEntity + plugins []*kongv1.KongPlugin + schemas map[string]kong.Schema + expectedCustomEntities map[string][]custom.Object + expectedTranslationFailures map[k8stypes.NamespacedName]string + }{ + { + name: "single custom entity", + initialState: &KongState{}, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "session-foo", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "sessions", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"name":"session1"}`), + }, + }, + }, + }, + schemas: map[string]kong.Schema{ + "sessions": { + "fields": []interface{}{ + map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + "sessions": { + { + "name": "session1", + }, + }, + }, + }, + { + name: "custom entity with unknown type", + initialState: &KongState{}, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "session-foo", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "sessions", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"name":"session1"}`), + }, + }, + }, + }, + expectedTranslationFailures: map[k8stypes.NamespacedName]string{ + { + Namespace: "default", + Name: "session-foo", + }: "failed to fetch entity schema for entity type sessions: schema not found", + }, + }, + { + name: "multiple custom entities with same type", + initialState: &KongState{}, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "session-foo", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "sessions", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"name":"session-foo"}`), + }, + }, + }, + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "session-bar", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "sessions", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"name":"session-bar"}`), + }, + }, + }, + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default-1", + Name: "session-foo", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "sessions", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"name":"session-foo-1"}`), + }, + }, + }, + }, + schemas: map[string]kong.Schema{ + "sessions": { + "fields": []interface{}{ + map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + // Should be sorted by original KCE namespace/name. + "sessions": { + { + // from default/bar + "name": "session-bar", + }, + { + // from default/foo + "name": "session-foo", + }, + { + // from default-1/foo + "name": "session-foo-1", + }, + }, + }, + }, + { + name: "custom entities with reference to other entities (services)", + initialState: &KongState{ + Services: []Service{ksService1}, // Services + }, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "degraphql_routes", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"uri":"/api/me"}`), + }, + ParentRef: &kongv1alpha1.ObjectReference{ + Group: kong.String(kongv1.GroupVersion.Group), + Kind: kong.String("KongPlugin"), + Name: "degraphql-1", + }, + }, + }, + }, + plugins: []*kongv1.KongPlugin{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + PluginName: "degraphql", + }, + }, + schemas: map[string]kong.Schema{ + "degraphql_routes": { + "fields": []interface{}{ + map[string]interface{}{ + "uri": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + map[string]interface{}{ + "service": map[string]interface{}{ + "type": "foreign", + "reference": "services", + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + "degraphql_routes": { + { + "uri": "/api/me", + "service": map[string]interface{}{ + // ID of Kong service "service1" in workspace "". + "id": "service1", + }, + }, + }, + }, + }, + { + name: "custom entity attached to multiple services via plugin", + initialState: &KongState{ + Services: []Service{ + ksService1, + ksService2, + }, + }, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "degraphql_routes", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"uri":"/api/me"}`), + }, + ParentRef: &kongv1alpha1.ObjectReference{ + Group: kong.String(kongv1.GroupVersion.Group), + Kind: kong.String("KongPlugin"), + Name: "degraphql-1", + }, + }, + }, + }, + plugins: []*kongv1.KongPlugin{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + PluginName: "degraphql", + }, + }, + schemas: map[string]kong.Schema{ + "degraphql_routes": { + "fields": []interface{}{ + map[string]interface{}{ + "uri": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + map[string]interface{}{ + "service": map[string]interface{}{ + "type": "foreign", + "reference": "services", + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + "degraphql_routes": { + { + "uri": "/api/me", + "service": map[string]interface{}{ + // ID of Kong service "service1" in workspace "". + "id": "service1", + }, + }, + { + "uri": "/api/me", + "service": map[string]interface{}{ + // ID of Kong service "service2" in workspace "". + "id": "service2", + }, + }, + }, + }, + }, + { + name: "custom entity attached to route", + initialState: &KongState{ + Services: []Service{ + { + Service: kongService1, + Routes: []Route{ + { + Route: kong.Route{ + Name: kong.String("route1"), + ID: kong.String("route1"), + }, + Ingress: util.K8sObjectInfo{ + Name: "ingerss1", + Namespace: "default", + Annotations: map[string]string{ + annotations.AnnotationPrefix + annotations.PluginsKey: "degraphql-1", + }, + }, + }, + }, + }, + }, + }, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "degraphql_routes", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"uri":"/api/me"}`), + }, + ParentRef: &kongv1alpha1.ObjectReference{ + Group: kong.String(kongv1.GroupVersion.Group), + Kind: kong.String("KongPlugin"), + Name: "degraphql-1", + }, + }, + }, + }, + plugins: []*kongv1.KongPlugin{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + PluginName: "degraphql", + }, + }, + schemas: map[string]kong.Schema{ + "degraphql_routes": { + "fields": []interface{}{ + map[string]interface{}{ + "uri": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + map[string]interface{}{ + "route": map[string]interface{}{ + "type": "foreign", + "reference": "routes", + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + "degraphql_routes": { + { + "uri": "/api/me", + "route": map[string]interface{}{ + // ID of Kong route "route1". + "id": "route1", + }, + }, + }, + }, + }, + { + name: "custom entity attached to two services and one consumer", + initialState: &KongState{ + Services: []Service{ + ksService1, + ksService2, + }, + Consumers: []Consumer{ + { + Consumer: kong.Consumer{ + ID: kong.String("consumer1"), + Username: kong.String("consumer1"), + }, + K8sKongConsumer: kongv1.KongConsumer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "consumer1", + Annotations: map[string]string{ + annotations.AnnotationPrefix + annotations.PluginsKey: "degraphql-1", + }, + }, + }, + }, + }, + }, + customEntities: []*kongv1alpha1.KongCustomEntity{ + { + TypeMeta: customEntityTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "fake-entity-1", + }, + Spec: kongv1alpha1.KongCustomEntitySpec{ + EntityType: "fake_entities", + ControllerName: annotations.DefaultIngressClass, + Fields: apiextensionsv1.JSON{ + Raw: []byte(`{"foo":"bar"}`), + }, + ParentRef: &kongv1alpha1.ObjectReference{ + Group: kong.String(kongv1.GroupVersion.Group), + Kind: kong.String("KongPlugin"), + Name: "degraphql-1", + }, + }, + }, + }, + plugins: []*kongv1.KongPlugin{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "degraphql-1", + }, + PluginName: "degraphql", + }, + }, + schemas: map[string]kong.Schema{ + "fake_entities": { + "fields": []interface{}{ + map[string]interface{}{ + "foo": map[string]interface{}{ + "type": "string", + "required": true, + }, + }, + map[string]interface{}{ + "service": map[string]interface{}{ + "type": "foreign", + "reference": "services", + }, + }, + map[string]interface{}{ + "consumer": map[string]interface{}{ + "type": "foreign", + "reference": "consumers", + }, + }, + }, + }, + }, + expectedCustomEntities: map[string][]custom.Object{ + "fake_entities": { + { + "foo": "bar", + "service": map[string]interface{}{ + // ID of Kong service "service1". + "id": "service1", + }, + "consumer": map[string]interface{}{ + // ID of Kong consumer "consumer1". + "id": "consumer1", + }, + }, + { + "foo": "bar", + "service": map[string]interface{}{ + // ID of Kong service "service2". + "id": "service2", + }, + "consumer": map[string]interface{}{ + // ID of Kong consumer "consumer1". + "id": "consumer1", + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := store.NewFakeStore(store.FakeObjects{ + KongCustomEntities: tc.customEntities, + KongPlugins: tc.plugins, + }) + require.NoError(t, err) + failuresCollector := failures.NewResourceFailuresCollector(logr.Discard()) + + ks := tc.initialState + ks.FillCustomEntities( + logr.Discard(), s, + failuresCollector, + &fakeSchemaGetter{schemas: tc.schemas}, "", + ) + for entityType, expectedObjectList := range tc.expectedCustomEntities { + require.NotNil(t, ks.CustomEntities[entityType]) + objectList := lo.Map(ks.CustomEntities[entityType].Entities, func(e CustomEntity, _ int) custom.Object { + return e.Object + }) + require.Equal(t, expectedObjectList, objectList) + } + + translationFailures := failuresCollector.PopResourceFailures() + for nsName, message := range tc.expectedTranslationFailures { + hasError := lo.ContainsBy(translationFailures, func(f failures.ResourceFailure) bool { + fmt.Println(f.Message()) + return f.Message() == message && lo.ContainsBy(f.CausingObjects(), func(o client.Object) bool { + return o.GetNamespace() == nsName.Namespace && o.GetName() == nsName.Name + }) + }) + require.Truef(t, hasError, "translation error for KongCustomEntity %s not found", nsName) + } + }) + } +} diff --git a/internal/dataplane/kongstate/kongstate.go b/internal/dataplane/kongstate/kongstate.go index 6f601a8981..598cab97fd 100644 --- a/internal/dataplane/kongstate/kongstate.go +++ b/internal/dataplane/kongstate/kongstate.go @@ -1,9 +1,7 @@ package kongstate import ( - "context" "crypto/sha256" - "encoding/json" "fmt" "strconv" "strings" @@ -678,173 +676,6 @@ func maybeLogKongIngressDeprecationError(logger logr.Logger, services []*corev1. } } -// FillCustomEntities fills custom entities in KongState. -func (ks *KongState) FillCustomEntities( - logger logr.Logger, - s store.Storer, - failuresCollector *failures.ResourceFailuresCollector, - schemaGetter SchemaGetter, - workspace string, -) { - entities := s.ListKongCustomEntities() - if len(entities) == 0 { - return - } - logger = logger.WithName("fillCustomEntities") - - if ks.CustomEntities == nil { - ks.CustomEntities = map[string]*KongCustomEntityCollection{} - } - // Fetch relations between plugins and services/routes/consumers and store the pointer to translated Kong entities. - // Used for fetching entity referred by a custom entity and fill the ID of referred entity. - pluginRels := ks.getPluginRelatedEntitiesRef(s, logger) - - for _, entity := range entities { - // reject the custom entity if its type is in "known" entity types that are already processed. - if IsKnownEntityType(entity.Spec.EntityType) { - failuresCollector.PushResourceFailure( - fmt.Sprintf("cannot use known entity type %s in custom entity", entity.Spec.EntityType), - entity, - ) - continue - } - // Fetch the entity schema. - schema, err := ks.fetchEntitySchema(schemaGetter, entity.Spec.EntityType) - if err != nil { - failuresCollector.PushResourceFailure( - fmt.Sprintf("failed to fetch entity schema for entity type %s: %v", entity.Spec.EntityType, err), - entity, - ) - continue - } - // Unmarshal fields of the entity. - var parsedEntity map[string]interface{} - if err = json.Unmarshal(entity.Spec.Fields.Raw, &parsedEntity); err != nil { - failuresCollector.PushResourceFailure(fmt.Sprintf("failed to unmarshal fields of entity: %v", err), entity) - continue - } - // Fill the "foreign" fields if the entity has such fields referencing services/routes/consumers. - ks.fillCustomEntityForeignFields(logger, entity, schema, parsedEntity, pluginRels, workspace) - // Put the entity into the custom collection to store the entities of its type. - if _, ok := ks.CustomEntities[entity.Spec.EntityType]; !ok { - ks.CustomEntities[entity.Spec.EntityType] = &KongCustomEntityCollection{ - Schema: schema, - } - } - collection := ks.CustomEntities[entity.Spec.EntityType] - collection.Entities = append(collection.Entities, CustomEntity{ - Object: parsedEntity, - K8sKongCustomEntity: entity, - }) - } - - ks.sortCustomEntities() -} - -// fetchEntitySchema fetches schema of an entity by its type and stores the schema in its custom entity collection -// as a cache to avoid excessive calling of Kong admin APIs. -func (ks *KongState) fetchEntitySchema(schemaGetter SchemaGetter, entityType string) (EntitySchema, error) { - collection, ok := ks.CustomEntities[entityType] - if ok { - return collection.Schema, nil - } - // Use `context.Background()` here because `BuildKongConfig` does not provide a context. - schema, err := schemaGetter.Get(context.Background(), entityType) - if err != nil { - return EntitySchema{}, err - } - return ExtractEntityFieldDefinitions(schema), nil -} - -// fillCustomEntityForeignFields fills the "foreign" fields of a custom Kong entity -// if it refers to a service/route/consumer. -// Because Kong gateway requires the "foreign" fields to use IDs of the referred entity, -// it fills ID of the referred entity and fill the ID of the entity to the field. -// So the function has the side effect that the referred entity will have a generated fixed ID. -// It does not support fields referring to other types of entities. -func (ks *KongState) fillCustomEntityForeignFields( - logger logr.Logger, - k8sEntity *kongv1alpha1.KongCustomEntity, - schema EntitySchema, - parsedEntity map[string]any, - pluginRelEntities PluginRelatedEntitiesRefs, - workspace string, -) { - logger = logger.WithValues("entity_namespace", k8sEntity.Namespace, "entity_name", k8sEntity.Name) - // Find referred entity via the plugin in its spec.parentRef. - // Then we can fetch the referred service/route/consumer from the reference relations of the plugin. - parentRef := k8sEntity.Spec.ParentRef - var namespace string - // Abort if the parentRef is empty or does not refer to a plugin. - if parentRef == nil || - (parentRef.Group == nil || *parentRef.Group != kongv1alpha1.GroupVersion.Group) { - return - } - if parentRef.Kind == nil || (*parentRef.Kind != "KongPlugin" && *parentRef.Kind != "KongClusterPlugin") { - return - } - // Extract the plugin key to get the plugin relations. - if parentRef.Namespace == nil || *parentRef.Namespace == "" { - namespace = k8sEntity.Namespace - } else { - namespace = *parentRef.Namespace - } - pluginKey := namespace + ":" + parentRef.Name - // Get the relations with other entities of the plugin. - rels, ok := pluginRelEntities.RelatedEntities[pluginKey] - if !ok { - return - } - logger.V(util.DebugLevel).Info("fetch references via plugin", "plugin_key", pluginKey) - - // Traverse through the fields of the entity and fill the "foreign" fields with IDs of referring entities. - // Note: this procedure will make referred services'/routes'/consumers' ID to be filled. - // So it requires the `FillIDs` feature gate to be enabled. - for fieldName, field := range schema.Fields { - if field.Type != EntityFieldTypeForeign { - continue - } - switch field.Reference { - case string(kong.EntityTypeServices): - serviceIDs := getServiceIDFromPluginRels(logger, rels, pluginRelEntities.RouteAttachedService, workspace) - // TODO: we should generate multiple entities if the plugin is attached to multiple services/routes/consumers. - // https://github.com/Kong/kubernetes-ingress-controller/issues/6123 - if len(serviceIDs) > 0 { - parsedEntity[fieldName] = map[string]interface{}{ - "id": serviceIDs[0], - } - logger.V(util.DebugLevel).Info("added ref to service", "service_id", serviceIDs[0]) - } - case string(kong.EntityTypeRoutes): - routeIDs := lo.FilterMap(rels.Routes, func(r *Route, _ int) (string, bool) { - if err := r.FillID(workspace); err != nil { - return "", false - } - return *r.ID, true - }) - if len(routeIDs) > 0 { - parsedEntity[fieldName] = map[string]interface{}{ - "id": routeIDs[0], - } - logger.V(util.DebugLevel).Info("added ref to route", "route_id", routeIDs[0]) - } - case string(kong.EntityTypeConsumers): - consumerIDs := lo.FilterMap(rels.Consumers, func(c *Consumer, _ int) (string, bool) { - if err := c.FillID(workspace); err != nil { - return "", false - } - return *c.ID, true - }) - if len(consumerIDs) > 0 { - parsedEntity[fieldName] = map[string]interface{}{ - "id": consumerIDs[0], - } - logger.V(util.DebugLevel).Info("added ref to consumer", "consumer_id", consumerIDs[0]) - } - } - } -} - // getServiceIDFromPluginRels returns the ID of the services which a plugin refers to in RelatedEntitiesRef. // It fills the IDs of services directly referred, and IDs of services where referred routes attaches to. func getServiceIDFromPluginRels(log logr.Logger, rels RelatedEntitiesRef, routeAttachedService map[string]*Service, workspace string) []string { diff --git a/internal/dataplane/kongstate/kongstate_test.go b/internal/dataplane/kongstate/kongstate_test.go index 6ab9c89c64..f6d5c190c7 100644 --- a/internal/dataplane/kongstate/kongstate_test.go +++ b/internal/dataplane/kongstate/kongstate_test.go @@ -13,7 +13,6 @@ import ( "github.com/go-logr/logr/testr" "github.com/go-logr/zapr" "github.com/kong/go-kong/kong" - "github.com/kong/go-kong/kong/custom" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1527,287 +1526,3 @@ func (s *fakeSchemaGetter) Get(_ context.Context, entityType string) (kong.Schem } return schema, nil } - -func TestKongState_FillCustomEntities(t *testing.T) { - customEntityTypeMeta := metav1.TypeMeta{ - APIVersion: kongv1alpha1.GroupVersion.Group + "/" + kongv1alpha1.GroupVersion.Version, - Kind: "KongCustomEntity", - } - kongService1 := kong.Service{ - Name: kong.String("service1"), - } - getKongServiceID := func(s *kong.Service) string { - err := s.FillID("") - require.NoError(t, err) - return *s.ID - } - - testCases := []struct { - name string - initialState *KongState - customEntities []*kongv1alpha1.KongCustomEntity - plugins []*kongv1.KongPlugin - schemas map[string]kong.Schema - expectedCustomEntities map[string][]custom.Object - expectedTranslationFailures map[k8stypes.NamespacedName]string - }{ - { - name: "single custom entity", - initialState: &KongState{}, - customEntities: []*kongv1alpha1.KongCustomEntity{ - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "session-foo", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "sessions", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"name":"session1"}`), - }, - }, - }, - }, - schemas: map[string]kong.Schema{ - "sessions": { - "fields": []interface{}{ - map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "required": true, - }, - }, - }, - }, - }, - expectedCustomEntities: map[string][]custom.Object{ - "sessions": { - { - "name": "session1", - }, - }, - }, - }, - { - name: "custom entity with unknown type", - initialState: &KongState{}, - customEntities: []*kongv1alpha1.KongCustomEntity{ - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "session-foo", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "sessions", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"name":"session1"}`), - }, - }, - }, - }, - expectedTranslationFailures: map[k8stypes.NamespacedName]string{ - { - Namespace: "default", - Name: "session-foo", - }: "failed to fetch entity schema for entity type sessions: schema not found", - }, - }, - { - name: "multiple custom entities with same type", - initialState: &KongState{}, - customEntities: []*kongv1alpha1.KongCustomEntity{ - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "session-foo", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "sessions", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"name":"session-foo"}`), - }, - }, - }, - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "session-bar", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "sessions", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"name":"session-bar"}`), - }, - }, - }, - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default-1", - Name: "session-foo", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "sessions", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"name":"session-foo-1"}`), - }, - }, - }, - }, - schemas: map[string]kong.Schema{ - "sessions": { - "fields": []interface{}{ - map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "required": true, - }, - }, - }, - }, - }, - expectedCustomEntities: map[string][]custom.Object{ - // Should be sorted by original KCE namespace/name. - "sessions": { - { - // from default/bar - "name": "session-bar", - }, - { - // from default/foo - "name": "session-foo", - }, - { - // from default-1/foo - "name": "session-foo-1", - }, - }, - }, - }, - { - name: "custom entities with reference to other entities (services)", - initialState: &KongState{ - Services: []Service{ - { - Service: kongService1, - K8sServices: map[string]*corev1.Service{ - "default/service1": { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "service1", - Annotations: map[string]string{ - annotations.AnnotationPrefix + annotations.PluginsKey: "degraphql-1", - }, - }, - }, - }, - }, // Service: service1 - }, // Services - }, - customEntities: []*kongv1alpha1.KongCustomEntity{ - { - TypeMeta: customEntityTypeMeta, - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "degraphql-1", - }, - Spec: kongv1alpha1.KongCustomEntitySpec{ - EntityType: "degraphql_routes", - ControllerName: annotations.DefaultIngressClass, - Fields: apiextensionsv1.JSON{ - Raw: []byte(`{"uri":"/api/me"}`), - }, - ParentRef: &kongv1alpha1.ObjectReference{ - Group: kong.String(kongv1.GroupVersion.Group), - Kind: kong.String("KongPlugin"), - Name: "degraphql-1", - }, - }, - }, - }, - plugins: []*kongv1.KongPlugin{ - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "degraphql-1", - }, - PluginName: "degraphql", - }, - }, - schemas: map[string]kong.Schema{ - "degraphql_routes": { - "fields": []interface{}{ - map[string]interface{}{ - "uri": map[string]interface{}{ - "type": "string", - "required": true, - }, - }, - map[string]interface{}{ - "service": map[string]interface{}{ - "type": "foreign", - "reference": "services", - }, - }, - }, - }, - }, - expectedCustomEntities: map[string][]custom.Object{ - "degraphql_routes": { - { - "uri": "/api/me", - "service": map[string]interface{}{ - // ID generated from Kong service "service1" in workspace "". - "id": getKongServiceID(&kongService1), - }, - }, - }, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - s, err := store.NewFakeStore(store.FakeObjects{ - KongCustomEntities: tc.customEntities, - KongPlugins: tc.plugins, - }) - require.NoError(t, err) - failuresCollector := failures.NewResourceFailuresCollector(logr.Discard()) - - ks := tc.initialState - ks.FillCustomEntities( - logr.Discard(), s, - failuresCollector, - &fakeSchemaGetter{schemas: tc.schemas}, "", - ) - for entityType, expectedObjectList := range tc.expectedCustomEntities { - require.NotNil(t, ks.CustomEntities[entityType]) - objectList := lo.Map(ks.CustomEntities[entityType].Entities, func(e CustomEntity, _ int) custom.Object { - return e.Object - }) - require.Equal(t, expectedObjectList, objectList) - } - - translationFailures := failuresCollector.PopResourceFailures() - for nsName, message := range tc.expectedTranslationFailures { - hasError := lo.ContainsBy(translationFailures, func(f failures.ResourceFailure) bool { - fmt.Println(f.Message()) - return f.Message() == message && lo.ContainsBy(f.CausingObjects(), func(o client.Object) bool { - return o.GetNamespace() == nsName.Namespace && o.GetName() == nsName.Name - }) - }) - require.Truef(t, hasError, "translation error for KongCustomEntity %s not found", nsName) - } - }) - } -} diff --git a/test/integration/isolated/custom_entity_test.go b/test/integration/isolated/custom_entity_test.go index b898081891..b23bfd9557 100644 --- a/test/integration/isolated/custom_entity_test.go +++ b/test/integration/isolated/custom_entity_test.go @@ -14,6 +14,9 @@ import ( "github.com/kong/kubernetes-testing-framework/pkg/clusters" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/e2e-framework/pkg/envconf" "sigs.k8s.io/e2e-framework/pkg/features" @@ -132,6 +135,65 @@ func TestCustomEntityExample(t *testing.T) { return ctx }). + Assess("another ingress using the same degraphql plugin should also work", func(ctx context.Context, t *testing.T, conf *envconf.Config) context.Context { + const ( + ingressNamespace = "default" + serviceName = "hasura" + ingressName = "hasura-ingress-graphql" + alterServiceName = "hasura-alter" + alterIngressName = "hasura-ingress-graphql-alter" + ) + r := conf.Client().Resources() + + t.Log("creating alternative service") + svc := corev1.Service{} + require.NoError(t, r.Get(ctx, serviceName, ingressNamespace, &svc)) + alterService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: alterServiceName, + Namespace: ingressNamespace, + Labels: svc.Labels, + Annotations: svc.Annotations, + }, + } + alterService.Spec = *svc.Spec.DeepCopy() + alterService.Spec.ClusterIP = "" + alterService.Spec.ClusterIPs = []string{} + require.NoError(t, r.Create(ctx, alterService)) + + t.Log("creating alternative ingress with the same degraphql plugin attached") + ingress := netv1.Ingress{} + require.NoError(t, r.Get(ctx, ingressName, ingressNamespace, &ingress)) + alterIngress := &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: alterIngressName, + Namespace: ingressNamespace, + Labels: ingress.Labels, + Annotations: ingress.Annotations, + }, + } + alterIngress.Spec = *ingress.Spec.DeepCopy() + for i := range alterIngress.Spec.Rules { + alterIngress.Spec.Rules[i].Host = "alter-graphql.service.example" + for j := range alterIngress.Spec.Rules[i].HTTP.Paths { + alterIngress.Spec.Rules[i].HTTP.Paths[j].Backend = netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: alterServiceName, + Port: netv1.ServiceBackendPort{ + Number: int32(80), + }, + }, + } + } + } + require.NoError(t, r.Create(ctx, alterIngress)) + + t.Log("verifying degraphQL plugin and degraphql_routes entity works") + proxyURL := GetHTTPURLFromCtx(ctx) + helpers.EventuallyGETPath(t, proxyURL, "alter-graphql.service.example", "/contacts", http.StatusOK, `"name":"Alice"`, map[string]string{"Host": "graphql.service.example"}, consts.IngressWait, consts.WaitTick) + + return ctx + }). Teardown(featureTeardown()) tenv.Test(t, f.Feature())