Skip to content

Commit e44874a

Browse files
mheapPrashansa-K
andauthored
Add deck gateway apply command (#1459)
* feat(apply): add deck gateway apply command The `deck gateway apply` command allows you to apply partial configuration to a running Gateway instance. To do this, it runs a `sync` with the NoDeletes flag enabled. This means that only new and existing resources are updated. Existing resources that do not exist in the declarative configuration file are left untouched. * test(apply): Add integration tests * Disable consumer group test until deck dump is fixed * Check if the running Gateway is licensed before fetching Consumer Groups * Enable consumer group test * Skip Consumer Group test on OSS --------- Co-authored-by: Prashansa Kulshrestha <prashkulshrestha@gmail.com>
1 parent 412ad16 commit e44874a

File tree

21 files changed

+358
-22
lines changed

21 files changed

+358
-22
lines changed

cmd/common.go

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func RemoveConsumerPlugins(targetContentPlugins []file.FPlugin) []file.FPlugin {
131131
}
132132

133133
func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
134-
delay int, workspace string, enableJSONOutput bool,
134+
delay int, workspace string, enableJSONOutput bool, noDeletes bool,
135135
) error {
136136
// read target file
137137
if enableJSONOutput {
@@ -157,6 +157,13 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
157157
}
158158

159159
cmd := "sync"
160+
161+
isPartialApply := false
162+
if noDeletes {
163+
cmd = "apply"
164+
isPartialApply = true
165+
}
166+
160167
if dry {
161168
cmd = "diff"
162169
}
@@ -202,14 +209,31 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
202209
// load Kong version after workspace
203210
var kongVersion string
204211
var parsedKongVersion semver.Version
212+
isLicensedKongEnterprise := false
205213
if mode == modeKonnect {
206214
kongVersion = fetchKonnectKongVersion()
215+
isLicensedKongEnterprise = true
207216
} else {
208217
kongVersion, err = fetchKongVersion(ctx, wsConfig)
209218
if err != nil {
210219
return fmt.Errorf("reading Kong version: %w", err)
211220
}
221+
222+
// Are we running enterprise?
223+
v, err := kong.ParseSemanticVersion(kongVersion)
224+
if err != nil {
225+
return fmt.Errorf("parsing Kong version: %w", err)
226+
}
227+
228+
// Check if there's an active license for Consumer Group checks
229+
if v.IsKongGatewayEnterprise() {
230+
isLicensedKongEnterprise, err = isLicensed(ctx, wsConfig)
231+
if err != nil {
232+
return fmt.Errorf("checking if Kong is licensed: %w", err)
233+
}
234+
}
212235
}
236+
213237
parsedKongVersion, err = reconcilerUtils.ParseKongVersion(kongVersion)
214238
if err != nil {
215239
return fmt.Errorf("parsing Kong version: %w", err)
@@ -248,21 +272,24 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
248272
return err
249273
}
250274

251-
dumpConfig.LookUpSelectorTagsConsumerGroups, err = determineLookUpSelectorTagsConsumerGroups(*targetContent)
252-
if err != nil {
253-
return fmt.Errorf("error determining lookup selector tags for consumer groups: %w", err)
254-
}
255-
256-
if dumpConfig.LookUpSelectorTagsConsumerGroups != nil {
257-
consumerGroupsGlobal, err := dump.GetAllConsumerGroups(ctx, kongClient, dumpConfig.LookUpSelectorTagsConsumerGroups)
275+
// Consumer groups are an enterprise 3.4+ feature
276+
if parsedKongVersion.GTE(reconcilerUtils.Kong340Version) && isLicensedKongEnterprise {
277+
dumpConfig.LookUpSelectorTagsConsumerGroups, err = determineLookUpSelectorTagsConsumerGroups(*targetContent)
258278
if err != nil {
259-
return fmt.Errorf("error retrieving global consumer groups via lookup selector tags: %w", err)
279+
return fmt.Errorf("error determining lookup selector tags for consumer groups: %w", err)
260280
}
261-
for _, c := range consumerGroupsGlobal {
262-
targetContent.ConsumerGroups = append(targetContent.ConsumerGroups,
263-
file.FConsumerGroupObject{ConsumerGroup: *c.ConsumerGroup})
281+
282+
if dumpConfig.LookUpSelectorTagsConsumerGroups != nil || isPartialApply {
283+
consumerGroupsGlobal, err := dump.GetAllConsumerGroups(ctx, kongClient, dumpConfig.LookUpSelectorTagsConsumerGroups)
264284
if err != nil {
265-
return fmt.Errorf("error adding global consumer group %v: %w", *c.ConsumerGroup.Name, err)
285+
return fmt.Errorf("error retrieving global consumer groups via lookup selector tags: %w", err)
286+
}
287+
for _, c := range consumerGroupsGlobal {
288+
targetContent.ConsumerGroups = append(targetContent.ConsumerGroups,
289+
file.FConsumerGroupObject{ConsumerGroup: *c.ConsumerGroup})
290+
if err != nil {
291+
return fmt.Errorf("error adding global consumer group %v: %w", *c.ConsumerGroup.Name, err)
292+
}
266293
}
267294
}
268295
}
@@ -272,7 +299,7 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
272299
return fmt.Errorf("error determining lookup selector tags for consumers: %w", err)
273300
}
274301

275-
if dumpConfig.LookUpSelectorTagsConsumers != nil {
302+
if dumpConfig.LookUpSelectorTagsConsumers != nil || isPartialApply {
276303
consumersGlobal, err := dump.GetAllConsumers(ctx, kongClient, dumpConfig.LookUpSelectorTagsConsumers)
277304
if err != nil {
278305
return fmt.Errorf("error retrieving global consumers via lookup selector tags: %w", err)
@@ -290,7 +317,7 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
290317
return fmt.Errorf("error determining lookup selector tags for routes: %w", err)
291318
}
292319

293-
if dumpConfig.LookUpSelectorTagsRoutes != nil {
320+
if dumpConfig.LookUpSelectorTagsRoutes != nil || isPartialApply {
294321
routesGlobal, err := dump.GetAllRoutes(ctx, kongClient, dumpConfig.LookUpSelectorTagsRoutes)
295322
if err != nil {
296323
return fmt.Errorf("error retrieving global routes via lookup selector tags: %w", err)
@@ -308,7 +335,7 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
308335
return fmt.Errorf("error determining lookup selector tags for services: %w", err)
309336
}
310337

311-
if dumpConfig.LookUpSelectorTagsServices != nil {
338+
if dumpConfig.LookUpSelectorTagsServices != nil || isPartialApply {
312339
servicesGlobal, err := dump.GetAllServices(ctx, kongClient, dumpConfig.LookUpSelectorTagsServices)
313340
if err != nil {
314341
return fmt.Errorf("error retrieving global services via lookup selector tags: %w", err)
@@ -373,7 +400,7 @@ func syncMain(ctx context.Context, filenames []string, dry bool, parallelism,
373400
}
374401

375402
totalOps, err := performDiff(
376-
ctx, currentState, targetState, dry, parallelism, delay, kongClient, mode == modeKonnect, enableJSONOutput)
403+
ctx, currentState, targetState, dry, parallelism, delay, kongClient, mode == modeKonnect, enableJSONOutput, noDeletes)
377404
if err != nil {
378405
if enableJSONOutput {
379406
var errs reconcilerUtils.ErrArray
@@ -502,7 +529,7 @@ func fetchCurrentState(ctx context.Context, client *kong.Client, dumpConfig dump
502529

503530
func performDiff(ctx context.Context, currentState, targetState *state.KongState,
504531
dry bool, parallelism int, delay int, client *kong.Client, isKonnect bool,
505-
enableJSONOutput bool,
532+
enableJSONOutput bool, noDeletes bool,
506533
) (int, error) {
507534
s, err := diff.NewSyncer(diff.SyncerOpts{
508535
CurrentState: currentState,
@@ -511,6 +538,7 @@ func performDiff(ctx context.Context, currentState, targetState *state.KongState
511538
StageDelaySec: delay,
512539
NoMaskValues: noMaskValues,
513540
IsKonnect: isKonnect,
541+
NoDeletes: noDeletes,
514542
})
515543
if err != nil {
516544
return 0, err
@@ -542,6 +570,31 @@ func performDiff(ctx context.Context, currentState, targetState *state.KongState
542570
return int(totalOps), nil
543571
}
544572

573+
func isLicensed(ctx context.Context, config reconcilerUtils.KongClientConfig) (bool, error) {
574+
client, err := reconcilerUtils.GetKongClient(config)
575+
if err != nil {
576+
return false, err
577+
}
578+
579+
req, err := http.NewRequest("GET",
580+
reconcilerUtils.CleanAddress(config.Address)+"/",
581+
nil)
582+
if err != nil {
583+
return false, err
584+
}
585+
var resp map[string]interface{}
586+
_, err = client.Do(ctx, req, &resp)
587+
if err != nil {
588+
return false, err
589+
}
590+
_, ok := resp["license"]
591+
if !ok {
592+
return false, nil
593+
}
594+
595+
return true, nil
596+
}
597+
545598
func fetchKongVersion(ctx context.Context, config reconcilerUtils.KongClientConfig) (string, error) {
546599
var version string
547600

cmd/common_konnect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func resetKonnectV2(ctx context.Context) error {
127127
if err != nil {
128128
return err
129129
}
130-
_, err = performDiff(ctx, currentState, targetState, false, 10, 0, client, true, resetJSONOutput)
130+
_, err = performDiff(ctx, currentState, targetState, false, 10, 0, client, true, resetJSONOutput, false)
131131
if err != nil {
132132
return err
133133
}

cmd/gateway_apply.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
var (
8+
applyCmdParallelism int
9+
applyCmdDBUpdateDelay int
10+
applyWorkspace string
11+
applyJSONOutput bool
12+
)
13+
14+
var applyCmdKongStateFile []string
15+
16+
func executeApply(cmd *cobra.Command, _ []string) error {
17+
return syncMain(cmd.Context(), applyCmdKongStateFile, false,
18+
applyCmdParallelism, applyCmdDBUpdateDelay, applyWorkspace, applyJSONOutput, true)
19+
}
20+
21+
func newApplyCmd() *cobra.Command {
22+
short := "Apply configuration to Kong without deleting existing entities"
23+
execute := executeApply
24+
25+
applyCmd := &cobra.Command{
26+
Use: "apply [flags] [kong-state-files...]",
27+
Short: short,
28+
Long: `The apply command allows you to apply partial Kong configuration files without deleting existing entities.`,
29+
Args: cobra.MinimumNArgs(0),
30+
RunE: execute,
31+
PreRunE: func(_ *cobra.Command, args []string) error {
32+
applyCmdKongStateFile = args
33+
if len(applyCmdKongStateFile) == 0 {
34+
applyCmdKongStateFile = []string{"-"}
35+
}
36+
return preRunSilenceEventsFlag()
37+
},
38+
}
39+
40+
applyCmd.Flags().StringVarP(&applyWorkspace, "workspace", "w", "",
41+
"Apply configuration to a specific workspace "+
42+
"(Kong Enterprise only).\n"+
43+
"This takes precedence over _workspace fields in state files.")
44+
applyCmd.Flags().IntVar(&applyCmdParallelism, "parallelism",
45+
10, "Maximum number of concurrent operations.")
46+
applyCmd.Flags().IntVar(&applyCmdDBUpdateDelay, "db-update-propagation-delay",
47+
0, "artificial delay (in seconds) that is injected between insert operations \n"+
48+
"for related entities (usually for Cassandra deployments).\n"+
49+
"See `db_update_propagation` in kong.conf.")
50+
applyCmd.Flags().BoolVar(&syncJSONOutput, "json-output",
51+
false, "generate command execution report in a JSON format")
52+
addSilenceEventsFlag(applyCmd.Flags())
53+
54+
return applyCmd
55+
}

cmd/gateway_diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ var (
1717

1818
func executeDiff(cmd *cobra.Command, _ []string) error {
1919
return syncMain(cmd.Context(), diffCmdKongStateFile, true,
20-
diffCmdParallelism, 0, diffWorkspace, diffJSONOutput)
20+
diffCmdParallelism, 0, diffWorkspace, diffJSONOutput, false)
2121
}
2222

2323
// newDiffCmd represents the diff command

cmd/gateway_reset.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func executeReset(cmd *cobra.Command, _ []string) error {
9797
if err != nil {
9898
return err
9999
}
100-
_, err = performDiff(ctx, currentState, targetState, false, 10, 0, wsClient, false, resetJSONOutput)
100+
_, err = performDiff(ctx, currentState, targetState, false, 10, 0, wsClient, false, resetJSONOutput, false)
101101
if err != nil {
102102
return err
103103
}

cmd/gateway_sync.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ var syncCmdKongStateFile []string
1818

1919
func executeSync(cmd *cobra.Command, _ []string) error {
2020
return syncMain(cmd.Context(), syncCmdKongStateFile, false,
21-
syncCmdParallelism, syncCmdDBUpdateDelay, syncWorkspace, syncJSONOutput)
21+
syncCmdParallelism, syncCmdDBUpdateDelay, syncWorkspace, syncJSONOutput, false)
2222
}
2323

2424
// newSyncCmd represents the sync command

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ It can be used to export, import, or sync entities to Kong.`,
237237
gatewayCmd.AddCommand(newPingCmd(false))
238238
gatewayCmd.AddCommand(newDumpCmd(false))
239239
gatewayCmd.AddCommand(newDiffCmd(false))
240+
gatewayCmd.AddCommand(newApplyCmd())
240241
}
241242
{
242243
fileCmd := newFileSubCmd()

tests/integration/apply_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func Test_Apply_3x(t *testing.T) {
12+
// setup stage
13+
14+
tests := []struct {
15+
name string
16+
firstFile string
17+
secondFile string
18+
expectedState string
19+
runWhen string
20+
}{
21+
{
22+
name: "applies multiple of the same entity",
23+
firstFile: "testdata/apply/001-same-type/service-01.yaml",
24+
secondFile: "testdata/apply/001-same-type/service-02.yaml",
25+
expectedState: "testdata/apply/001-same-type/expected-state.yaml",
26+
runWhen: "kong",
27+
},
28+
{
29+
name: "applies different entity types",
30+
firstFile: "testdata/apply/002-different-types/service-01.yaml",
31+
secondFile: "testdata/apply/002-different-types/plugin-01.yaml",
32+
expectedState: "testdata/apply/002-different-types/expected-state.yaml",
33+
runWhen: "kong",
34+
},
35+
{
36+
name: "accepts consumer foreign keys",
37+
firstFile: "testdata/apply/003-foreign-keys-consumers/consumer-01.yaml",
38+
secondFile: "testdata/apply/003-foreign-keys-consumers/plugin-01.yaml",
39+
expectedState: "testdata/apply/003-foreign-keys-consumers/expected-state.yaml",
40+
runWhen: "kong",
41+
},
42+
{
43+
name: "accepts consumer group foreign keys",
44+
firstFile: "testdata/apply/004-foreign-keys-consumer-groups/consumer-group-01.yaml",
45+
secondFile: "testdata/apply/004-foreign-keys-consumer-groups/consumer-01.yaml",
46+
expectedState: "testdata/apply/004-foreign-keys-consumer-groups/expected-state.yaml",
47+
runWhen: "enterprise",
48+
},
49+
}
50+
for _, tc := range tests {
51+
t.Run(tc.name, func(t *testing.T) {
52+
runWhen(t, tc.runWhen, ">=3.0.0")
53+
setup(t)
54+
apply(tc.firstFile)
55+
apply(tc.secondFile)
56+
57+
out, _ := dump()
58+
59+
expected, err := readFile(tc.expectedState)
60+
if err != nil {
61+
t.Fatalf("failed to read expected state: %v", err)
62+
}
63+
64+
assert.Equal(t, expected, out)
65+
})
66+
}
67+
}

tests/integration/test_utils.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,16 @@ func setup(t *testing.T) {
266266
})
267267
}
268268

269+
func apply(kongFile string, opts ...string) error {
270+
deckCmd := cmd.NewRootCmd()
271+
args := []string{"gateway", "apply", kongFile}
272+
if len(opts) > 0 {
273+
args = append(args, opts...)
274+
}
275+
deckCmd.SetArgs(args)
276+
return deckCmd.ExecuteContext(context.Background())
277+
}
278+
269279
func sync(kongFile string, opts ...string) error {
270280
deckCmd := cmd.NewRootCmd()
271281
args := []string{"gateway", "sync", kongFile}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
_format_version: "3.0"
2+
services:
3+
- connect_timeout: 60000
4+
enabled: true
5+
host: httpbin.org
6+
name: mock1
7+
path: /anything
8+
port: 80
9+
protocol: http
10+
read_timeout: 60000
11+
retries: 5
12+
write_timeout: 60000
13+
- connect_timeout: 60000
14+
enabled: true
15+
host: httpbin.org
16+
name: mock2
17+
path: /anything
18+
port: 80
19+
protocol: http
20+
read_timeout: 60000
21+
retries: 5
22+
write_timeout: 60000

0 commit comments

Comments
 (0)