Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
27 changes: 27 additions & 0 deletions cmd/gateway_apply.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
38 changes: 38 additions & 0 deletions cmd/gateway_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
36 changes: 36 additions & 0 deletions cmd/gateway_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
37 changes: 32 additions & 5 deletions cmd/gateway_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading