diff --git a/cmd/common.go b/cmd/common.go index a9184e2c9..03978b553 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -494,6 +494,151 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism, return nil } +// uniqueKeysByType maps each entity type name to the field names that must be +// unique within that type. reflect.Value.FieldByName resolves promoted fields +// from embedded structs automatically, so Kong entity types embedded inside +// file.F* types work without extra logic. +var uniqueKeysByType = map[string][]string{ + "FService": {"Name"}, + "FRoute": {"Name"}, + "FConsumer": {"Username", "CustomID"}, + "FConsumerGroupObject": {"Name"}, + "FUpstream": {"Name"}, + "FCertificate": {"Cert"}, + "FCACertificate": {"Cert"}, + "FVault": {"Prefix"}, + "FKey": {"Name"}, + "FKeySet": {"Name"}, + "FPartial": {"Name"}, + "FRBACRole": {"Name"}, + "FFilterChain": {"Name"}, + "SNI": {"Name"}, +} + +// checkCrossFileConflicts merges all filenames into one file.Content (the same +// way a regular multi-file sync would) and then scans the merged content for +// duplicate entity IDs (globally unique across all types) and duplicate natural +// keys (per entity type). +func checkCrossFileConflicts(filenames []string) error { + merged, err := file.GetContentFromFiles(filenames, false) + if err != nil { + return err + } + return detectDuplicatesInContent(merged) +} + +func detectDuplicatesInContent(content *file.Content) error { + globalIDs := map[string]bool{} // id -> label of first claimant + perTypeKeys := map[string]map[string]bool{} // typeName -> "field=value" -> seen + + cv := reflect.ValueOf(content).Elem() + for i := 0; i < cv.NumField(); i++ { + fv := cv.Field(i) + if !fv.IsValid() || fv.Kind() != reflect.Slice || fv.Len() == 0 { + continue + } + for j := 0; j < fv.Len(); j++ { + elem := derefValue(fv.Index(j)) + if !elem.IsValid() || elem.Kind() != reflect.Struct { + continue + } + if err := checkEntityConflicts(elem, globalIDs, perTypeKeys); err != nil { + return err + } + } + } + return nil +} + +func checkEntityConflicts(v reflect.Value, globalIDs map[string]bool, perTypeKeys map[string]map[string]bool) error { + typeName := v.Type().Name() + + // IDs must be globally unique across all entity types. + if id := stringPtrField(v, "ID"); id != "" { + if _, ok := globalIDs[id]; ok { + return fmt.Errorf("duplicate ID %q on %s", id, typeName) + } + globalIDs[id] = true + } + + // Natural keys must be unique within each entity type. + if fields, ok := uniqueKeysByType[typeName]; ok { + if perTypeKeys[typeName] == nil { + perTypeKeys[typeName] = map[string]bool{} + } + for _, fieldName := range fields { + val := stringPtrField(v, fieldName) + if val == "" { + continue + } + mapKey := fieldName + "=" + val + if perTypeKeys[typeName][mapKey] { + return fmt.Errorf("duplicate %s %q in %s", fieldName, val, typeName) + } + perTypeKeys[typeName][mapKey] = true + } + } + + // Recurse into any slice-of-struct fields: routes/plugins/filter-chains under + // services, plugins under consumers, SNIs under certificates, targets under + // upstreams, etc. No explicit nesting registry is needed — we walk every field + // of the current struct and recurse whenever its element type is a struct. + t := v.Type() + for i := 0; i < v.NumField(); i++ { + fv := v.Field(i) + if fv.Kind() != reflect.Slice || fv.Len() == 0 { + continue + } + // Determine the element type after stripping pointer indirection. + elemType := t.Field(i).Type.Elem() + for elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + if elemType.Kind() != reflect.Struct { + continue + } + for j := 0; j < fv.Len(); j++ { + elem := derefValue(fv.Index(j)) + if !elem.IsValid() || elem.Kind() != reflect.Struct { + continue + } + if err := checkEntityConflicts(elem, globalIDs, perTypeKeys); err != nil { + return err + } + } + } + + return nil +} + +// derefValue follows pointer indirections until a non-pointer value is reached. +// Returns a zero Value if a nil pointer is encountered. +func derefValue(v reflect.Value) reflect.Value { + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return reflect.Value{} + } + v = v.Elem() + } + return v +} + +// stringPtrField returns the string stored at a *string field named fieldName +// within struct v, resolving promoted fields from embedded structs via +// reflect.Value.FieldByName. Returns "" if the field does not exist, is not a +// *string, or is nil. +func stringPtrField(v reflect.Value, fieldName string) string { + fv := v.FieldByName(fieldName) + if !fv.IsValid() || fv.Kind() != reflect.Ptr || fv.IsNil() { + return "" + } + s, ok := fv.Interface().(*string) + if !ok || s == nil { + return "" + } + return *s +} + func validateSkipConsumersWithLookupTags(targetContent *file.Content) error { if dumpConfig.SkipConsumers && targetContent.Info != nil && targetContent.Info.LookUpSelectorTags != nil && (targetContent.Info.LookUpSelectorTags.Consumers != nil || diff --git a/cmd/gateway_apply.go b/cmd/gateway_apply.go index c3affb22a..5338bbcb5 100644 --- a/cmd/gateway_apply.go +++ b/cmd/gateway_apply.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" ) @@ -14,6 +16,29 @@ var ( var applyCmdKongStateFile []string func executeApply(cmd *cobra.Command, _ []string) error { + if syncNoMerge && len(applyCmdKongStateFile) > 0 { + // Save original SelectorTags from CLI flags. syncMain mutates the global + // dumpConfig.SelectorTags via determineSelectorTag (picking up tags from + // each file's _info.select_tags), so we must restore them before each + // iteration to prevent the first file's tags from bleeding into the next. + originalSelectorTags := dumpConfig.SelectorTags + for _, file := range applyCmdKongStateFile { + if file == "-" { + return fmt.Errorf("cannot use --no-merge with stdin input") + } + + dumpConfig.SelectorTags = originalSelectorTags + + err := syncMain(cmd.Context(), []string{file}, false, + applyCmdParallelism, applyCmdDBUpdateDelay, applyWorkspace, applyJSONOutput, ApplyTypePartial) + if err != nil { + return err + } + } + + return nil + } + return syncMain(cmd.Context(), applyCmdKongStateFile, false, applyCmdParallelism, applyCmdDBUpdateDelay, applyWorkspace, applyJSONOutput, ApplyTypePartial) } @@ -53,6 +78,8 @@ func newApplyCmd() *cobra.Command { applyCmd.Flags().BoolVar(&syncJSONOutput, "json-output", false, "generate command execution report in a JSON format") addSilenceEventsFlag(applyCmd.Flags()) + applyCmd.Flags().BoolVar(&syncNoMerge, "no-merge", + false, "do not merge the state file with the existing configuration") return applyCmd } diff --git a/cmd/gateway_diff.go b/cmd/gateway_diff.go index 02c0ac692..d6af06f85 100644 --- a/cmd/gateway_diff.go +++ b/cmd/gateway_diff.go @@ -16,6 +16,38 @@ var ( ) func executeDiff(cmd *cobra.Command, _ []string) error { + if syncNoMerge { + for _, f := range diffCmdKongStateFile { + if f == "-" { + return fmt.Errorf("cannot use --no-merge with stdin input") + } + } + + // Expand any directory arguments into individual files so that each + // file gets its own syncMain call with only its own select_tags. + expanded, err := expandToFiles(diffCmdKongStateFile) + if err != nil { + return err + } + + if err := checkCrossFileConflicts(expanded); err != nil { + return err + } + + originalSelectorTags := dumpConfig.SelectorTags + for _, batch := range batchFiles(expanded, syncBatchSize) { + dumpConfig.SelectorTags = originalSelectorTags + + err := syncMain(cmd.Context(), batch, true, + diffCmdParallelism, 0, diffWorkspace, diffJSONOutput, ApplyTypeFull) + if err != nil { + return err + } + } + + return nil + } + return syncMain(cmd.Context(), diffCmdKongStateFile, true, diffCmdParallelism, 0, diffWorkspace, diffJSONOutput, ApplyTypeFull) } @@ -113,6 +145,12 @@ that will be created, updated, or deleted. "This flag is not valid with Konnect.") diffCmd.Flags().BoolVar(&syncCmdAssumeYes, "yes", false, "assume `yes` to prompts and run non-interactively.") + diffCmd.Flags().BoolVar(&syncNoMerge, "no-merge", + false, "do not merge the state file with the existing configuration") + diffCmd.Flags().IntVar(&syncBatchSize, "batch-size", + 1, "number of files to process per batch when --no-merge is set.\n"+ + "Defaults to 1 (one file at a time). Use a higher value to process\n"+ + "multiple files per batch for better performance.") addSilenceEventsFlag(diffCmd.Flags()) return diffCmd } diff --git a/cmd/gateway_sync.go b/cmd/gateway_sync.go index 18f30a1b5..5bc25af27 100644 --- a/cmd/gateway_sync.go +++ b/cmd/gateway_sync.go @@ -12,11 +12,41 @@ var ( syncCmdDBUpdateDelay int syncWorkspace string syncJSONOutput bool + syncNoMerge bool + syncBatchSize int ) var syncCmdKongStateFile []string func executeSync(cmd *cobra.Command, _ []string) error { + if syncNoMerge { + for _, f := range syncCmdKongStateFile { + if f == "-" { + return fmt.Errorf("cannot use --no-merge with stdin input") + } + } + + // Expand any directory arguments into individual files so that each + // file gets its own syncMain call with only its own select_tags. + expanded, err := expandToFiles(syncCmdKongStateFile) + if err != nil { + return err + } + + originalSelectorTags := dumpConfig.SelectorTags + for _, batch := range batchFiles(expanded, syncBatchSize) { + dumpConfig.SelectorTags = originalSelectorTags + + err := syncMain(cmd.Context(), batch, false, + syncCmdParallelism, syncCmdDBUpdateDelay, syncWorkspace, syncJSONOutput, ApplyTypeFull) + if err != nil { + return err + } + } + + return nil + } + return syncMain(cmd.Context(), syncCmdKongStateFile, false, syncCmdParallelism, syncCmdDBUpdateDelay, syncWorkspace, syncJSONOutput, ApplyTypeFull) } @@ -113,6 +143,12 @@ to get Kong's state in sync with the input state.`, false, "assume `yes` to prompts and run non-interactively.") syncCmd.Flags().BoolVar(&syncJSONOutput, "json-output", false, "generate command execution report in a JSON format") + syncCmd.Flags().BoolVar(&syncNoMerge, "no-merge", + false, "do not merge the state file with the existing configuration") + syncCmd.Flags().IntVar(&syncBatchSize, "batch-size", + 1, "number of files to process per batch when --no-merge is set.\n"+ + "Defaults to 1 (one file at a time). Use a higher value to process\n"+ + "multiple files per batch for better performance.") addSilenceEventsFlag(syncCmd.Flags()) return syncCmd } diff --git a/cmd/gateway_validate.go b/cmd/gateway_validate.go index a09f65bc0..c1b398720 100644 --- a/cmd/gateway_validate.go +++ b/cmd/gateway_validate.go @@ -25,18 +25,44 @@ var ( validateKonnectCompatibility bool validateWorkspace string validateParallelism int + validateNoMerge bool ) func lookupSelectorTagsIsSet(targetContent *file.Content) bool { return targetContent.Info != nil && targetContent.Info.LookUpSelectorTags != nil } -func executeValidate(cmd *cobra.Command, _ []string) error { +func validateMain(cmd *cobra.Command, _ []string) error { + if validateNoMerge && len(validateCmdKongStateFile) > 0 { + for _, file := range validateCmdKongStateFile { + if file == "-" { + return fmt.Errorf("cannot use --no-merge with stdin input") + } + } + + if err := checkCrossFileConflicts(validateCmdKongStateFile); err != nil { + return err + } + + for _, file := range validateCmdKongStateFile { + err := executeValidate(cmd.Context(), []string{file}) + if err != nil { + return err + } + } + + return nil + } + + return executeValidate(cmd.Context(), validateCmdKongStateFile) +} + +func executeValidate(ctx context.Context, validationStateFiles []string) error { mode := getMode(nil) _ = sendAnalytics("validate", "", mode) // read target file // this does json schema validation as well - targetContent, err := file.GetContentFromFiles(validateCmdKongStateFile, false) + targetContent, err := file.GetContentFromFiles(validationStateFiles, false) if err != nil { return err } @@ -50,7 +76,6 @@ func executeValidate(cmd *cobra.Command, _ []string) error { if err != nil { return err } - ctx := cmd.Context() var kongClient *kong.Client if validateOnline { // if workspace is not set via flag, use the one in the state file @@ -188,7 +213,7 @@ parsing issues. It also checks for foreign relationships and alerts if there are broken relationships, or missing links present. ` - execute := executeValidate + execute := validateMain argsValidator := cobra.MinimumNArgs(0) var preRun func(cmd *cobra.Command, args []string) error @@ -213,7 +238,7 @@ this command unless --online flag is used. " - the '--online' flag is removed, use either 'deck file' or 'deck gateway'\n"+ " - the default changed from 'kong.yaml' to '-' (stdin/stdout)\n") - return executeValidate(cmd, args) + return executeValidate(cmd.Context(), args) } argsValidator = validateNoArgs preRun = func(_ *cobra.Command, _ []string) error { @@ -297,6 +322,8 @@ this command unless --online flag is used. 10, "Maximum number of concurrent requests to Kong.") validateCmd.Flags().BoolVar(&validateKonnectCompatibility, "konnect-compatibility", false, "validate that the state file(s) are ready to be deployed to Konnect") + validateCmd.Flags().BoolVar(&validateNoMerge, "no-merge", + false, "indicate that the state file(s) should not be merged before validation.") validateCmd.MarkFlagsMutuallyExclusive("konnect-compatibility", "workspace") validateCmd.MarkFlagsMutuallyExclusive("konnect-compatibility", "rbac-resources-only") diff --git a/cmd/utils.go b/cmd/utils.go index ba0c70ed0..5e35d02a4 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -3,11 +3,13 @@ package cmd import ( "errors" "fmt" + "os" "slices" "github.com/fatih/color" "github.com/kong/go-database-reconciler/pkg/cprint" "github.com/kong/go-database-reconciler/pkg/diff" + reconcilerUtils "github.com/kong/go-database-reconciler/pkg/utils" "github.com/spf13/pflag" ) @@ -47,3 +49,39 @@ func validateInputFlag(flagName string, flagValue string, allowedValues []string return fmt.Errorf("invalid value '%s' found for the '%s' flag. Allowed values: %v", flagValue, flagName, allowedValues) } + +// expandToFiles expands a list of file/directory paths into a flat list of +// individual files. Directories are walked recursively. +func expandToFiles(paths []string) ([]string, error) { + var result []string + for _, p := range paths { + fi, err := os.Stat(p) + if err != nil || !fi.IsDir() { + result = append(result, p) + continue + } + files, err := reconcilerUtils.ConfigFilesInDir(p) + if err != nil { + return nil, err + } + result = append(result, files...) + } + return result, nil +} + +// batchFiles splits files into consecutive batches of at most size entries. +// If size < 1, each file is its own batch (equivalent to size=1). +func batchFiles(files []string, size int) [][]string { + if size < 1 { + size = 1 + } + var batches [][]string + for i := 0; i < len(files); i += size { + end := i + size + if end > len(files) { + end = len(files) + } + batches = append(batches, files[i:end]) + } + return batches +} diff --git a/tests/integration/sync_test.go b/tests/integration/sync_test.go index 5b6d26a7a..8aac28181 100644 --- a/tests/integration/sync_test.go +++ b/tests/integration/sync_test.go @@ -11855,3 +11855,340 @@ func testSyncPluginsNestedForeignKeysExternalEntitiesKonnectImpl(t *testing.T) { }) } } + +func Test_Sync_Entities_No_Merge(t *testing.T) { + setup(t) + + client, err := getTestClient() + require.NoError(t, err) + + ctx := context.Background() + + tests := []struct { + name string + runWhen func(t *testing.T) + stateFiles []string + assertFn func(t *testing.T) + expectedErr string + }{ + { + name: "consumers from two tagged files are each synced independently", + runWhen: func(t *testing.T) { runWhen(t, "kong", ">=3.0.0") }, + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/consumer1.yaml", + "testdata/sync/051-sync-no-merge/consumer2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + // tag1 scope must contain only consumer1 + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"consumer-tag1"}}, t) + require.NoError(t, err) + consumers1, err := state1.Consumers.GetAll() + require.NoError(t, err) + require.Len(t, consumers1, 1) + assert.Equal(t, "consumer1", *consumers1[0].Username) + + // tag2 scope must contain only consumer2 + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"consumer-tag2"}}, t) + require.NoError(t, err) + consumers2, err := state2.Consumers.GetAll() + require.NoError(t, err) + require.Len(t, consumers2, 1) + assert.Equal(t, "consumer2", *consumers2[0].Username) + }, + }, + { + name: "services from two tagged files are each synced independently", + runWhen: func(t *testing.T) { runWhen(t, "kong", ">=3.0.0") }, + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/service1.yaml", + "testdata/sync/051-sync-no-merge/service2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + // svc-tag1 scope must contain only service1 + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"svc-tag1"}}, t) + require.NoError(t, err) + services1, err := state1.Services.GetAll() + require.NoError(t, err) + require.Len(t, services1, 1) + assert.Equal(t, "service1", *services1[0].Name) + + // svc-tag2 scope must contain only service2 + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"svc-tag2"}}, t) + require.NoError(t, err) + services2, err := state2.Services.GetAll() + require.NoError(t, err) + require.Len(t, services2, 1) + assert.Equal(t, "service2", *services2[0].Name) + }, + }, + { + name: "routes from two tagged files are each synced independently", + runWhen: func(t *testing.T) { runWhen(t, "kong", ">=3.0.0") }, + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/route1.yaml", + "testdata/sync/051-sync-no-merge/route2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + // route-tag1 scope must contain only route1 (and its parent service) + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"route-tag1"}}, t) + require.NoError(t, err) + routes1, err := state1.Routes.GetAll() + require.NoError(t, err) + require.Len(t, routes1, 1) + assert.Equal(t, "route1", *routes1[0].Name) + + // route-tag2 scope must contain only route2 (and its parent service) + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"route-tag2"}}, t) + require.NoError(t, err) + routes2, err := state2.Routes.GetAll() + require.NoError(t, err) + require.Len(t, routes2, 1) + assert.Equal(t, "route2", *routes2[0].Name) + }, + }, + { + name: "consumer-groups from two tagged files are each synced independently", + runWhen: func(t *testing.T) { runWhen(t, "enterprise", ">=3.0.0") }, + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/consumer-group1.yaml", + "testdata/sync/051-sync-no-merge/consumer-group2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + // cg-tag1 scope must contain only group1 + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"cg-tag1"}}, t) + require.NoError(t, err) + groups1, err := state1.ConsumerGroups.GetAll() + require.NoError(t, err) + require.Len(t, groups1, 1) + assert.Equal(t, "group1", *groups1[0].Name) + + // cg-tag2 scope must contain only group2 + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"cg-tag2"}}, t) + require.NoError(t, err) + groups2, err := state2.ConsumerGroups.GetAll() + require.NoError(t, err) + require.Len(t, groups2, 1) + assert.Equal(t, "group2", *groups2[0].Name) + }, + }, + { + name: "plugins from two tagged files are each synced independently", + runWhen: func(t *testing.T) { runWhen(t, "kong", ">=3.0.0") }, + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/plugin1.yaml", + "testdata/sync/051-sync-no-merge/plugin2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + // plugin-tag1 scope must contain only rate-limiting + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"plugin-tag1"}}, t) + require.NoError(t, err) + plugins1, err := state1.Plugins.GetAll() + require.NoError(t, err) + require.Len(t, plugins1, 1) + assert.Equal(t, "rate-limiting", *plugins1[0].Name) + + // plugin-tag2 scope must contain only request-size-limiting + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"plugin-tag2"}}, t) + require.NoError(t, err) + plugins2, err := state2.Plugins.GetAll() + require.NoError(t, err) + require.Len(t, plugins2, 1) + assert.Equal(t, "request-size-limiting", *plugins2[0].Name) + }, + }, + { + name: "stdin input is rejected when --no-merge is set", + runWhen: func(t *testing.T) { runWhen(t, "kong", ">=3.0.0") }, + stateFiles: []string{ + "-", + "testdata/sync/051-sync-no-merge/consumer1.yaml", + }, + expectedErr: "cannot use --no-merge with stdin input", + assertFn: func(t *testing.T) { t.Helper() }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.runWhen(t) + reset(t) + + err := multiFileSync(ctx, tc.stateFiles, "--no-merge") + + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + + require.NoError(t, err) + tc.assertFn(t) + }) + } +} + +// test scope: +// - konnect +func Test_Sync_Entities_No_Merge_Konnect(t *testing.T) { + runDualTestWithSkipDefaults(t, "Test_Sync_Entities_No_Merge_Konnect", testSyncEntitiesNoMergeKonnectImpl) +} + +func testSyncEntitiesNoMergeKonnectImpl(t *testing.T) { + runWhenKonnect(t) + client, err := getTestClient() + require.NoError(t, err) + + ctx := context.Background() + + tests := []struct { + name string + stateFiles []string + assertFn func(t *testing.T) + expectedErr string + }{ + { + name: "consumers from two tagged files are each synced independently", + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/consumer1.yaml", + "testdata/sync/051-sync-no-merge/consumer2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"consumer-tag1"}}, t) + require.NoError(t, err) + consumers1, err := state1.Consumers.GetAll() + require.NoError(t, err) + require.Len(t, consumers1, 1) + assert.Equal(t, "consumer1", *consumers1[0].Username) + + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"consumer-tag2"}}, t) + require.NoError(t, err) + consumers2, err := state2.Consumers.GetAll() + require.NoError(t, err) + require.Len(t, consumers2, 1) + assert.Equal(t, "consumer2", *consumers2[0].Username) + }, + }, + { + name: "services from two tagged files are each synced independently", + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/service1.yaml", + "testdata/sync/051-sync-no-merge/service2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"svc-tag1"}}, t) + require.NoError(t, err) + services1, err := state1.Services.GetAll() + require.NoError(t, err) + require.Len(t, services1, 1) + assert.Equal(t, "service1", *services1[0].Name) + + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"svc-tag2"}}, t) + require.NoError(t, err) + services2, err := state2.Services.GetAll() + require.NoError(t, err) + require.Len(t, services2, 1) + assert.Equal(t, "service2", *services2[0].Name) + }, + }, + { + name: "routes from two tagged files are each synced independently", + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/route1.yaml", + "testdata/sync/051-sync-no-merge/route2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"route-tag1"}}, t) + require.NoError(t, err) + routes1, err := state1.Routes.GetAll() + require.NoError(t, err) + require.Len(t, routes1, 1) + assert.Equal(t, "route1", *routes1[0].Name) + + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"route-tag2"}}, t) + require.NoError(t, err) + routes2, err := state2.Routes.GetAll() + require.NoError(t, err) + require.Len(t, routes2, 1) + assert.Equal(t, "route2", *routes2[0].Name) + }, + }, + { + name: "consumer-groups from two tagged files are each synced independently", + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/consumer-group1.yaml", + "testdata/sync/051-sync-no-merge/consumer-group2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"cg-tag1"}}, t) + require.NoError(t, err) + groups1, err := state1.ConsumerGroups.GetAll() + require.NoError(t, err) + require.Len(t, groups1, 1) + assert.Equal(t, "group1", *groups1[0].Name) + + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"cg-tag2"}}, t) + require.NoError(t, err) + groups2, err := state2.ConsumerGroups.GetAll() + require.NoError(t, err) + require.Len(t, groups2, 1) + assert.Equal(t, "group2", *groups2[0].Name) + }, + }, + { + name: "plugins from two tagged files are each synced independently", + stateFiles: []string{ + "testdata/sync/051-sync-no-merge/plugin1.yaml", + "testdata/sync/051-sync-no-merge/plugin2.yaml", + }, + assertFn: func(t *testing.T) { + t.Helper() + state1, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"plugin-tag1"}}, t) + require.NoError(t, err) + plugins1, err := state1.Plugins.GetAll() + require.NoError(t, err) + require.Len(t, plugins1, 1) + assert.Equal(t, "rate-limiting", *plugins1[0].Name) + + state2, err := fetchCurrentState(ctx, client, deckDump.Config{SelectorTags: []string{"plugin-tag2"}}, t) + require.NoError(t, err) + plugins2, err := state2.Plugins.GetAll() + require.NoError(t, err) + require.Len(t, plugins2, 1) + assert.Equal(t, "request-size-limiting", *plugins2[0].Name) + }, + }, + { + name: "stdin input is rejected when --no-merge is set", + stateFiles: []string{ + "-", + "testdata/sync/051-sync-no-merge/consumer1.yaml", + }, + expectedErr: "cannot use --no-merge with stdin input", + assertFn: func(t *testing.T) { t.Helper() }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setup(t) + + err := multiFileSync(ctx, tc.stateFiles, "--no-merge") + + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + + require.NoError(t, err) + tc.assertFn(t) + }) + } +} diff --git a/tests/integration/testdata/sync/051-sync-no-merge/consumer-group1.yaml b/tests/integration/testdata/sync/051-sync-no-merge/consumer-group1.yaml new file mode 100644 index 000000000..459d9eaa5 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/consumer-group1.yaml @@ -0,0 +1,7 @@ +_format_version: "3.0" +_info: + select_tags: + - cg-tag1 + +consumer_groups: +- name: group1 diff --git a/tests/integration/testdata/sync/051-sync-no-merge/consumer-group2.yaml b/tests/integration/testdata/sync/051-sync-no-merge/consumer-group2.yaml new file mode 100644 index 000000000..3a3a0f3cc --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/consumer-group2.yaml @@ -0,0 +1,7 @@ +_format_version: "3.0" +_info: + select_tags: + - cg-tag2 + +consumer_groups: +- name: group2 diff --git a/tests/integration/testdata/sync/051-sync-no-merge/consumer1.yaml b/tests/integration/testdata/sync/051-sync-no-merge/consumer1.yaml new file mode 100644 index 000000000..9fc0f2094 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/consumer1.yaml @@ -0,0 +1,7 @@ +_format_version: "3.0" +_info: + select_tags: + - consumer-tag1 + +consumers: +- username: consumer1 \ No newline at end of file diff --git a/tests/integration/testdata/sync/051-sync-no-merge/consumer2.yaml b/tests/integration/testdata/sync/051-sync-no-merge/consumer2.yaml new file mode 100644 index 000000000..2c439ed25 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/consumer2.yaml @@ -0,0 +1,7 @@ +_format_version: "3.0" +_info: + select_tags: + - consumer-tag2 + +consumers: +- username: consumer2 \ No newline at end of file diff --git a/tests/integration/testdata/sync/051-sync-no-merge/plugin1.yaml b/tests/integration/testdata/sync/051-sync-no-merge/plugin1.yaml new file mode 100644 index 000000000..48f2a1493 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/plugin1.yaml @@ -0,0 +1,10 @@ +_format_version: "3.0" +_info: + select_tags: + - plugin-tag1 + +plugins: +- name: rate-limiting + config: + minute: 5 + policy: local diff --git a/tests/integration/testdata/sync/051-sync-no-merge/plugin2.yaml b/tests/integration/testdata/sync/051-sync-no-merge/plugin2.yaml new file mode 100644 index 000000000..5fe5dd24a --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/plugin2.yaml @@ -0,0 +1,9 @@ +_format_version: "3.0" +_info: + select_tags: + - plugin-tag2 + +plugins: +- name: request-size-limiting + config: + allowed_payload_size: 128 diff --git a/tests/integration/testdata/sync/051-sync-no-merge/route1.yaml b/tests/integration/testdata/sync/051-sync-no-merge/route1.yaml new file mode 100644 index 000000000..8ff298f17 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/route1.yaml @@ -0,0 +1,14 @@ +_format_version: "3.0" +_info: + select_tags: + - route-tag1 + +services: +- name: route-service1 + host: route1.test + port: 80 + protocol: http + routes: + - name: route1 + paths: + - /route1 diff --git a/tests/integration/testdata/sync/051-sync-no-merge/route2.yaml b/tests/integration/testdata/sync/051-sync-no-merge/route2.yaml new file mode 100644 index 000000000..1b460e92b --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/route2.yaml @@ -0,0 +1,14 @@ +_format_version: "3.0" +_info: + select_tags: + - route-tag2 + +services: +- name: route-service2 + host: route2.test + port: 80 + protocol: http + routes: + - name: route2 + paths: + - /route2 diff --git a/tests/integration/testdata/sync/051-sync-no-merge/service1.yaml b/tests/integration/testdata/sync/051-sync-no-merge/service1.yaml new file mode 100644 index 000000000..0d349d743 --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/service1.yaml @@ -0,0 +1,10 @@ +_format_version: "3.0" +_info: + select_tags: + - svc-tag1 + +services: +- name: service1 + host: service1.test + port: 80 + protocol: http diff --git a/tests/integration/testdata/sync/051-sync-no-merge/service2.yaml b/tests/integration/testdata/sync/051-sync-no-merge/service2.yaml new file mode 100644 index 000000000..1fb202c9b --- /dev/null +++ b/tests/integration/testdata/sync/051-sync-no-merge/service2.yaml @@ -0,0 +1,10 @@ +_format_version: "3.0" +_info: + select_tags: + - svc-tag2 + +services: +- name: service2 + host: service2.test + port: 80 + protocol: http