diff --git a/docs/declarative.md b/docs/declarative.md index 03bdebf7..632435de 100644 --- a/docs/declarative.md +++ b/docs/declarative.md @@ -178,6 +178,7 @@ lists the currently supported resources and their relationships. - Portals - Application Auth Strategies - Control Planes (including Control Plane Groups) +- Event Gateways **Child Resources** (do NOT support kongctl metadata): diff --git a/docs/examples/declarative/event-gateway/event-gateway.yaml b/docs/examples/declarative/event-gateway/event-gateway.yaml new file mode 100644 index 00000000..18fd3313 --- /dev/null +++ b/docs/examples/declarative/event-gateway/event-gateway.yaml @@ -0,0 +1,6 @@ +event_gateways: + - ref: getting-started-event-gateway + name: "My First Event Gateway" + description: "My first event gateway control plane for managing event-driven architectures" + kongctl: + namespace: "default" diff --git a/internal/cmd/root/products/konnect/adopt/event_gateway.go b/internal/cmd/root/products/konnect/adopt/event_gateway.go new file mode 100644 index 00000000..95d7f548 --- /dev/null +++ b/internal/cmd/root/products/konnect/adopt/event_gateway.go @@ -0,0 +1,285 @@ +package adopt + +import ( + "fmt" + "net/url" + "strings" + + kk "github.com/Kong/sdk-konnect-go" + kkComps "github.com/Kong/sdk-konnect-go/models/components" + kkOps "github.com/Kong/sdk-konnect-go/models/operations" + cmdpkg "github.com/kong/kongctl/internal/cmd" + cmdCommon "github.com/kong/kongctl/internal/cmd/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/config" + "github.com/kong/kongctl/internal/declarative/labels" + "github.com/kong/kongctl/internal/declarative/validator" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/util" + "github.com/segmentio/cli" + "github.com/spf13/cobra" +) + +func NewEventGatewayControlPlaneCmd( + verb verbs.VerbValue, + baseCmd *cobra.Command, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) (*cobra.Command, error) { + cmd := baseCmd + if cmd == nil { + cmd = &cobra.Command{} + } + + cmd.Use = "event-gateway " + cmd.Short = "Adopt an existing Konnect Event Gateway Control Plane into namespace management" + cmd.Long = "Apply the KONGCTL-namespace label to an existing Konnect Event Gateway Control Plane " + + "that is not currently managed by kongctl." + cmd.Args = func(_ *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("exactly one event gateway control plane identifier (name or ID) is required") + } + if trimmed := strings.TrimSpace(args[0]); trimmed == "" { + return fmt.Errorf("event gateway control plane identifier cannot be empty") + } + return nil + } + + if addParentFlags != nil { + addParentFlags(verb, cmd) + } + + if parentPreRun != nil { + cmd.PreRunE = parentPreRun + } + + cmd.Flags().String(NamespaceFlagName, "", "Namespace label to apply to the resource") + if err := cmd.MarkFlagRequired(NamespaceFlagName); err != nil { + return nil, err + } + + cmd.RunE = func(cobraCmd *cobra.Command, args []string) error { + helper := cmdpkg.BuildHelper(cobraCmd, args) + + namespace, err := cobraCmd.Flags().GetString(NamespaceFlagName) + if err != nil { + return err + } + + nsValidator := validator.NewNamespaceValidator() + if err := nsValidator.ValidateNamespace(namespace); err != nil { + return &cmdpkg.ConfigurationError{Err: err} + } + + outType, err := helper.GetOutputFormat() + if err != nil { + return err + } + + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + logger, err := helper.GetLogger() + if err != nil { + return err + } + + sdk, err := helper.GetKonnectSDK(cfg, logger) + if err != nil { + return err + } + + result, err := adoptEventGatewayControlPlane( + helper, + sdk.GetEventGatewayControlPlaneAPI(), + cfg, + namespace, + strings.TrimSpace(args[0]), + ) + if err != nil { + return err + } + + streams := helper.GetStreams() + if outType == cmdCommon.TEXT { + name := result.Name + if name == "" { + name = result.ID + } + fmt.Fprintf( + streams.Out, + "Adopted Event Gateway Control Plane %q (%s) into namespace %q\n", + name, + result.ID, + result.Namespace, + ) + return nil + } + + printer, err := cli.Format(outType.String(), streams.Out) + if err != nil { + return err + } + defer printer.Flush() + printer.Print(result) + return nil + } + + return cmd, nil +} + +func adoptEventGatewayControlPlane( + helper cmdpkg.Helper, + egwClient helpers.EGWControlPlaneAPI, + cfg config.Hook, + namespace string, + identifier string, +) (*adoptResult, error) { + egw, err := resolveEventGatewayControlPlane(helper, egwClient, cfg, identifier) + if err != nil { + return nil, err + } + + if existing := egw.Labels; existing != nil { + if currentNamespace, ok := existing[labels.NamespaceKey]; ok && currentNamespace != "" { + return nil, &cmdpkg.ConfigurationError{ + Err: fmt.Errorf("event gateway control plane %q already has namespace label %q", egw.Name, currentNamespace), + } + } + } + + updateReq := kkComps.UpdateGatewayRequest{ + Name: &egw.Name, + Description: egw.Description, + Labels: stringLabelMap(egw.Labels, namespace), + } + + ctx := ensureContext(helper.GetContext()) + + resp, err := egwClient.UpdateEGWControlPlane(ctx, egw.ID, updateReq) + if err != nil { + attrs := cmdpkg.TryConvertErrorToAttrs(err) + return nil, cmdpkg.PrepareExecutionError( + "failed to update Event Gateway Control Plane", + err, + helper.GetCmd(), + attrs..., + ) + } + + updated := resp.EventGatewayInfo + if updated == nil { + return nil, cmdpkg.PrepareExecutionErrorMsg(helper, "update Event Gateway Control Plane failed") + } + + ns := namespace + if updated.Labels != nil { + if v, ok := updated.Labels[labels.NamespaceKey]; ok && v != "" { + ns = v + } + } + + return &adoptResult{ + ResourceType: "event_gateway", + ID: updated.ID, + Name: updated.Name, + Namespace: ns, + }, nil +} + +func resolveEventGatewayControlPlane( + helper cmdpkg.Helper, + egwClient helpers.EGWControlPlaneAPI, + cfg config.Hook, + identifier string, +) (*kkComps.EventGatewayInfo, error) { + ctx := ensureContext(helper.GetContext()) + + if util.IsValidUUID(identifier) { + res, err := egwClient.FetchEGWControlPlane(ctx, identifier) + if err != nil { + attrs := cmdpkg.TryConvertErrorToAttrs(err) + return nil, cmdpkg.PrepareExecutionError( + "failed to retrieve Event Gateway Control Plane", + err, + helper.GetCmd(), + attrs..., + ) + } + egw := res.EventGatewayInfo + if egw == nil { + return nil, cmdpkg.PrepareExecutionErrorMsg( + helper, + fmt.Sprintf("event gateway control plane %s not found", identifier), + ) + } + return egw, nil + } + + pageSize := cfg.GetInt(common.RequestPageSizeConfigPath) + if pageSize < 1 { + pageSize = common.DefaultRequestPageSize + } + + var pageAfter *string + for { + req := kkOps.ListEventGatewaysRequest{ + PageSize: kk.Int64(int64(pageSize)), + } + + if pageAfter != nil { + req.PageAfter = pageAfter + } + + res, err := egwClient.ListEGWControlPlanes(ctx, req) + if err != nil { + attrs := cmdpkg.TryConvertErrorToAttrs(err) + return nil, cmdpkg.PrepareExecutionError( + "failed to list Event Gateway Control Planes", + err, + helper.GetCmd(), + attrs..., + ) + } + + list := res.ListEventGatewaysResponse + if list == nil || len(list.Data) == 0 { + break + } + + for _, egw := range list.Data { + if egw.Name == identifier { + egwCopy := egw + return &egwCopy, nil + } + } + + if list.Meta.Page.Next == nil { + break + } + + // Page.Next contains a full URL; parse it and extract the cursor from + // the `page[after]` query parameter so we can pass it to the next request. + u, err := url.Parse(*list.Meta.Page.Next) + if err != nil { + attrs := cmdpkg.TryConvertErrorToAttrs(err) + return nil, cmdpkg.PrepareExecutionError("failed to parse pagination URL", err, helper.GetCmd(), attrs...) + } + + values := u.Query() + after := values.Get("page[after]") + if after == "" { + break + } + // allocate a new string so the pointer remains valid across iterations + tmp := after + pageAfter = &tmp + } + + return nil, &cmdpkg.ConfigurationError{ + Err: fmt.Errorf("event gateway control plane %q not found", identifier), + } +} diff --git a/internal/cmd/root/products/konnect/adopt/event_gateway_control_plane_test.go b/internal/cmd/root/products/konnect/adopt/event_gateway_control_plane_test.go new file mode 100644 index 00000000..20df25e1 --- /dev/null +++ b/internal/cmd/root/products/konnect/adopt/event_gateway_control_plane_test.go @@ -0,0 +1,192 @@ +package adopt + +import ( + "context" + "testing" + + kkComps "github.com/Kong/sdk-konnect-go/models/components" + kkOps "github.com/Kong/sdk-konnect-go/models/operations" + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/config" + "github.com/kong/kongctl/internal/declarative/labels" + helpers "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/stretchr/testify/assert" +) + +type egwControlPlaneAPIStub struct { + t *testing.T + fetchResponse *kkComps.EventGatewayInfo + listResponse []kkComps.EventGatewayInfo + lastUpdate kkComps.UpdateGatewayRequest + updateCalls int +} + +func (e *egwControlPlaneAPIStub) ListEGWControlPlanes( + context.Context, + kkOps.ListEventGatewaysRequest, + ...kkOps.Option, +) (*kkOps.ListEventGatewaysResponse, error) { + resp := &kkOps.ListEventGatewaysResponse{ + ListEventGatewaysResponse: &kkComps.ListEventGatewaysResponse{ + Data: e.listResponse, + Meta: kkComps.CursorMeta{}, + }, + } + return resp, nil +} + +func (e *egwControlPlaneAPIStub) FetchEGWControlPlane( + _ context.Context, + id string, + _ ...kkOps.Option, +) (*kkOps.GetEventGatewayResponse, error) { + if id != e.fetchResponse.ID { + e.t.Fatalf("unexpected Event Gateway Control Plane id: %s", id) + } + return &kkOps.GetEventGatewayResponse{EventGatewayInfo: e.fetchResponse}, nil +} + +func (e *egwControlPlaneAPIStub) CreateEGWControlPlane( + context.Context, + kkComps.CreateGatewayRequest, + ...kkOps.Option, +) (*kkOps.CreateEventGatewayResponse, error) { + e.t.Fatalf("unexpected CreateEGWControlPlane call") + return nil, nil +} + +func (e *egwControlPlaneAPIStub) UpdateEGWControlPlane( + _ context.Context, + id string, + req kkComps.UpdateGatewayRequest, + _ ...kkOps.Option, +) (*kkOps.UpdateEventGatewayResponse, error) { + if id != e.fetchResponse.ID { + e.t.Fatalf("unexpected Event Gateway Control Plane id: %s", id) + } + e.updateCalls++ + e.lastUpdate = req + + labels := make(map[string]string) + if req.Labels != nil { + for k, v := range req.Labels { + labels[k] = v + } + } + + resp := &kkOps.UpdateEventGatewayResponse{ + EventGatewayInfo: &kkComps.EventGatewayInfo{ + ID: e.fetchResponse.ID, + Name: e.fetchResponse.Name, + Labels: labels, + }, + } + return resp, nil +} + +func (e *egwControlPlaneAPIStub) DeleteEGWControlPlane( + context.Context, + string, + ...kkOps.Option, +) (*kkOps.DeleteEventGatewayResponse, error) { + e.t.Fatalf("unexpected DeleteEGWControlPlane call") + return nil, nil +} + +func TestAdoptEventGatewayControlPlaneByName(t *testing.T) { + helper := new(cmd.MockHelper) + helper.EXPECT().GetContext().Return(context.Background()) + + egw := &egwControlPlaneAPIStub{ + t: t, + fetchResponse: &kkComps.EventGatewayInfo{ + ID: "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", + Name: "production-egw", + Labels: map[string]string{"env": "prod"}, + }, + listResponse: []kkComps.EventGatewayInfo{ + { + ID: "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", + Name: "production-egw", + Labels: map[string]string{"env": "prod"}, + }, + }, + } + + cfg := stubConfig{pageSize: 50} + + result, err := adoptEventGatewayControlPlane(helper, egw, cfg, "team-events", "production-egw") + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "event_gateway", result.ResourceType) + assert.Equal(t, "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", result.ID) + assert.Equal(t, "production-egw", result.Name) + assert.Equal(t, "team-events", result.Namespace) + + assert.Equal(t, 1, egw.updateCalls) + assert.Equal(t, "prod", egw.lastUpdate.Labels["env"]) + assert.Equal(t, "team-events", egw.lastUpdate.Labels[labels.NamespaceKey]) + + helper.AssertExpectations(t) +} + +func TestAdoptEventGatewayControlPlaneById(t *testing.T) { + helper := new(cmd.MockHelper) + helper.EXPECT().GetContext().Return(context.Background()) + + egw := &egwControlPlaneAPIStub{ + t: t, + fetchResponse: &kkComps.EventGatewayInfo{ + ID: "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", + Name: "production-egw", + Labels: map[string]string{"env": "prod"}, + }, + } + + cfg := stubConfig{pageSize: 50} + + result, err := adoptEventGatewayControlPlane(helper, egw, cfg, "team-events", "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e") + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "event_gateway", result.ResourceType) + assert.Equal(t, "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", result.ID) + assert.Equal(t, "production-egw", result.Name) + assert.Equal(t, "team-events", result.Namespace) + + assert.Equal(t, 1, egw.updateCalls) + assert.Equal(t, "prod", egw.lastUpdate.Labels["env"]) + assert.Equal(t, "team-events", egw.lastUpdate.Labels[labels.NamespaceKey]) + + helper.AssertExpectations(t) +} + +func TestAdoptEventGatewayControlPlaneRejectsExistingNamespace(t *testing.T) { + helper := new(cmd.MockHelper) + helper.EXPECT().GetContext().Return(context.Background()) + + egw := &egwControlPlaneAPIStub{ + t: t, + listResponse: []kkComps.EventGatewayInfo{ + { + ID: "f3b8c0d1-9a2e-4f12-8d3c-1e4a5b6c7d8e", + Name: "production-egw", + Labels: map[string]string{labels.NamespaceKey: "existing-team"}, + }, + }, + } + + cfg := stubConfig{pageSize: 50} + + _, err := adoptEventGatewayControlPlane(helper, egw, cfg, "team-events", "production-egw") + assert.Error(t, err) + var cfgErr *cmd.ConfigurationError + assert.ErrorAs(t, err, &cfgErr) + assert.Equal(t, 0, egw.updateCalls) + + helper.AssertExpectations(t) +} + +var ( + _ helpers.EGWControlPlaneAPI = (*egwControlPlaneAPIStub)(nil) + _ config.Hook = stubConfig{} +) diff --git a/internal/cmd/root/products/konnect/declarative/declarative.go b/internal/cmd/root/products/konnect/declarative/declarative.go index 2a85ac56..d282b77e 100644 --- a/internal/cmd/root/products/konnect/declarative/declarative.go +++ b/internal/cmd/root/products/konnect/declarative/declarative.go @@ -262,7 +262,8 @@ func runPlan(command *cobra.Command, args []string) error { // Check if configuration is empty totalResources := len(resourceSet.Portals) + len(resourceSet.ApplicationAuthStrategies) + - len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + len(resourceSet.CatalogServices) + len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + len(resourceSet.CatalogServices) + + len(resourceSet.EventGatewayControlPlanes) if err := nsValidator.ValidateNamespaceRequirement(resourceSet, requirement); err != nil { return err @@ -338,6 +339,13 @@ func runPlan(command *cobra.Command, args []string) error { } namespaces[ns] = true } + for _, egwControlPlane := range resourceSet.EventGatewayControlPlanes { + ns := "default" + if egwControlPlane.Kongctl != nil && egwControlPlane.Kongctl.Namespace != nil { + ns = *egwControlPlane.Kongctl.Namespace + } + namespaces[ns] = true + } if len(namespaces) > 1 { fmt.Fprintf(command.OutOrStderr(), "Processing %d namespaces...\n", len(namespaces)) @@ -441,7 +449,8 @@ func runDiff(command *cobra.Command, args []string) error { } totalResources := len(resourceSet.Portals) + len(resourceSet.ApplicationAuthStrategies) + - len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + len(resourceSet.CatalogServices) + len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + len(resourceSet.CatalogServices) + + len(resourceSet.EventGatewayControlPlanes) if totalResources == 0 { if len(filenames) == 0 { return fmt.Errorf("no configuration files found. Use -f to specify files or --plan to use existing plan") @@ -907,7 +916,8 @@ func runApply(command *cobra.Command, args []string) error { // Check if configuration is empty totalResources := len(resourceSet.Portals) + len(resourceSet.ApplicationAuthStrategies) + - len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + len(resourceSet.CatalogServices) + len(resourceSet.ControlPlanes) + len(resourceSet.APIs) + + len(resourceSet.EventGatewayControlPlanes) + len(resourceSet.CatalogServices) if totalResources == 0 { // Check if we're using default directory (no explicit sources) @@ -1482,5 +1492,8 @@ func createStateClient(kkClient helpers.SDKAPI) *state.Client { APIPublicationAPI: kkClient.GetAPIPublicationAPI(), APIImplementationAPI: kkClient.GetAPIImplementationAPI(), APIDocumentAPI: kkClient.GetAPIDocumentAPI(), + + // Event Gateway APIs + EGWControlPlaneAPI: kkClient.GetEventGatewayControlPlaneAPI(), }) } diff --git a/internal/cmd/root/products/konnect/eventgateway/event-gateway.go b/internal/cmd/root/products/konnect/eventgateway/event-gateway.go new file mode 100644 index 00000000..64a83aed --- /dev/null +++ b/internal/cmd/root/products/konnect/eventgateway/event-gateway.go @@ -0,0 +1,39 @@ +package eventgateway + +import ( + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/spf13/cobra" +) + +var ( + eventGatewayUse = "event-gateway" + eventGatewayShort = i18n.T("root.konnect.event-gateway.gatewayShort", "Manage Konnect Event Gateway resources") + eventGatewayLong = normalizers.LongDesc(i18n.T("root.konnect.event-gateway.gatewayLong", + `The event-gateway command allows you to manage Konnect Event Gateway resources.`)) +) + +func NewEventGatewayCmd(verb verbs.VerbValue, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) (*cobra.Command, error) { + baseCmd := cobra.Command{ + Use: eventGatewayUse, + Short: eventGatewayShort, + Long: eventGatewayLong, + Aliases: []string{"egw", "EGW", "event-gateways"}, + } + + switch verb { + case verbs.Get: + return newGetEventGatewayControlPlaneCmd(verb, &baseCmd, addParentFlags, parentPreRun).Command, nil + case verbs.List: + return newGetEventGatewayControlPlaneCmd(verb, &baseCmd, addParentFlags, parentPreRun).Command, nil + case verbs.Create, verbs.Add, verbs.Apply, verbs.Dump, verbs.Update, verbs.Delete, verbs.Help, verbs.Login, + verbs.Plan, verbs.Sync, verbs.Diff, verbs.Export, verbs.Adopt, verbs.API, verbs.Kai, verbs.View, verbs.Logout: + return &baseCmd, nil + default: + return &baseCmd, nil + } +} diff --git a/internal/cmd/root/products/konnect/eventgateway/getEventGateway.go b/internal/cmd/root/products/konnect/eventgateway/getEventGateway.go new file mode 100644 index 00000000..49662c69 --- /dev/null +++ b/internal/cmd/root/products/konnect/eventgateway/getEventGateway.go @@ -0,0 +1,349 @@ +package eventgateway + +import ( + "fmt" + "net/url" + "strings" + "time" + + kk "github.com/Kong/sdk-konnect-go" // kk = Kong Konnect + kkComps "github.com/Kong/sdk-konnect-go/models/components" + kkOps "github.com/Kong/sdk-konnect-go/models/operations" + "github.com/kong/kongctl/internal/cmd" + cmdCommon "github.com/kong/kongctl/internal/cmd/common" + "github.com/kong/kongctl/internal/cmd/output/tableview" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/config" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/kong/kongctl/internal/meta" + "github.com/kong/kongctl/internal/util" + "github.com/kong/kongctl/internal/util/i18n" + "github.com/kong/kongctl/internal/util/normalizers" + "github.com/segmentio/cli" + "github.com/spf13/cobra" +) + +var ( + getEventGatewayControlPlanesShort = i18n.T( + "root.products.konnect.event-gateway.control-plane.getEventGatewayControlPlanesShort", + "List or get Konnect Event Gateways") + getEventGatewayControlPlanesLong = i18n.T( + "root.products.konnect.event-gateway.control-plane.getEventGatewayControlPlanesLong", + `Use the get verb with the event-gateway command to query Konnect Event Gateways.`) + getEventGatewayControlPlanesExample = normalizers.Examples( + i18n.T("root.products.konnect.event-gateway.control-plane.getEventGatewayControlPlanesExamples", + fmt.Sprintf(`# List all the Event Gateways for the organization +%[1]s get event-gateway +# Get details for an Event Gateway with a specific ID +%[1]s get event-gateway 22cd8a0b-72e7-4212-9099-0764f8e9c5ac +# Get details for an Event Gateway with a specific name +%[1]s get event-gateway my-eventgateway +# Get all the Event Gateways using command aliases +%[1]s get egw +`, meta.CLIName))) +) + +type textDisplayRecord struct { + ID string + Name string + Description string + LocalCreatedTime string + LocalUpdatedTime string +} + +func eventGatewayControlPlaneToDisplayRecord(e *kkComps.EventGatewayInfo) textDisplayRecord { + missing := "n/a" + + var id, name string + if e.ID != "" { + id = util.AbbreviateUUID(e.ID) + } else { + id = missing + } + + if e.Name != "" { + name = e.Name + } else { + name = missing + } + + description := missing + if e.Description != nil && *e.Description != "" { + description = *e.Description + } + + createdAt := e.CreatedAt.In(time.Local).Format("2006-01-02 15:04:05") + updatedAt := e.UpdatedAt.In(time.Local).Format("2006-01-02 15:04:05") + return textDisplayRecord{ + ID: id, + Name: name, + Description: description, + LocalCreatedTime: createdAt, + LocalUpdatedTime: updatedAt, + } +} + +type getEventGatewayControlPlaneCmd struct { + *cobra.Command +} + +// runListByName retrieves an Event Gateway Control Plane by its name +// TODO: Since the API does not support filtering by name, we fetch all and filter locally +func runListByName(name string, kkClient helpers.EGWControlPlaneAPI, helper cmd.Helper, + cfg config.Hook, +) (*kkComps.EventGatewayInfo, error) { + allEventGateways, err := runList(kkClient, helper, cfg) + if err != nil { + return nil, err + } + + for _, eventGateway := range allEventGateways { + if eventGateway.Name == name { + return &eventGateway, nil + } + } + + return nil, cmd.PrepareExecutionErrorMsg(helper, + fmt.Sprintf("Event Gateway Control Plane with name %s not found", name)) +} + +func runList(kkClient helpers.EGWControlPlaneAPI, helper cmd.Helper, + cfg config.Hook, +) ([]kkComps.EventGatewayInfo, error) { + requestPageSize := int64(cfg.GetInt(common.RequestPageSizeConfigPath)) + + var allData []kkComps.EventGatewayInfo + var pageAfter *string + + for { + req := kkOps.ListEventGatewaysRequest{ + PageSize: kk.Int64(requestPageSize), + } + + if pageAfter != nil { + req.PageAfter = pageAfter + } + + res, err := kkClient.ListEGWControlPlanes(helper.GetContext(), req) + if err != nil { + attrs := cmd.TryConvertErrorToAttrs(err) + return nil, cmd.PrepareExecutionError("Failed to list Event Gateways", err, helper.GetCmd(), attrs...) + } + + allData = append(allData, res.ListEventGatewaysResponse.Data...) + + if res.ListEventGatewaysResponse.Meta.Page.Next == nil { + break + } + + u, err := url.Parse(*res.ListEventGatewaysResponse.Meta.Page.Next) + if err != nil { + return nil, cmd.PrepareExecutionError("Failed to list Event Gateways: invalid cursor", err, helper.GetCmd()) + } + + values := u.Query() + pageAfter = kk.String(values.Get("page[after]")) + } + + return allData, nil +} + +func runGet(id string, kkClient helpers.EGWControlPlaneAPI, helper cmd.Helper, +) (*kkComps.EventGatewayInfo, error) { + res, err := kkClient.FetchEGWControlPlane(helper.GetContext(), id) + if err != nil { + attrs := cmd.TryConvertErrorToAttrs(err) + return nil, cmd.PrepareExecutionError( + "Failed to get Event Gateway Control Plane", + err, + helper.GetCmd(), + attrs...) + } + + return res.GetEventGatewayInfo(), nil +} + +func (c *getEventGatewayControlPlaneCmd) validate(helper cmd.Helper) error { + if len(helper.GetArgs()) > 1 { + return &cmd.ConfigurationError{ + Err: fmt.Errorf("too many arguments. Listing Event Gateways requires 0 or 1 arguments (name or ID)"), + } + } + + config, err := helper.GetConfig() + if err != nil { + return err + } + + pageSize := config.GetInt(common.RequestPageSizeConfigPath) + if pageSize < 1 { + return &cmd.ConfigurationError{ + Err: fmt.Errorf("%s must be greater than 0", common.RequestPageSizeFlagName), + } + } + return nil +} + +func (c *getEventGatewayControlPlaneCmd) runE(cobraCmd *cobra.Command, args []string) error { + var e error + helper := cmd.BuildHelper(cobraCmd, args) + if e = c.validate(helper); e != nil { + return e + } + + logger, e := helper.GetLogger() + if e != nil { + return e + } + + outType, e := helper.GetOutputFormat() + if e != nil { + return e + } + + var printer cli.PrintFlusher + + printer, e = cli.Format(outType.String(), helper.GetStreams().Out) + if e != nil { + return e + } + defer printer.Flush() + + cfg, e := helper.GetConfig() + if e != nil { + return e + } + + sdk, e := helper.GetKonnectSDK(cfg, logger) + if e != nil { + return e + } + + // 'get event-gateway' can be run in various ways: + // > get event-gateway # Get by UUID + // > get event-gateway # Get by name + // > get event-gateway # List all + if len(helper.GetArgs()) == 1 { // validate above checks that args is 0 or 1 + id := strings.TrimSpace(helper.GetArgs()[0]) + + isUUID := util.IsValidUUID(id) + + if !isUUID { + // If the ID is not a UUID, then it is a name + eventGatewayControlPlane, e := runListByName(id, sdk.GetEventGatewayControlPlaneAPI(), helper, cfg) + if e != nil { + return e + } + return tableview.RenderForFormat( + false, + outType, + printer, + helper.GetStreams(), + eventGatewayControlPlaneToDisplayRecord(eventGatewayControlPlane), + eventGatewayControlPlane, + "", + tableview.WithRootLabel(helper.GetCmd().Name()), + tableview.WithDetailHelper(helper), + tableview.WithDetailContext("eventGatewayControlPlane", func(index int) any { + if index != 0 { + return nil + } + return eventGatewayControlPlane + }), + ) + } + + eventGatewayControlPlane, e := runGet(id, sdk.GetEventGatewayControlPlaneAPI(), helper) + if e != nil { + return e + } + + return tableview.RenderForFormat( + false, + outType, + printer, + helper.GetStreams(), + eventGatewayControlPlaneToDisplayRecord(eventGatewayControlPlane), + eventGatewayControlPlane, + "", + tableview.WithRootLabel(helper.GetCmd().Name()), + tableview.WithDetailHelper(helper), + tableview.WithDetailContext("eventGatewayControlPlane", func(index int) any { + if index != 0 { + return nil + } + return eventGatewayControlPlane + }), + ) + } + + eventGatewayControlPlanes, e := runList(sdk.GetEventGatewayControlPlaneAPI(), helper, cfg) + if e != nil { + return e + } + + return renderEventGatewayControlPlaneList( + helper, + helper.GetCmd().Name(), + false, + outType, + printer, + eventGatewayControlPlanes, + ) +} + +func renderEventGatewayControlPlaneList( + helper cmd.Helper, + rootLabel string, + interactive bool, + outType cmdCommon.OutputFormat, + printer cli.PrintFlusher, + eventGatewayControlPlanes []kkComps.EventGatewayInfo, +) error { + displayRecords := make([]textDisplayRecord, 0, len(eventGatewayControlPlanes)) + for i := range eventGatewayControlPlanes { + displayRecords = append(displayRecords, eventGatewayControlPlaneToDisplayRecord(&eventGatewayControlPlanes[i])) + } + + options := []tableview.Option{ + tableview.WithRootLabel(rootLabel), + tableview.WithDetailHelper(helper), + } + + return tableview.RenderForFormat( + interactive, + outType, + printer, + helper.GetStreams(), + displayRecords, + eventGatewayControlPlanes, + "", + options..., + ) +} + +func newGetEventGatewayControlPlaneCmd(verb verbs.VerbValue, + baseCmd *cobra.Command, + addParentFlags func(verbs.VerbValue, *cobra.Command), + parentPreRun func(*cobra.Command, []string) error, +) *getEventGatewayControlPlaneCmd { + rv := getEventGatewayControlPlaneCmd{ + Command: baseCmd, + } + + rv.Short = getEventGatewayControlPlanesShort + rv.Long = getEventGatewayControlPlanesLong + rv.Example = getEventGatewayControlPlanesExample + if parentPreRun != nil { + rv.PreRunE = parentPreRun + } + rv.RunE = rv.runE + + // Ensure parent-level flags are available on this command + if addParentFlags != nil { + addParentFlags(verb, rv.Command) + } + + return &rv +} diff --git a/internal/cmd/root/products/konnect/konnect.go b/internal/cmd/root/products/konnect/konnect.go index ce23fc1e..25916142 100644 --- a/internal/cmd/root/products/konnect/konnect.go +++ b/internal/cmd/root/products/konnect/konnect.go @@ -11,6 +11,7 @@ import ( "github.com/kong/kongctl/internal/cmd/root/products/konnect/authstrategy" "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" "github.com/kong/kongctl/internal/cmd/root/products/konnect/declarative" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/eventgateway" "github.com/kong/kongctl/internal/cmd/root/products/konnect/gateway" "github.com/kong/kongctl/internal/cmd/root/products/konnect/me" "github.com/kong/kongctl/internal/cmd/root/products/konnect/organization" @@ -186,6 +187,12 @@ func NewKonnectCmd(verb verbs.VerbValue) (*cobra.Command, error) { } cmd.AddCommand(authStrategyCmd) + eventGatewayCmd, err := adopt.NewEventGatewayControlPlaneCmd(verb, &cobra.Command{}, addFlags, preRunE) + if err != nil { + return nil, err + } + cmd.AddCommand(eventGatewayCmd) + addFlags(verb, cmd) return cmd, nil case verbs.Add, verbs.Get, verbs.Create, verbs.Dump, verbs.Update, @@ -242,6 +249,13 @@ func NewKonnectCmd(verb verbs.VerbValue) (*cobra.Command, error) { } cmd.AddCommand(rc) + // Add EventGateway command + egcpc, e := eventgateway.NewEventGatewayCmd(verb, addFlags, preRunE) + if e != nil { + return nil, e + } + cmd.AddCommand(egcpc) + if verb == verbs.Get { cmd.RunE = func(c *cobra.Command, args []string) error { helper := cmdpkg.BuildHelper(c, args) diff --git a/internal/cmd/root/verbs/adopt/adopt.go b/internal/cmd/root/verbs/adopt/adopt.go index a24c618b..bfe0f37e 100644 --- a/internal/cmd/root/verbs/adopt/adopt.go +++ b/internal/cmd/root/verbs/adopt/adopt.go @@ -32,6 +32,8 @@ var ( %[1]s adopt control-plane 22cd8a0b-72e7-4212-9099-0764f8e9c5ac --namespace platform # Adopt an API explicitly via the konnect product %[1]s adopt konnect api my-api --namespace team-alpha + # Adopt an Event Gateway explicitly via the konnect product + %[1]s adopt konnect event-gateway my-egw --namespace team-alpha `, meta.CLIName))) ) @@ -95,5 +97,11 @@ Setting this value overrides tokens obtained from the login command. } cmd.AddCommand(authStrategyCmd) + eventGatewayCmd, err := NewDirectEventGatewayCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(eventGatewayCmd) + return cmd, nil } diff --git a/internal/cmd/root/verbs/adopt/direct.go b/internal/cmd/root/verbs/adopt/direct.go index af0280d6..a224f3bf 100644 --- a/internal/cmd/root/verbs/adopt/direct.go +++ b/internal/cmd/root/verbs/adopt/direct.go @@ -139,6 +139,47 @@ Setting this value overrides tokens obtained from the login command. return cmd, nil } +func NewDirectEventGatewayCmd() (*cobra.Command, error) { + addFlags := func(_ verbs.VerbValue, cmd *cobra.Command) { + cmd.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmd.Flags().String(common.RegionFlagName, "", + fmt.Sprintf(`Konnect region identifier (for example "eu"). Used to construct the base URL when --%s is not provided. +- Config path: [ %s ]`, + common.BaseURLFlagName, common.RegionConfigPath)) + + cmd.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token (PAT) used to authenticate the CLI. +Setting this value overrides tokens obtained from the login command. +- Config path: [ %s ]`, + common.PATConfigPath)) + } + + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, common.GetSDKFactory()) + c.SetContext(ctx) + return bindKonnectFlags(c, args) + } + + cmd, err := konnectadopt.NewEventGatewayControlPlaneCmd(Verb, &cobra.Command{}, addFlags, preRunE) + if err != nil { + return nil, err + } + + cmd.Example = ` # Adopt an Event Gateway by name + kongctl adopt event-gateway my-egw --namespace team-alpha` + + return cmd, nil +} func NewDirectAuthStrategyCmd() (*cobra.Command, error) { addFlags := func(_ verbs.VerbValue, cmd *cobra.Command) { diff --git a/internal/cmd/root/verbs/dump/declarative.go b/internal/cmd/root/verbs/dump/declarative.go index 92b5d348..d48b5ef1 100644 --- a/internal/cmd/root/verbs/dump/declarative.go +++ b/internal/cmd/root/verbs/dump/declarative.go @@ -3,6 +3,7 @@ package dump import ( "context" "fmt" + "net/url" "reflect" "sort" "strings" @@ -35,6 +36,7 @@ var declarativeAllowedResources = map[string]struct{}{ "apis": {}, "application_auth_strategies": {}, "control_planes": {}, + "event_gateways": {}, } func newDeclarativeCmd() *cobra.Command { @@ -194,6 +196,16 @@ func runDeclarativeDump(helper cmdpkg.Helper, opts declarativeOptions) error { return err } resourceSet.ControlPlanes = append(resourceSet.ControlPlanes, controlPlanes...) + case "event_gateways": + eventGateways, err := collectDeclarativeEventGateways( + ctx, + sdk.GetEventGatewayControlPlaneAPI(), + requestPageSize, + ) + if err != nil { + return err + } + resourceSet.EventGatewayControlPlanes = append(resourceSet.EventGatewayControlPlanes, eventGateways...) } } @@ -329,6 +341,56 @@ func collectDeclarativeAPIs( return results, nil } +func collectDeclarativeEventGateways( + ctx context.Context, + eventGatewayClient helpers.EGWControlPlaneAPI, + requestPageSize int64, +) ([]declresources.EventGatewayControlPlaneResource, error) { + if eventGatewayClient == nil { + return nil, fmt.Errorf("event gateway client is not configured") + } + + var allData []declresources.EventGatewayControlPlaneResource + var pageAfter *string + + for { + req := kkOps.ListEventGatewaysRequest{ + PageSize: Int64(requestPageSize), + } + + if pageAfter != nil { + req.PageAfter = pageAfter + } + + res, err := eventGatewayClient.ListEGWControlPlanes(ctx, req) + if err != nil { + return nil, err + } + + for _, egw := range res.ListEventGatewaysResponse.Data { + allData = append(allData, mapEventGatewayToDeclarativeResource(egw)) + } + + if res.ListEventGatewaysResponse.Meta.Page.Next == nil { + break + } + + u, err := url.Parse(*res.ListEventGatewaysResponse.Meta.Page.Next) + if err != nil { + return nil, err + } + + values := u.Query() + pageAfter = stringPointer(values.Get("page[after]")) + } + + sort.Slice(allData, func(i, j int) bool { + return allData[i].Name < allData[j].Name + }) + + return allData, nil +} + func mapPortalToDeclarativeResource(portal kkComps.ListPortalsResponsePortal) declresources.PortalResource { result := declresources.PortalResource{ CreatePortal: kkComps.CreatePortal{ @@ -399,6 +461,22 @@ func mapAPIToDeclarativeResource(api kkComps.APIResponseSchema) declresources.AP return result } +func mapEventGatewayToDeclarativeResource(egw kkComps.EventGatewayInfo) declresources.EventGatewayControlPlaneResource { + result := declresources.EventGatewayControlPlaneResource{ + CreateGatewayRequest: kkComps.CreateGatewayRequest{ + Name: egw.Name, + Description: egw.Description, + }, + Ref: egw.ID, + } + + if labels := decllabels.GetUserLabels(egw.Labels); len(labels) > 0 { + result.Labels = labels + } + + return result +} + func normalizeAPIResource(api *declresources.APIResource) { if api.Attributes == nil { return diff --git a/internal/cmd/root/verbs/get/eventgateway.go b/internal/cmd/root/verbs/get/eventgateway.go new file mode 100644 index 00000000..3ccca364 --- /dev/null +++ b/internal/cmd/root/verbs/get/eventgateway.go @@ -0,0 +1,119 @@ +package get + +import ( + "context" + "fmt" + + "github.com/kong/kongctl/internal/cmd" + "github.com/kong/kongctl/internal/cmd/root/products" + "github.com/kong/kongctl/internal/cmd/root/products/konnect" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/common" + "github.com/kong/kongctl/internal/cmd/root/products/konnect/eventgateway" + "github.com/kong/kongctl/internal/cmd/root/verbs" + "github.com/kong/kongctl/internal/konnect/helpers" + "github.com/spf13/cobra" +) + +// NewDirectEventGatewayCmd creates an Event Gateway Control Plane command that works at the root level (Konnect-first) +func NewDirectEventGatewayCmd() (*cobra.Command, error) { + // Define the addFlags function to add Konnect-specific flags + addFlags := func(verb verbs.VerbValue, cmd *cobra.Command) { + cmd.Flags().String(common.BaseURLFlagName, "", + fmt.Sprintf(`Base URL for Konnect API requests. +- Config path: [ %s ] +- Default : [ %s ]`, + common.BaseURLConfigPath, common.BaseURLDefault)) + + cmd.Flags().String(common.RegionFlagName, "", + fmt.Sprintf(`Konnect region identifier (for example "eu"). Used to construct the base URL when --%s is not provided. +- Config path: [ %s ]`, + common.BaseURLFlagName, common.RegionConfigPath)) + + cmd.Flags().String(common.PATFlagName, "", + fmt.Sprintf(`Konnect Personal Access Token (PAT) used to authenticate the CLI. +Setting this value overrides tokens obtained from the login command. +- Config path: [ %s ]`, + common.PATConfigPath)) + + if verb == verbs.Get || verb == verbs.List { + cmd.Flags().Int( + common.RequestPageSizeFlagName, + common.DefaultRequestPageSize, + fmt.Sprintf(`Max number of results to include per response page for get and list operations. +- Config path: [ %s ]`, + common.RequestPageSizeConfigPath)) + } + } + + // Define the preRunE function to set up Konnect context + preRunE := func(c *cobra.Command, args []string) error { + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + ctx = context.WithValue(ctx, products.Product, konnect.Product) + ctx = context.WithValue(ctx, helpers.SDKAPIFactoryKey, helpers.SDKAPIFactory(common.KonnectSDKFactory)) + c.SetContext(ctx) + + // Bind flags + return bindEventGatewayFlags(c, args) + } + + // Create the Event Gateway Control Plane command using the existing api package + eventGatewayCmd, err := eventgateway.NewEventGatewayCmd(verbs.Get, addFlags, preRunE) + if err != nil { + return nil, err + } + + // Override the example to show direct usage without "konnect" + eventGatewayCmd.Example = ` # List all the Event Gateways for the organization + kongctl get event-gateways + # Get a specific Event Gateway + kongctl get event-gateways + ` + + return eventGatewayCmd, nil +} + +// bindEventGatewayFlags binds Konnect-specific flags to configuration +func bindEventGatewayFlags(c *cobra.Command, args []string) error { + helper := cmd.BuildHelper(c, args) + cfg, err := helper.GetConfig() + if err != nil { + return err + } + + f := c.Flags().Lookup(common.BaseURLFlagName) + if f != nil { + err = cfg.BindFlag(common.BaseURLConfigPath, f) + if err != nil { + return err + } + } + + f = c.Flags().Lookup(common.RegionFlagName) + if f != nil { + err = cfg.BindFlag(common.RegionConfigPath, f) + if err != nil { + return err + } + } + + f = c.Flags().Lookup(common.PATFlagName) + if f != nil { + err = cfg.BindFlag(common.PATConfigPath, f) + if err != nil { + return err + } + } + + f = c.Flags().Lookup(common.RequestPageSizeFlagName) + if f != nil { + err = cfg.BindFlag(common.RequestPageSizeConfigPath, f) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/cmd/root/verbs/get/get.go b/internal/cmd/root/verbs/get/get.go index 8dc63aef..4356396e 100644 --- a/internal/cmd/root/verbs/get/get.go +++ b/internal/cmd/root/verbs/get/get.go @@ -43,6 +43,8 @@ Output can be formatted in multiple ways to aid in further processing.`)) %[1]s get gateway control-planes # Retrieve Konnect control planes (explicit) %[1]s get konnect gateway control-planes + # Retrieve Konnect Event Gateways + %[1]s get event-gateways `, meta.CLIName))) ) @@ -154,6 +156,12 @@ Setting this value overrides tokens obtained from the login command. } cmd.AddCommand(regionsCmd) + eventGatewayControlPlaneCmd, err := NewDirectEventGatewayCmd() + if err != nil { + return nil, err + } + cmd.AddCommand(eventGatewayControlPlaneCmd) + return cmd, nil } diff --git a/internal/declarative/executor/event_gateway_control_plane_adapter.go b/internal/declarative/executor/event_gateway_control_plane_adapter.go new file mode 100644 index 00000000..202fd7e7 --- /dev/null +++ b/internal/declarative/executor/event_gateway_control_plane_adapter.go @@ -0,0 +1,175 @@ +package executor + +import ( + "context" + + "github.com/Kong/sdk-konnect-go/models/components" + "github.com/kong/kongctl/internal/declarative/common" + "github.com/kong/kongctl/internal/declarative/labels" + "github.com/kong/kongctl/internal/declarative/planner" + "github.com/kong/kongctl/internal/declarative/resources" + "github.com/kong/kongctl/internal/declarative/state" +) + +type EventGatewayControlPlaneControlPlaneAdapter struct { + client *state.Client +} + +func NewEventGatewayControlPlaneControlPlaneAdapter(client *state.Client) *EventGatewayControlPlaneControlPlaneAdapter { + return &EventGatewayControlPlaneControlPlaneAdapter{ + client: client, + } +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) MapCreateFields(_ context.Context, + execCtx *ExecutionContext, + fields map[string]any, create *components.CreateGatewayRequest, +) error { + namespace := execCtx.Namespace + protection := execCtx.Protection + + // Map required fields + create.Name = common.ExtractResourceName(fields) + + // Map optional fields + common.MapOptionalStringFieldToPtr(&create.Description, fields, "description") + + // Handle labels + userLabels := labels.ExtractLabelsFromField(fields["labels"]) + labelsMap := labels.BuildCreateLabels(userLabels, namespace, protection) + + // Convert to SDK format + if len(labelsMap) > 0 { + create.Labels = labelsMap + } + + return nil +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) MapUpdateFields( + _ context.Context, execCtx *ExecutionContext, + fields map[string]any, update *components.UpdateGatewayRequest, + currentLabels map[string]string, +) error { + namespace := execCtx.Namespace + protection := execCtx.Protection + + // Only include changed fields + for field, value := range fields { + switch field { + case "name": + if name, ok := value.(string); ok { + update.Name = &name + } + case "description": + if desc, ok := value.(string); ok { + update.Description = &desc + } + } + } + + // Handle labels + desiredLabels := labels.ExtractLabelsFromField(fields["labels"]) + if desiredLabels != nil { + plannerCurrentLabels := labels.ExtractLabelsFromField(fields[planner.FieldCurrentLabels]) + if plannerCurrentLabels != nil { + currentLabels = plannerCurrentLabels + } + + labelsMap := labels.BuildUpdateLabels(desiredLabels, currentLabels, namespace, protection) + + // Convert to SDK format + update.Labels = labels.ConvertPointerMapsToStringMap(labelsMap) + // OR: update.Labels = labelsMap + } else if currentLabels != nil { + // No label changes, preserve with updated protection + labelsMap := labels.BuildUpdateLabels(currentLabels, currentLabels, namespace, protection) + update.Labels = labels.ConvertPointerMapsToStringMap(labelsMap) + } + + return nil +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) Create( + ctx context.Context, req components.CreateGatewayRequest, + namespace string, _ *ExecutionContext, +) (string, error) { + resp, err := a.client.CreateEventGatewayControlPlane(ctx, req, namespace) + if err != nil { + return "", err + } + return resp, nil +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) Update( + ctx context.Context, id string, req components.UpdateGatewayRequest, + namespace string, _ *ExecutionContext, +) (string, error) { + resp, err := a.client.UpdateEventGatewayControlPlane(ctx, id, req, namespace) + if err != nil { + return "", err + } + return resp, nil +} + +// GetByID gets a event_gateway by ID +func (a *EventGatewayControlPlaneControlPlaneAdapter) GetByID( + ctx context.Context, id string, _ *ExecutionContext, +) (ResourceInfo, error) { + eventGateway, err := a.client.GetEventGatewayControlPlaneByID(ctx, id) + if err != nil { + return nil, err + } + if eventGateway == nil { + return nil, nil + } + return &EventGatewayControlPlaneResourceInfo{eventGatewayControlPlane: eventGateway}, nil +} + +// GetByName gets a event_gateway by name +func (a *EventGatewayControlPlaneControlPlaneAdapter) GetByName( + _ context.Context, _ string, +) (ResourceInfo, error) { + // TODO - find why this is required and fix + return nil, nil +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) Delete( + ctx context.Context, id string, _ *ExecutionContext, +) error { + return a.client.DeleteEventGatewayControlPlane(ctx, id) +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) ResourceType() string { + return string(resources.ResourceTypeEventGatewayControlPlane) +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) RequiredFields() []string { + return []string{"name"} +} + +func (a *EventGatewayControlPlaneControlPlaneAdapter) SupportsUpdate() bool { + return true +} + +// EventGatewayControlPlaneResourceInfo wraps an Event Gateway Control Plane to implement ResourceInfo +type EventGatewayControlPlaneResourceInfo struct { + eventGatewayControlPlane *state.EventGatewayControlPlane +} + +func (e *EventGatewayControlPlaneResourceInfo) GetID() string { + return e.eventGatewayControlPlane.ID +} + +func (e *EventGatewayControlPlaneResourceInfo) GetName() string { + return e.eventGatewayControlPlane.Name +} + +func (e *EventGatewayControlPlaneResourceInfo) GetLabels() map[string]string { + // EventGatewayControlPlane.Labels is already map[string]string + return e.eventGatewayControlPlane.Labels +} + +func (e *EventGatewayControlPlaneResourceInfo) GetNormalizedLabels() map[string]string { + return e.eventGatewayControlPlane.NormalizedLabels +} diff --git a/internal/declarative/executor/executor.go b/internal/declarative/executor/executor.go index f32bb36e..316d54a0 100644 --- a/internal/declarative/executor/executor.go +++ b/internal/declarative/executor/executor.go @@ -31,11 +31,15 @@ type Executor struct { stateCache *state.Cache // Resource executors - portalExecutor *BaseExecutor[kkComps.CreatePortal, kkComps.UpdatePortal] - controlPlaneExecutor *BaseExecutor[kkComps.CreateControlPlaneRequest, kkComps.UpdateControlPlaneRequest] - apiExecutor *BaseExecutor[kkComps.CreateAPIRequest, kkComps.UpdateAPIRequest] - authStrategyExecutor *BaseExecutor[kkComps.CreateAppAuthStrategyRequest, kkComps.UpdateAppAuthStrategyRequest] - catalogServiceExecutor *BaseExecutor[kkComps.CreateCatalogService, kkComps.UpdateCatalogService] + portalExecutor *BaseExecutor[kkComps.CreatePortal, kkComps.UpdatePortal] + controlPlaneExecutor *BaseExecutor[kkComps.CreateControlPlaneRequest, kkComps.UpdateControlPlaneRequest] + apiExecutor *BaseExecutor[kkComps.CreateAPIRequest, kkComps.UpdateAPIRequest] + authStrategyExecutor *BaseExecutor[ + kkComps.CreateAppAuthStrategyRequest, + kkComps.UpdateAppAuthStrategyRequest, + ] + catalogServiceExecutor *BaseExecutor[kkComps.CreateCatalogService, kkComps.UpdateCatalogService] + eventGatewayControlPlaneExecutor *BaseExecutor[kkComps.CreateGatewayRequest, kkComps.UpdateGatewayRequest] // Portal child resource executors portalCustomizationExecutor *BaseSingletonExecutor[kkComps.PortalCustomization] @@ -97,6 +101,11 @@ func New(client *state.Client, reporter ProgressReporter, dryRun bool) *Executor client, dryRun, ) + e.eventGatewayControlPlaneExecutor = NewBaseExecutor[kkComps.CreateGatewayRequest, kkComps.UpdateGatewayRequest]( + NewEventGatewayControlPlaneControlPlaneAdapter(client), + client, + dryRun, + ) // Initialize portal child resource executors e.portalCustomizationExecutor = NewBaseSingletonExecutor[kkComps.PortalCustomization]( @@ -1414,6 +1423,8 @@ func (e *Executor) createResource(ctx context.Context, change *planner.PlannedCh change.References["portal_id"] = portalRef } return e.portalEmailTemplateExecutor.Create(ctx, *change) + case "event_gateway": + return e.eventGatewayControlPlaneExecutor.Create(ctx, *change) default: return "", fmt.Errorf("create operation not yet implemented for %s", change.ResourceType) } @@ -1625,6 +1636,8 @@ func (e *Executor) updateResource(ctx context.Context, change *planner.PlannedCh } return e.apiVersionExecutor.Update(ctx, *change) // Note: api_publication and api_implementation don't support update + case "event_gateway": + return e.eventGatewayControlPlaneExecutor.Update(ctx, *change) default: return "", fmt.Errorf("update operation not yet implemented for %s", change.ResourceType) } @@ -1753,6 +1766,8 @@ func (e *Executor) deleteResource(ctx context.Context, change *planner.PlannedCh } return e.portalEmailTemplateExecutor.Delete(ctx, *change) // Note: portal_customization is a singleton resource and cannot be deleted + case "event_gateway": + return e.eventGatewayControlPlaneExecutor.Delete(ctx, *change) default: return fmt.Errorf("delete operation not yet implemented for %s", change.ResourceType) } diff --git a/internal/declarative/labels/labels.go b/internal/declarative/labels/labels.go index 8cc03424..e6954cb5 100644 --- a/internal/declarative/labels/labels.go +++ b/internal/declarative/labels/labels.go @@ -381,3 +381,20 @@ func ConvertStringMapToPointerMap(labels map[string]string) map[string]*string { } return result } + +// ConvertPointerMapsToStringMap converts map[string]*string to map[string]string +func ConvertPointerMapsToStringMap(labels map[string]*string) map[string]string { + if len(labels) == 0 { + return nil + } + + result := make(map[string]string) + for k, v := range labels { + val := v + + if val != nil { + result[k] = *val + } + } + return result +} diff --git a/internal/declarative/loader/loader.go b/internal/declarative/loader/loader.go index c1acdf24..9a369121 100644 --- a/internal/declarative/loader/loader.go +++ b/internal/declarative/loader/loader.go @@ -498,6 +498,15 @@ func (l *Loader) appendResourcesWithDuplicateCheck( accumulated.PortalTeamRoles = append(accumulated.PortalTeamRoles, role) } + for _, egwControlPlane := range source.EventGatewayControlPlanes { + if accumulated.HasRef(egwControlPlane.Ref) { + existing, _ := accumulated.GetResourceByRef(egwControlPlane.Ref) + return fmt.Errorf("duplicate ref '%s' found in %s (already defined as %s)", + egwControlPlane.Ref, sourcePath, existing.GetType()) + } + accumulated.EventGatewayControlPlanes = append(accumulated.EventGatewayControlPlanes, egwControlPlane) + } + // If this source defines a namespace default without parent resources, // propagate it so sync mode can inspect the correct namespace. if source.DefaultNamespace != "" { @@ -565,7 +574,10 @@ func (l *Loader) applyNamespaceDefaults(rs *resources.ResourceSet, fileDefaults for i := range rs.Portals { if rs.Portals[i].IsExternal() { if rs.Portals[i].Kongctl != nil { - return fmt.Errorf("portal '%s' is marked as external and cannot use kongctl metadata", rs.Portals[i].Ref) + return fmt.Errorf( + "portal '%s' is marked as external and cannot use kongctl metadata", + rs.Portals[i].Ref, + ) } continue } @@ -653,6 +665,26 @@ func (l *Loader) applyNamespaceDefaults(rs *resources.ResourceSet, fileDefaults } } + // Apply defaults to ControlPlanes (parent resources) + for i := range rs.EventGatewayControlPlanes { + if err := assignNamespace( + &rs.EventGatewayControlPlanes[i].Kongctl, + "control_plane", + rs.EventGatewayControlPlanes[i].Ref, + ); err != nil { + return err + } + // Apply protected default if not set + if rs.EventGatewayControlPlanes[i].Kongctl.Protected == nil && protectedDefault != nil { + rs.EventGatewayControlPlanes[i].Kongctl.Protected = protectedDefault + } + // Ensure protected has a value (false if still nil) + if rs.EventGatewayControlPlanes[i].Kongctl.Protected == nil { + falseVal := false + rs.EventGatewayControlPlanes[i].Kongctl.Protected = &falseVal + } + } + // Note: Child resources (API versions, publications, etc.) do not get kongctl metadata // as Konnect doesn't support labels on child resources return nil @@ -736,6 +768,10 @@ func (l *Loader) applyDefaults(rs *resources.ResourceSet) { for i := range rs.PortalEmailTemplates { rs.PortalEmailTemplates[i].SetDefaults() } + + for i := range rs.EventGatewayControlPlanes { + rs.EventGatewayControlPlanes[i].SetDefaults() + } } // extractPortalPages recursively extracts and flattens nested portal pages diff --git a/internal/declarative/planner/base_planner.go b/internal/declarative/planner/base_planner.go index b15d8690..03399e90 100644 --- a/internal/declarative/planner/base_planner.go +++ b/internal/declarative/planner/base_planner.go @@ -129,6 +129,13 @@ func (b *BasePlanner) GetDesiredPortalSnippets(namespace string) []resources.Por return b.planner.resources.GetPortalSnippetsByNamespace(namespace) } +// GetDesiredEventGatewayControlPlanes returns desired EGW CP resources from the specified namespace +func (b *BasePlanner) GetDesiredEventGatewayControlPlanes( + namespace string, +) []resources.EventGatewayControlPlaneResource { + return b.planner.resources.GetEventGatewayControlPlanesByNamespace(namespace) +} + // GetGenericPlanner returns the generic planner instance func (b *BasePlanner) GetGenericPlanner() *GenericPlanner { if b == nil || b.planner == nil { diff --git a/internal/declarative/planner/egw_control_plane_planner.go b/internal/declarative/planner/egw_control_plane_planner.go new file mode 100644 index 00000000..703f9766 --- /dev/null +++ b/internal/declarative/planner/egw_control_plane_planner.go @@ -0,0 +1,386 @@ +package planner + +import ( + "context" + "fmt" + "strings" + + "github.com/kong/kongctl/internal/declarative/labels" + "github.com/kong/kongctl/internal/declarative/resources" + "github.com/kong/kongctl/internal/declarative/state" +) + +type EGWControlPlanePlannerImpl struct { + *BasePlanner + resources *resources.ResourceSet +} + +func NewEGWControlPlanePlanner(planner *BasePlanner, resources *resources.ResourceSet) *EGWControlPlanePlannerImpl { + return &EGWControlPlanePlannerImpl{ + BasePlanner: planner, + resources: resources, + } +} + +func (p *EGWControlPlanePlannerImpl) GetDesiredEGWControlPlanes( + namespace string, +) []resources.EventGatewayControlPlaneResource { + return p.GetDesiredEventGatewayControlPlanes(namespace) +} + +func (p *EGWControlPlanePlannerImpl) PlanChanges(ctx context.Context, plannerCtx *Config, plan *Plan) error { + namespace := plannerCtx.Namespace + desired := p.GetDesiredEGWControlPlanes(namespace) + + // Skip if no desired Event Gateway Control Planes and not in sync mode + if len(desired) == 0 && plan.Metadata.Mode != PlanModeSync { + return nil + } + + err := p.planner.planEGWControlPlaneChanges(ctx, plannerCtx, desired, plan) + + if err != nil { + return err + } + + return nil +} + +func (p *Planner) planEGWControlPlaneChanges( + ctx context.Context, + plannerCtx *Config, + desired []resources.EventGatewayControlPlaneResource, + plan *Plan, +) error { + // Skip if no Event Gateway Control Plane resources to plan and not in sync mode + if len(desired) == 0 && plan.Metadata.Mode != PlanModeSync { + p.logger.Debug("Skipping Event Gateway Control Plane planning - no desired Event Gateway Control Planes") + return nil + } + + // Get namespace from planner context + namespace := plannerCtx.Namespace + + // Fetch current managed Event Gateway Control Planes from the specific namespace + namespaceFilter := []string{namespace} + currentEGWControlPlanes, err := p.client.ListManagedEventGatewayControlPlanes(ctx, namespaceFilter) + if err != nil { + // If API client is not configured, skip Event Gateway Control Plane planning + if state.IsAPIClientError(err) { + return nil + } + return fmt.Errorf("failed to list current Event Gateway Control Planes: %w", err) + } + + // Index current Event Gateway Control Planes by name + currentByName := make(map[string]state.EventGatewayControlPlane) + for _, cp := range currentEGWControlPlanes { + currentByName[cp.Name] = cp + } + + // Collect protection validation errors + var protectionErrors []error + + // Compare each desired Event Gateway Control Plane + for _, desiredEGWCP := range desired { + current, exists := currentByName[desiredEGWCP.Name] + + if !exists { + // CREATE action + _ = p.planEGWControlPlaneCreate(desiredEGWCP, plan) + } else { + // Check if update needed + isProtected := labels.IsProtectedResource(current.NormalizedLabels) + + // Get protection status from desired configuration + shouldProtect := false + if desiredEGWCP.Kongctl != nil && desiredEGWCP.Kongctl.Protected != nil && *desiredEGWCP.Kongctl.Protected { + shouldProtect = true + } + + // Handle protection changes + if isProtected != shouldProtect { + // When changing protection status, include any other field updates too + needsUpdate, updateFields := p.shouldUpdateEGWControlPlaneResource(current, desiredEGWCP) + + // Create protection change object + protectionChange := &ProtectionChange{ + Old: isProtected, + New: shouldProtect, + } + + // Validate protection change + err := p.validateProtectionWithChange( + string(resources.ResourceTypeEventGatewayControlPlane), + desiredEGWCP.Name, + isProtected, + ActionUpdate, + protectionChange, + needsUpdate, + ) + if err != nil { + protectionErrors = append(protectionErrors, err) + } else { + p.planEGWControlPlaneProtectionChangeWithFields( + current, desiredEGWCP, isProtected, shouldProtect, updateFields, plan) + } + } else { + // Check if update needed based on configuration + needsUpdate, updateFields := p.shouldUpdateEGWControlPlaneResource(current, desiredEGWCP) + if needsUpdate { + // Regular update - check protection + if err := p.validateProtection( + "event-gateway-control-plane", desiredEGWCP.Name, isProtected, ActionUpdate, + ); err != nil { + protectionErrors = append(protectionErrors, err) + } else { + p.planEGWControlPlaneUpdateWithFields(current, desiredEGWCP, updateFields, plan) + } + } + } + } + } + + // Check for managed resources to delete (sync mode only) + if plan.Metadata.Mode == PlanModeSync { + // Build set of desired Event gateway names + desiredNames := make(map[string]bool) + for _, eventGateway := range desired { + desiredNames[eventGateway.Name] = true + } + + // Find managed Event Gateway Control Planes not in desired state + for name, current := range currentByName { + if !desiredNames[name] { + // Validate protection before adding DELETE + isProtected := labels.IsProtectedResource(current.NormalizedLabels) + if err := p.validateProtection("event-gateway-control-plane", name, isProtected, ActionDelete); err != nil { + protectionErrors = append(protectionErrors, err) + } else { + p.planEGWControlPlaneDelete(current, plan) + } + } + } + } + + // Fail fast if any protected resources would be modified + if len(protectionErrors) > 0 { + errMsg := "Cannot generate plan due to protected resources:\n" + for _, err := range protectionErrors { + errMsg += fmt.Sprintf("- %s\n", err.Error()) + } + errMsg += "\nTo proceed, first update these resources to set protected: false" + return fmt.Errorf("%s", errMsg) + } + + return nil +} + +func (p *Planner) planEGWControlPlaneProtectionChangeWithFields( + current state.EventGatewayControlPlane, + desired resources.EventGatewayControlPlaneResource, + wasProtected, shouldProtect bool, + updateFields map[string]any, + plan *Plan, +) { + // Extract namespace + namespace := DefaultNamespace + if desired.Kongctl != nil && desired.Kongctl.Namespace != nil { + namespace = *desired.Kongctl.Namespace + } + + // Use generic protection change planner + config := ProtectionChangeConfig{ + ResourceType: string(resources.ResourceTypeEventGatewayControlPlane), + ResourceName: desired.Name, + ResourceRef: desired.GetRef(), + ResourceID: current.ID, + OldProtected: wasProtected, + NewProtected: shouldProtect, + Namespace: namespace, + } + + change := p.genericPlanner.PlanProtectionChange(context.Background(), config) + + // Always include essential fields for protection changes + fields := make(map[string]any) + + // Include any field updates if present + for field, newValue := range updateFields { + fields[field] = newValue + } + + // ALWAYS include essential identification fields for protection changes + fields["name"] = current.Name + fields["id"] = current.ID + + // Preserve namespace context for execution phase + if current.Labels != nil { + if namespace, exists := current.Labels[labels.NamespaceKey]; exists { + fields["namespace"] = namespace + } + } + + // Preserve other critical labels that identify managed resources + if current.Labels != nil { + preservedLabels := make(map[string]string) + for key, value := range current.Labels { + // Preserve all KONGCTL- prefixed labels except protected (which will be updated) + if strings.HasPrefix(key, "KONGCTL-") && key != labels.ProtectedKey { + preservedLabels[key] = value + } + } + if len(preservedLabels) > 0 { + fields["preserved_labels"] = preservedLabels + } + } + + change.Fields = fields + + plan.AddChange(change) +} + +func (p *Planner) shouldUpdateEGWControlPlaneResource( + current state.EventGatewayControlPlane, + desired resources.EventGatewayControlPlaneResource, +) (bool, map[string]any) { + updates := make(map[string]any) + + if desired.Name != current.Name { + currentName := current.Name + if currentName != desired.Name { + updates["name"] = desired.Name + } + } + + if desired.Description != current.Description { + if getString(current.Description) != getString(desired.Description) { + updates["description"] = getString(desired.Description) + } + } + + if desired.Labels != nil { + if labels.CompareUserLabels(current.NormalizedLabels, desired.GetLabels()) { + updates["labels"] = desired.GetLabels() + } + } + + // Add other field comparisons + + return len(updates) > 0, updates +} + +func (p *Planner) planEGWControlPlaneCreate( + egwControlPlane resources.EventGatewayControlPlaneResource, + plan *Plan, +) string { + var protection any + if egwControlPlane.Kongctl != nil && egwControlPlane.Kongctl.Protected != nil { + protection = *egwControlPlane.Kongctl.Protected + } + + // Extract namespace + namespace := DefaultNamespace + if egwControlPlane.Kongctl != nil && egwControlPlane.Kongctl.Namespace != nil { + namespace = *egwControlPlane.Kongctl.Namespace + } + + config := CreateConfig{ + ResourceType: string(egwControlPlane.GetType()), + ResourceName: egwControlPlane.Name, + ResourceRef: egwControlPlane.Ref, + RequiredFields: []string{"name"}, + FieldExtractor: func(_ any) map[string]any { + return extractEGWControlPlaneFields(egwControlPlane) + }, + Namespace: namespace, + DependsOn: []string{}, + } + + change, err := p.genericPlanner.PlanCreate(context.Background(), config) + if err != nil { + return "" + } + change.Protection = protection + plan.AddChange(change) + return change.ID +} + +func extractEGWControlPlaneFields(resource any) map[string]any { + fields := make(map[string]any) + egwControlPlane, ok := resource.(resources.EventGatewayControlPlaneResource) + if !ok { + return fields + } + + fields["name"] = egwControlPlane.Name + + if egwControlPlane.Description != nil { + fields["description"] = *egwControlPlane.Description + } + + if len(egwControlPlane.GetLabels()) > 0 { + fields["labels"] = egwControlPlane.GetLabels() + } + return fields +} + +func (p *Planner) planEGWControlPlaneUpdateWithFields( + current state.EventGatewayControlPlane, + desired resources.EventGatewayControlPlaneResource, + updateFields map[string]any, + plan *Plan) { + var protection any + if desired.Kongctl != nil && desired.Kongctl.Protected != nil { + protection = *desired.Kongctl.Protected + } + + // Extract namespace + namespace := DefaultNamespace + if desired.Kongctl != nil && desired.Kongctl.Namespace != nil { + namespace = *desired.Kongctl.Namespace + } + + // Always include name for identification + updateFields["name"] = current.Name + + updateFields[FieldCurrentLabels] = current.NormalizedLabels + config := UpdateConfig{ + ResourceType: string(desired.GetType()), + ResourceName: desired.Name, + ResourceRef: desired.Ref, + ResourceID: current.ID, + CurrentFields: nil, // Not needed for direct update + DesiredFields: updateFields, + RequiredFields: []string{"name"}, + Namespace: namespace, + } + + change, err := p.genericPlanner.PlanUpdate(context.Background(), config) + if err != nil { + // Handle error appropriately - this is example code + // In real implementation, return the error + return + } + change.Protection = protection + + plan.AddChange(change) +} + +func (p *Planner) planEGWControlPlaneDelete(egwControlPlane state.EventGatewayControlPlane, plan *Plan) { + namespace := DefaultNamespace + if ns, ok := egwControlPlane.NormalizedLabels[labels.NamespaceKey]; ok { + namespace = ns + } + + config := DeleteConfig{ + ResourceType: string(resources.ResourceTypeEventGatewayControlPlane), + ResourceName: egwControlPlane.Name, + ResourceRef: egwControlPlane.Name, + ResourceID: egwControlPlane.ID, + Namespace: namespace, + } + + change := p.genericPlanner.PlanDelete(context.Background(), config) + plan.AddChange(change) +} diff --git a/internal/declarative/planner/interfaces.go b/internal/declarative/planner/interfaces.go index 252e8911..65758c7a 100644 --- a/internal/declarative/planner/interfaces.go +++ b/internal/declarative/planner/interfaces.go @@ -40,3 +40,10 @@ type APIPlanner interface { type CatalogServicePlanner interface { ResourcePlanner } + +// EGWControlPlanePlanner handles planning for Event Gateway Control Plane resources +type EGWControlPlanePlanner interface { + ResourcePlanner + + // Additional Event Gateway Control Plane-specific methods if needed +} diff --git a/internal/declarative/planner/planner.go b/internal/declarative/planner/planner.go index b78e26ee..08f157b5 100644 --- a/internal/declarative/planner/planner.go +++ b/internal/declarative/planner/planner.go @@ -33,11 +33,12 @@ type Planner struct { genericPlanner *GenericPlanner // Resource-specific planners - portalPlanner PortalPlanner - controlPlanePlanner ControlPlanePlanner - authStrategyPlanner AuthStrategyPlanner - apiPlanner APIPlanner - catalogServicePlanner CatalogServicePlanner + portalPlanner PortalPlanner + controlPlanePlanner ControlPlanePlanner + authStrategyPlanner AuthStrategyPlanner + apiPlanner APIPlanner + catalogServicePlanner CatalogServicePlanner + eventGatewayControlPlanePlanner EGWControlPlanePlanner // ResourceSet containing all desired resources resources *resources.ResourceSet @@ -73,6 +74,7 @@ func NewPlanner(client *state.Client, logger *slog.Logger) *Planner { // Initialize resource-specific planners base := NewBasePlanner(p) p.portalPlanner = NewPortalPlanner(base) + p.eventGatewayControlPlanePlanner = NewEGWControlPlanePlanner(base, p.resources) p.controlPlanePlanner = NewControlPlanePlanner(base) p.authStrategyPlanner = NewAuthStrategyPlanner(base) p.catalogServicePlanner = NewCatalogServicePlanner(base) @@ -155,6 +157,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, rs *resources.ResourceSet, o namespacePlanner.authStrategyPlanner = NewAuthStrategyPlanner(base) namespacePlanner.catalogServicePlanner = NewCatalogServicePlanner(base) namespacePlanner.apiPlanner = NewAPIPlanner(base) + namespacePlanner.eventGatewayControlPlanePlanner = NewEGWControlPlanePlanner(base, rs) // Store full ResourceSet for access by planners (enables both filtered views and global lookups) namespacePlanner.resources = rs @@ -208,6 +211,10 @@ func (p *Planner) GeneratePlan(ctx context.Context, rs *resources.ResourceSet, o return nil, fmt.Errorf("failed to plan API changes for namespace %s: %w", namespace, err) } + if err := namespacePlanner.eventGatewayControlPlanePlanner.PlanChanges(ctx, plannerCtx, namespacePlan); err != nil { + return nil, fmt.Errorf("failed to plan Event Gateway Control Plane changes for namespace %s: %w", namespace, err) + } + // Merge namespace plan into base plan basePlan.Changes = append(basePlan.Changes, namespacePlan.Changes...) basePlan.Warnings = append(basePlan.Warnings, namespacePlan.Warnings...) @@ -1277,6 +1284,11 @@ func (p *Planner) getResourceNamespaces(rs *resources.ResourceSet) []string { namespaceSet[ns] = true } + for _, cp := range rs.EventGatewayControlPlanes { + ns := resources.GetNamespace(cp.Kongctl) + namespaceSet[ns] = true + } + // Convert set to sorted slice for consistent ordering namespaces := make([]string, 0, len(namespaceSet)) for ns := range namespaceSet { diff --git a/internal/declarative/resources/event_gateway_control_plane.go b/internal/declarative/resources/event_gateway_control_plane.go new file mode 100644 index 00000000..fd43b5bc --- /dev/null +++ b/internal/declarative/resources/event_gateway_control_plane.go @@ -0,0 +1,100 @@ +package resources + +import ( + "fmt" + "reflect" + + kkComps "github.com/Kong/sdk-konnect-go/models/components" +) + +type EventGatewayControlPlaneResource struct { + kkComps.CreateGatewayRequest + Ref string `json:"ref" yaml:"ref"` + Kongctl *KongctlMeta `json:"kongctl,omitempty" yaml:"kongctl,omitempty"` + + // Resolved Konnect ID (not serialized) + konnectID string `json:"-" yaml:"-"` +} + +func (e EventGatewayControlPlaneResource) GetType() ResourceType { + return ResourceTypeEventGatewayControlPlane +} + +func (e EventGatewayControlPlaneResource) GetRef() string { + return e.Ref +} + +func (e EventGatewayControlPlaneResource) GetMoniker() string { + return e.Name +} + +func (e EventGatewayControlPlaneResource) GetKonnectID() string { + return e.konnectID +} + +func (e EventGatewayControlPlaneResource) GetDependencies() []ResourceRef { + return []ResourceRef{} +} + +func (e EventGatewayControlPlaneResource) GetLabels() map[string]string { + return e.Labels +} + +func (e *EventGatewayControlPlaneResource) SetLabels(labels map[string]string) { + // Convert map to SDK format + e.Labels = labels +} + +func (e EventGatewayControlPlaneResource) Validate() error { + if err := ValidateRef(e.Ref); err != nil { + return fmt.Errorf("invalid Event Gateway Control Plane ref: %w", err) + } + return nil +} + +func (e *EventGatewayControlPlaneResource) SetDefaults() { + if e.Name == "" { + e.Name = e.Ref + } +} + +func (e EventGatewayControlPlaneResource) GetKonnectMonikerFilter() string { + if e.Name == "" { + return "" + } + return fmt.Sprintf("name[eq]=%s", e.Name) +} + +func (e *EventGatewayControlPlaneResource) TryMatchKonnectResource(konnectResource any) bool { + v := reflect.ValueOf(konnectResource) + + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() != reflect.Struct { + return false + } + + nameField := v.FieldByName("Name") + idField := v.FieldByName("ID") + + if !nameField.IsValid() || !idField.IsValid() { + eventGatewayField := v.FieldByName("EventGatewayInfo") + if eventGatewayField.IsValid() && eventGatewayField.Kind() == reflect.Struct { + nameField = eventGatewayField.FieldByName("Name") + idField = eventGatewayField.FieldByName("ID") + } + } + + // Extract values if fields are valid + if nameField.IsValid() && idField.IsValid() && + nameField.Kind() == reflect.String && idField.Kind() == reflect.String { + if nameField.String() == e.Name { + e.konnectID = idField.String() + return true + } + } + + return false +} diff --git a/internal/declarative/resources/types.go b/internal/declarative/resources/types.go index d38ba4ce..bb16b25d 100644 --- a/internal/declarative/resources/types.go +++ b/internal/declarative/resources/types.go @@ -5,27 +5,28 @@ type ResourceType string // Resource type constants const ( - ResourceTypePortal ResourceType = "portal" - ResourceTypeApplicationAuthStrategy ResourceType = "application_auth_strategy" - ResourceTypeControlPlane ResourceType = "control_plane" - ResourceTypeAPI ResourceType = "api" - ResourceTypeAPIVersion ResourceType = "api_version" - ResourceTypeAPIPublication ResourceType = "api_publication" - ResourceTypeAPIImplementation ResourceType = "api_implementation" - ResourceTypeAPIDocument ResourceType = "api_document" - ResourceTypeGatewayService ResourceType = "gateway_service" - ResourceTypePortalCustomization ResourceType = "portal_customization" - ResourceTypePortalCustomDomain ResourceType = "portal_custom_domain" - ResourceTypePortalAuthSettings ResourceType = "portal_auth_settings" - ResourceTypePortalPage ResourceType = "portal_page" - ResourceTypePortalSnippet ResourceType = "portal_snippet" - ResourceTypePortalTeam ResourceType = "portal_team" - ResourceTypePortalTeamRole ResourceType = "portal_team_role" - ResourceTypePortalAssetLogo ResourceType = "portal_asset_logo" - ResourceTypePortalAssetFavicon ResourceType = "portal_asset_favicon" - ResourceTypePortalEmailConfig ResourceType = "portal_email_config" - ResourceTypePortalEmailTemplate ResourceType = "portal_email_template" - ResourceTypeCatalogService ResourceType = "catalog_service" + ResourceTypePortal ResourceType = "portal" + ResourceTypeApplicationAuthStrategy ResourceType = "application_auth_strategy" + ResourceTypeControlPlane ResourceType = "control_plane" + ResourceTypeAPI ResourceType = "api" + ResourceTypeAPIVersion ResourceType = "api_version" + ResourceTypeAPIPublication ResourceType = "api_publication" + ResourceTypeAPIImplementation ResourceType = "api_implementation" + ResourceTypeAPIDocument ResourceType = "api_document" + ResourceTypeGatewayService ResourceType = "gateway_service" + ResourceTypePortalCustomization ResourceType = "portal_customization" + ResourceTypePortalCustomDomain ResourceType = "portal_custom_domain" + ResourceTypePortalAuthSettings ResourceType = "portal_auth_settings" + ResourceTypePortalPage ResourceType = "portal_page" + ResourceTypePortalSnippet ResourceType = "portal_snippet" + ResourceTypePortalTeam ResourceType = "portal_team" + ResourceTypePortalTeamRole ResourceType = "portal_team_role" + ResourceTypePortalAssetLogo ResourceType = "portal_asset_logo" + ResourceTypePortalAssetFavicon ResourceType = "portal_asset_favicon" + ResourceTypePortalEmailConfig ResourceType = "portal_email_config" + ResourceTypePortalEmailTemplate ResourceType = "portal_email_template" + ResourceTypeCatalogService ResourceType = "catalog_service" + ResourceTypeEventGatewayControlPlane ResourceType = "event_gateway" ) const ( @@ -55,17 +56,18 @@ type ResourceSet struct { APIImplementations []APIImplementationResource `yaml:"api_implementations,omitempty" json:"api_implementations,omitempty"` //nolint:lll APIDocuments []APIDocumentResource `yaml:"api_documents,omitempty" json:"api_documents,omitempty"` //nolint:lll // Portal child resources can be defined at root level (with parent reference) or nested under Portals - PortalCustomizations []PortalCustomizationResource `yaml:"portal_customizations,omitempty" json:"portal_customizations,omitempty"` //nolint:lll - PortalAuthSettings []PortalAuthSettingsResource `yaml:"portal_auth_settings,omitempty" json:"portal_auth_settings,omitempty"` //nolint:lll - PortalCustomDomains []PortalCustomDomainResource `yaml:"portal_custom_domains,omitempty" json:"portal_custom_domains,omitempty"` //nolint:lll - PortalPages []PortalPageResource `yaml:"portal_pages,omitempty" json:"portal_pages,omitempty"` //nolint:lll - PortalSnippets []PortalSnippetResource `yaml:"portal_snippets,omitempty" json:"portal_snippets,omitempty"` //nolint:lll - PortalTeams []PortalTeamResource `yaml:"portal_teams,omitempty" json:"portal_teams,omitempty"` //nolint:lll - PortalTeamRoles []PortalTeamRoleResource `yaml:"portal_team_roles,omitempty" json:"portal_team_roles,omitempty"` //nolint:lll - PortalAssetLogos []PortalAssetLogoResource `yaml:"portal_asset_logos,omitempty" json:"portal_asset_logos,omitempty"` //nolint:lll - PortalAssetFavicons []PortalAssetFaviconResource `yaml:"portal_asset_favicons,omitempty" json:"portal_asset_favicons,omitempty"` //nolint:lll - PortalEmailConfigs []PortalEmailConfigResource `yaml:"portal_email_configs,omitempty" json:"portal_email_configs,omitempty"` //nolint:lll - PortalEmailTemplates []PortalEmailTemplateResource `yaml:"portal_email_templates,omitempty" json:"portal_email_templates,omitempty"` //nolint:lll + PortalCustomizations []PortalCustomizationResource `yaml:"portal_customizations,omitempty" json:"portal_customizations,omitempty"` //nolint:lll + PortalAuthSettings []PortalAuthSettingsResource `yaml:"portal_auth_settings,omitempty" json:"portal_auth_settings,omitempty"` //nolint:lll + PortalCustomDomains []PortalCustomDomainResource `yaml:"portal_custom_domains,omitempty" json:"portal_custom_domains,omitempty"` //nolint:lll + PortalPages []PortalPageResource `yaml:"portal_pages,omitempty" json:"portal_pages,omitempty"` //nolint:lll + PortalSnippets []PortalSnippetResource `yaml:"portal_snippets,omitempty" json:"portal_snippets,omitempty"` //nolint:lll + PortalTeams []PortalTeamResource `yaml:"portal_teams,omitempty" json:"portal_teams,omitempty"` //nolint:lll + PortalTeamRoles []PortalTeamRoleResource `yaml:"portal_team_roles,omitempty" json:"portal_team_roles,omitempty"` //nolint:lll + PortalAssetLogos []PortalAssetLogoResource `yaml:"portal_asset_logos,omitempty" json:"portal_asset_logos,omitempty"` //nolint:lll + PortalAssetFavicons []PortalAssetFaviconResource `yaml:"portal_asset_favicons,omitempty" json:"portal_asset_favicons,omitempty"` //nolint:lll + PortalEmailConfigs []PortalEmailConfigResource `yaml:"portal_email_configs,omitempty" json:"portal_email_configs,omitempty"` //nolint:lll + PortalEmailTemplates []PortalEmailTemplateResource `yaml:"portal_email_templates,omitempty" json:"portal_email_templates,omitempty"` //nolint:lll + EventGatewayControlPlanes []EventGatewayControlPlaneResource `yaml:"event_gateways,omitempty" json:"event_gateways,omitempty"` //nolint:lll // DefaultNamespace tracks namespace from _defaults when no resources are present // This is used by the planner to determine which namespace to check for deletions @@ -600,6 +602,17 @@ func (rs *ResourceSet) GetPortalTeamRolesByNamespace(namespace string) []PortalT return filtered } +// GetEventGatewayControlPlanesByNamespace returns all EGW CP resources from the specified namespace +func (rs *ResourceSet) GetEventGatewayControlPlanesByNamespace(namespace string) []EventGatewayControlPlaneResource { + var filtered []EventGatewayControlPlaneResource + for _, cp := range rs.EventGatewayControlPlanes { + if GetNamespace(cp.Kongctl) == namespace { + filtered = append(filtered, cp) + } + } + return filtered +} + // GetNamespace safely extracts namespace from kongctl metadata func GetNamespace(kongctl *KongctlMeta) string { if kongctl == nil || kongctl.Namespace == nil { diff --git a/internal/declarative/state/client.go b/internal/declarative/state/client.go index 709bdd82..aa75d507 100644 --- a/internal/declarative/state/client.go +++ b/internal/declarative/state/client.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "log/slog" + "net/url" "strings" + kk "github.com/Kong/sdk-konnect-go" // kk = Kong Konnect kkComps "github.com/Kong/sdk-konnect-go/models/components" kkOps "github.com/Kong/sdk-konnect-go/models/operations" kkErrors "github.com/Kong/sdk-konnect-go/models/sdkerrors" @@ -44,6 +46,9 @@ type ClientConfig struct { APIPublicationAPI helpers.APIPublicationAPI APIImplementationAPI helpers.APIImplementationAPI APIDocumentAPI helpers.APIDocumentAPI + + // Event Gateway Control Plane API + EGWControlPlaneAPI helpers.EGWControlPlaneAPI } // Client wraps Konnect SDK for state management @@ -73,6 +78,9 @@ type Client struct { apiPublicationAPI helpers.APIPublicationAPI apiImplementationAPI helpers.APIImplementationAPI apiDocumentAPI helpers.APIDocumentAPI + + // Event Gateway Control Plane API + egwControlPlaneAPI helpers.EGWControlPlaneAPI } // NewClient creates a new state client with the provided configuration @@ -103,6 +111,9 @@ func NewClient(config ClientConfig) *Client { apiPublicationAPI: config.APIPublicationAPI, apiImplementationAPI: config.APIImplementationAPI, apiDocumentAPI: config.APIDocumentAPI, + + // Event Gateway Control Plane APIs + egwControlPlaneAPI: config.EGWControlPlaneAPI, } } @@ -219,6 +230,11 @@ type ApplicationAuthStrategy struct { NormalizedLabels map[string]string // Non-pointer labels } +type EventGatewayControlPlane struct { + kkComps.EventGatewayInfo + NormalizedLabels map[string]string // Non-pointer labels +} + // ListManagedPortals returns all KONGCTL-managed portals in the specified namespaces // If namespaces is empty, no resources are returned (breaking change from previous behavior) // To get all managed resources across all namespaces, pass []string{"*"} @@ -3096,7 +3112,8 @@ func (c *Client) ListPortalTeamRoles(ctx context.Context, portalID string, teamI }) } - if resp.AssignedPortalRoleCollectionResponse == nil || len(resp.AssignedPortalRoleCollectionResponse.Data) == 0 { + if resp.AssignedPortalRoleCollectionResponse == nil || + len(resp.AssignedPortalRoleCollectionResponse.Data) == 0 { break } @@ -3184,6 +3201,145 @@ func (c *Client) RemovePortalTeamRole(ctx context.Context, portalID string, team return nil } +func (c *Client) ListManagedEventGatewayControlPlanes( + ctx context.Context, + namespaces []string, +) ([]EventGatewayControlPlane, error) { + // Validate API client is initialized + if err := ValidateAPIClient(c.egwControlPlaneAPI, "event gateway control plane API"); err != nil { + return nil, err + } + + var allData []kkComps.EventGatewayInfo + var pageAfter *string + + for { + req := kkOps.ListEventGatewaysRequest{} + + if pageAfter != nil { + req.PageAfter = pageAfter + } + + res, err := c.egwControlPlaneAPI.ListEGWControlPlanes(ctx, req) + if err != nil { + return nil, WrapAPIError(err, "list event gateway control planes", nil) + } + + // If response is nil, break the loop + if res.ListEventGatewaysResponse == nil { + return []EventGatewayControlPlane{}, nil + } + + allData = append(allData, res.ListEventGatewaysResponse.Data...) + + if res.ListEventGatewaysResponse.Meta.Page.Next == nil { + break + } + + u, err := url.Parse(*res.ListEventGatewaysResponse.Meta.Page.Next) + if err != nil { + return nil, WrapAPIError(err, "list event gateway control planes: invalid cursor", nil) + } + + values := u.Query() + pageAfter = kk.String(values.Get("page[after]")) + } + + var filteredEGWControlPlanes []EventGatewayControlPlane + for _, f := range allData { + + // Filter by managed status and namespace + if labels.IsManagedResource(f.Labels) { + if shouldIncludeNamespace(f.Labels[labels.NamespaceKey], namespaces) { + eventGatewayControlPlane := EventGatewayControlPlane{ + f, + f.Labels, + } + filteredEGWControlPlanes = append(filteredEGWControlPlanes, eventGatewayControlPlane) + } + } + } + return filteredEGWControlPlanes, nil +} + +func (c *Client) CreateEventGatewayControlPlane( + ctx context.Context, + req kkComps.CreateGatewayRequest, + namespace string, +) (string, error) { + resp, err := c.egwControlPlaneAPI.CreateEGWControlPlane(ctx, req) + if err != nil { + return "", WrapAPIError(err, "create event gateway control plane", &ErrorWrapperOptions{ + ResourceType: "event_gateway", + ResourceName: "", // Adjust based on SDK + Namespace: namespace, + UseEnhanced: true, + }) + } + + if err := ValidateResponse(resp.EventGatewayInfo, "create event gateway control plane"); err != nil { + return "", err + } + + return resp.EventGatewayInfo.ID, nil +} + +func (c *Client) UpdateEventGatewayControlPlane( + ctx context.Context, + id string, + req kkComps.UpdateGatewayRequest, + namespace string, +) (string, error) { + resp, err := c.egwControlPlaneAPI.UpdateEGWControlPlane(ctx, id, req) + if err != nil { + return "", WrapAPIError(err, "update event gateway control plane", &ErrorWrapperOptions{ + ResourceType: "event_gateway", + ResourceName: "", // Adjust based on SDK + Namespace: namespace, + UseEnhanced: true, + }) + } + + return resp.EventGatewayInfo.ID, nil +} + +func (c *Client) GetEventGatewayControlPlaneByID(ctx context.Context, id string) (*EventGatewayControlPlane, error) { + resp, err := c.egwControlPlaneAPI.FetchEGWControlPlane(ctx, id) + if err != nil { + return nil, WrapAPIError(err, "get event gateway control plane by ID", &ErrorWrapperOptions{ + ResourceType: "event_gateway", + ResourceName: "", // Adjust based on SDK + UseEnhanced: true, + }) + } + + if resp.EventGatewayInfo == nil { + return nil, nil + } + + // Labels are already map[string]string in the SDK + normalized := resp.EventGatewayInfo.Labels + if normalized == nil { + normalized = make(map[string]string) + } + + eventGateway := &EventGatewayControlPlane{ + EventGatewayInfo: *resp.EventGatewayInfo, + NormalizedLabels: normalized, + } + + return eventGateway, nil +} + +func (c *Client) DeleteEventGatewayControlPlane(ctx context.Context, id string) error { + // Placeholder for future implementation + _, err := c.egwControlPlaneAPI.DeleteEGWControlPlane(ctx, id) + if err != nil { + return WrapAPIError(err, "delete event gateway control plane", nil) + } + return nil +} + func getString(value *string) string { if value == nil { return "" diff --git a/internal/konnect/helpers/event_gateway_control_plane.go b/internal/konnect/helpers/event_gateway_control_plane.go new file mode 100644 index 00000000..09cb1a74 --- /dev/null +++ b/internal/konnect/helpers/event_gateway_control_plane.go @@ -0,0 +1,62 @@ +package helpers + +import ( + "context" + + kkSDK "github.com/Kong/sdk-konnect-go" + kkComps "github.com/Kong/sdk-konnect-go/models/components" + kkOps "github.com/Kong/sdk-konnect-go/models/operations" +) + +type EGWControlPlaneAPI interface { + // Event Gateway Control Plane operations + ListEGWControlPlanes(ctx context.Context, request kkOps.ListEventGatewaysRequest, + opts ...kkOps.Option) (*kkOps.ListEventGatewaysResponse, error) + FetchEGWControlPlane(ctx context.Context, gatewayID string, + opts ...kkOps.Option) (*kkOps.GetEventGatewayResponse, error) + CreateEGWControlPlane(ctx context.Context, request kkComps.CreateGatewayRequest, + opts ...kkOps.Option) (*kkOps.CreateEventGatewayResponse, error) + UpdateEGWControlPlane(ctx context.Context, gatewayID string, request kkComps.UpdateGatewayRequest, + opts ...kkOps.Option) (*kkOps.UpdateEventGatewayResponse, error) + DeleteEGWControlPlane(ctx context.Context, gatewayID string, + opts ...kkOps.Option) (*kkOps.DeleteEventGatewayResponse, error) +} + +// EGWControlPlaneAPIImpl provides an implementation of the EGWControlPlaneAPI interface. +// It implements all Event Gateway Control Plane operations defined by EGWControlPlaneAPI. +type EGWControlPlaneAPIImpl struct { + SDK *kkSDK.SDK +} + +func (a *EGWControlPlaneAPIImpl) ListEGWControlPlanes(ctx context.Context, request kkOps.ListEventGatewaysRequest, + opts ...kkOps.Option, +) (*kkOps.ListEventGatewaysResponse, error) { + return a.SDK.EventGateways.ListEventGateways(ctx, request, opts...) +} + +func (a *EGWControlPlaneAPIImpl) FetchEGWControlPlane(ctx context.Context, gatewayID string, + opts ...kkOps.Option, +) (*kkOps.GetEventGatewayResponse, error) { + return a.SDK.EventGateways.GetEventGateway(ctx, gatewayID, opts...) +} + +func (a *EGWControlPlaneAPIImpl) CreateEGWControlPlane(ctx context.Context, request kkComps.CreateGatewayRequest, + opts ...kkOps.Option, +) (*kkOps.CreateEventGatewayResponse, error) { + return a.SDK.EventGateways.CreateEventGateway(ctx, request, opts...) +} + +func (a *EGWControlPlaneAPIImpl) UpdateEGWControlPlane( + ctx context.Context, + gatewayID string, + request kkComps.UpdateGatewayRequest, + opts ...kkOps.Option, +) (*kkOps.UpdateEventGatewayResponse, error) { + return a.SDK.EventGateways.UpdateEventGateway(ctx, gatewayID, request, opts...) +} + +func (a *EGWControlPlaneAPIImpl) DeleteEGWControlPlane(ctx context.Context, gatewayID string, + opts ...kkOps.Option, +) (*kkOps.DeleteEventGatewayResponse, error) { + return a.SDK.EventGateways.DeleteEventGateway(ctx, gatewayID, opts...) +} diff --git a/internal/konnect/helpers/sdk.go b/internal/konnect/helpers/sdk.go index 479d11fc..4e4aeda1 100644 --- a/internal/konnect/helpers/sdk.go +++ b/internal/konnect/helpers/sdk.go @@ -43,6 +43,7 @@ type SDKAPI interface { GetPortalTeamMembershipAPI() PortalTeamMembershipAPI GetAssetsAPI() AssetsAPI GetPortalEmailsAPI() PortalEmailsAPI + GetEventGatewayControlPlaneAPI() EGWControlPlaneAPI } // This is the real implementation of the SDKAPI @@ -275,6 +276,15 @@ func (k *KonnectSDK) GetPortalEmailsAPI() PortalEmailsAPI { return &PortalEmailsAPIImpl{SDK: k.SDK} } +// Returns the implementation of the EGWControlPlaneAPI interface +func (k *KonnectSDK) GetEventGatewayControlPlaneAPI() EGWControlPlaneAPI { + if k.SDK == nil { + return nil + } + + return &EGWControlPlaneAPIImpl{SDK: k.SDK} +} + // A function that can build an SDKAPI with a given configuration type SDKAPIFactory func(cfg config.Hook, logger *slog.Logger) (SDKAPI, error) diff --git a/internal/konnect/helpers/sdk_mock.go b/internal/konnect/helpers/sdk_mock.go index b62d3911..0347dff1 100644 --- a/internal/konnect/helpers/sdk_mock.go +++ b/internal/konnect/helpers/sdk_mock.go @@ -32,6 +32,9 @@ type MockKonnectSDK struct { PortalTeamMembershipFactory func() PortalTeamMembershipAPI AssetsFactory func() AssetsAPI PortalEmailsFactory func() PortalEmailsAPI + + // Event Gateway Control Plane factory + EventGatewayControlPlaneFactory func() EGWControlPlaneAPI } // Returns a mock instance of the ControlPlaneAPI @@ -231,6 +234,14 @@ func (m *MockKonnectSDK) GetPortalEmailsAPI() PortalEmailsAPI { return nil } +// Returns a mock instance of the EGWControlPlaneAPI +func (m *MockKonnectSDK) GetEventGatewayControlPlaneAPI() EGWControlPlaneAPI { + if m.EventGatewayControlPlaneFactory != nil { + return m.EventGatewayControlPlaneFactory() + } + return nil +} + // This is a mock implementation of the SDKFactory interface // which can associate a Testing.T instance with any MockKonnectSDK // instances Built by it diff --git a/test/e2e/harness/reset.go b/test/e2e/harness/reset.go index a1216a78..789f85b4 100644 --- a/test/e2e/harness/reset.go +++ b/test/e2e/harness/reset.go @@ -180,6 +180,7 @@ var resetSequence = []struct { {"v2", "application-auth-strategies"}, {"v2", "control-planes"}, {"v1", "catalog-services"}, + {"v1", "event-gateways"}, } func executeReset(client *http.Client, baseURL, token string) (resetResult, error) { diff --git a/test/e2e/scenarios/event-gateway/control-planes/overlays/002-update-fields/event-gateways.yaml b/test/e2e/scenarios/event-gateway/control-planes/overlays/002-update-fields/event-gateways.yaml new file mode 100644 index 00000000..628ba6a9 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/control-planes/overlays/002-update-fields/event-gateways.yaml @@ -0,0 +1,10 @@ +event_gateways: + - ref: egw-top-level + name: "My Event Gateway CP" + description: "Updated description for My Event Gateway CP" + labels: + owner: "platform-eng" + lifecycle: "production" + kongctl: + namespace: "event-gateways-comprehensive" + protected: false diff --git a/test/e2e/scenarios/event-gateway/control-planes/overlays/003-sync-delete/event-gateways.yaml b/test/e2e/scenarios/event-gateway/control-planes/overlays/003-sync-delete/event-gateways.yaml new file mode 100644 index 00000000..8d75aa05 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/control-planes/overlays/003-sync-delete/event-gateways.yaml @@ -0,0 +1,5 @@ +_defaults: + kongctl: + namespace: "event-gateways-comprehensive" + +event_gateways: [] diff --git a/test/e2e/scenarios/event-gateway/control-planes/scenario.yaml b/test/e2e/scenarios/event-gateway/control-planes/scenario.yaml new file mode 100644 index 00000000..e07b0300 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/control-planes/scenario.yaml @@ -0,0 +1,130 @@ +baseInputsPath: ../../../testdata/declarative/event-gateways/comprehensive-fields + +env: + KONGCTL_LOG_LEVEL: info + +defaults: + mask: + dropKeys: + - id + - created_at + - updated_at + +steps: + - name: 000-reset-org + skipInputs: true + commands: + - resetOrg: true + + - name: 001-apply-create + commands: + - name: 000-apply-create + run: + - apply + - -f + - "{{ .workdir }}/event-gateways.yaml" + - --auto-approve + assertions: + - select: plan.summary + expect: + fields: + total_changes: 1 + by_action.CREATE: 1 + - select: summary + expect: + fields: + applied: 1 + failed: 0 + status: success + - select: "plan.changes[?resource_type=='event_gateway' && resource_ref=='egw-top-level'] | [0]" + expect: + fields: + action: CREATE + fields.name: "My Event Gateway CP" + fields.description: "Initial description for My Event Gateway CP" + fields.labels.owner: "platform" + fields.labels.lifecycle: "dev" + - name: 001-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='My Event Gateway CP'] | [0]" + expect: + fields: + name: "My Event Gateway CP" + description: "Initial description for My Event Gateway CP" + labels.owner: "platform" + labels.lifecycle: "dev" + labels."KONGCTL-namespace": "event-gateways-comprehensive" + - name: 002-apply-update + inputOverlayDirs: + - overlays/002-update-fields + commands: + - name: 000-apply-update + run: + - apply + - -f + - "{{ .workdir }}/event-gateways.yaml" + - --auto-approve + assertions: + - select: plan.summary + expect: + fields: + total_changes: 1 + by_action.UPDATE: 1 + - select: summary + expect: + fields: + applied: 1 + failed: 0 + status: success + - select: "plan.changes[?resource_type=='event_gateway' && resource_ref=='egw-top-level'] | [0]" + expect: + fields: + action: UPDATE + fields.description: "Updated description for My Event Gateway CP" + fields.labels.owner: "platform-eng" + fields.labels.lifecycle: "production" + - name: 001-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='My Event Gateway CP'] | [0]" + expect: + fields: + name: "My Event Gateway CP" + description: "Updated description for My Event Gateway CP" + labels.owner: "platform-eng" + labels.lifecycle: "production" + labels."KONGCTL-namespace": "event-gateways-comprehensive" + - name: 003-sync-delete + inputOverlayDirs: + - overlays/003-sync-delete + commands: + - name: 000-sync-delete + run: + - sync + - -f + - "{{ .workdir }}/event-gateways.yaml" + - --auto-approve + assertions: + - select: plan.summary + expect: + fields: + total_changes: 1 + by_action.DELETE: 1 + - select: summary + expect: + fields: + applied: 1 + failed: 0 + status: success + - select: "plan.changes[?resource_type=='event_gateway' && resource_ref=='My Event Gateway CP'] | [0]" + expect: + fields: + action: DELETE + - name: 001-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='My Event Gateway CP']" + expect: + fields: + "length(@)": 0 diff --git a/test/e2e/scenarios/event-gateway/plan/apply-workflow/overlays/001-update/config.yaml b/test/e2e/scenarios/event-gateway/plan/apply-workflow/overlays/001-update/config.yaml new file mode 100644 index 00000000..19e51947 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/plan/apply-workflow/overlays/001-update/config.yaml @@ -0,0 +1,8 @@ +_defaults: + kongctl: + namespace: egw-plan-apply + +event_gateways: + - ref: plan-apply-egw + name: plan-apply-egw + description: "Updated description for plan apply workflow" diff --git a/test/e2e/scenarios/event-gateway/plan/apply-workflow/scenario.yaml b/test/e2e/scenarios/event-gateway/plan/apply-workflow/scenario.yaml new file mode 100644 index 00000000..42cc5a12 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/plan/apply-workflow/scenario.yaml @@ -0,0 +1,143 @@ +baseInputsPath: ../../../../testdata/declarative/event-gateways/plan/apply + +env: + KONGCTL_LOG_LEVEL: info + +vars: + egwRef: plan-apply-egw + egwName: plan-apply-egw + egwInitialDesc: "Initial description for plan apply workflow" + egwUpdatedDesc: "Updated description for plan apply workflow" + +defaults: + mask: + dropKeys: + - id + - created_at + - updated_at + +steps: + - name: 000-reset-org + skipInputs: true + commands: + - resetOrg: true + + - name: 001-plan-apply-create + commands: + - name: 000-plan-create + outputFormat: disable + stdoutFile: "{{ .workdir }}/plan-create.json" + run: + - plan + - -f + - "{{ .workdir }}/config.yaml" + - --mode + - apply + assertions: + - select: metadata + expect: + fields: + mode: apply + - select: summary + expect: + fields: + total_changes: 1 + by_action.CREATE: 1 + - name: 001-diff-plan-text + outputFormat: text + parseAs: raw + run: + - diff + - --plan + - "{{ .workdir }}/plan-create.json" + assertions: + - select: stdout + expect: + fields: + "contains(@, 'Plan: 1 to add')": true + "contains(@, 'event_gateway \"plan-apply-egw\" will be created')": true + - name: 002-apply-plan + outputFormat: json + run: + - apply + - --plan + - "{{ .workdir }}/plan-create.json" + - --auto-approve + assertions: + - select: plan.metadata + expect: + fields: + mode: apply + - select: summary + expect: + fields: + applied: 1 + failed: 0 + - name: 003-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.egwRef }}'] | [0]" + expect: + fields: + name: "{{ .vars.egwName }}" + description: "{{ .vars.egwInitialDesc }}" + - name: 002-plan-apply-update + inputOverlayDirs: + - overlays/001-update + commands: + - name: 000-plan-update + outputFormat: disable + stdoutFile: "{{ .workdir }}/plan-update.json" + run: + - plan + - -f + - "{{ .workdir }}/config.yaml" + - --mode + - apply + assertions: + - select: metadata + expect: + fields: + mode: apply + - select: summary + expect: + fields: + total_changes: 1 + by_action.UPDATE: 1 + - name: 001-diff-plan-text + outputFormat: text + parseAs: raw + run: + - diff + - --plan + - "{{ .workdir }}/plan-update.json" + assertions: + - select: stdout + expect: + fields: + "contains(@, 'event_gateway \"plan-apply-egw\" will be updated')": true + - name: 002-apply-plan + outputFormat: json + run: + - apply + - --plan + - "{{ .workdir }}/plan-update.json" + - --auto-approve + assertions: + - select: summary + expect: + fields: + applied: 1 + failed: 0 + - select: plan.summary + expect: + fields: + by_action.UPDATE: 1 + - name: 003-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.egwRef }}'] | [0]" + expect: + fields: + name: "{{ .vars.egwName }}" + description: "{{ .vars.egwUpdatedDesc }}" diff --git a/test/e2e/scenarios/event-gateway/plan/sync-workflow/overlays/001-update/config.yaml b/test/e2e/scenarios/event-gateway/plan/sync-workflow/overlays/001-update/config.yaml new file mode 100644 index 00000000..cd00f30f --- /dev/null +++ b/test/e2e/scenarios/event-gateway/plan/sync-workflow/overlays/001-update/config.yaml @@ -0,0 +1,8 @@ +_defaults: + kongctl: + namespace: egw-plan-sync + +event_gateways: + - ref: plan-sync-egw + name: plan-sync-egw + description: "Updated description for plan sync workflow" diff --git a/test/e2e/scenarios/event-gateway/plan/sync-workflow/scenario.yaml b/test/e2e/scenarios/event-gateway/plan/sync-workflow/scenario.yaml new file mode 100644 index 00000000..d8e6b6a3 --- /dev/null +++ b/test/e2e/scenarios/event-gateway/plan/sync-workflow/scenario.yaml @@ -0,0 +1,155 @@ +baseInputsPath: ../../../../testdata/declarative/event-gateways/plan/sync + +env: + KONGCTL_LOG_LEVEL: info + +vars: + egwRef: plan-sync-egw + egwName: plan-sync-egw + egwInitialDesc: "Initial description for plan sync workflow" + egwUpdatedDesc: "Updated description for plan sync workflow" + +defaults: + mask: + dropKeys: + - id + - created_at + - updated_at + +steps: + - name: 000-reset-org + skipInputs: true + commands: + - resetOrg: true + + - name: 001-plan-sync-create + commands: + - name: 000-plan-create + outputFormat: disable + stdoutFile: "{{ .workdir }}/plan-create.json" + run: + - plan + - -f + - "{{ .workdir }}/config.yaml" + - --mode + - sync + assertions: + - select: metadata + expect: + fields: + mode: sync + - select: summary + expect: + fields: + total_changes: 1 + by_action.CREATE: 1 + - name: 001-diff-plan-text + outputFormat: text + parseAs: raw + run: + - diff + - --plan + - "{{ .workdir }}/plan-create.json" + assertions: + - select: stdout + expect: + fields: + "contains(@, 'Plan: 1 to add')": true + "contains(@, 'event_gateway \"plan-sync-egw\" will be created')": true + - name: 002-sync-plan + outputFormat: json + run: + - sync + - --plan + - "{{ .workdir }}/plan-create.json" + - --auto-approve + assertions: + - select: plan.metadata + expect: + fields: + mode: sync + - select: summary + expect: + fields: + applied: 1 + failed: 0 + - name: 003-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.egwRef }}'] | [0]" + expect: + fields: + name: "{{ .vars.egwName }}" + description: "{{ .vars.egwInitialDesc }}" + - name: 002-plan-sync-update + inputOverlayDirs: + - overlays/001-update + commands: + - name: 000-plan-update + outputFormat: disable + stdoutFile: "{{ .workdir }}/plan-update.json" + run: + - plan + - -f + - "{{ .workdir }}/config.yaml" + - --mode + - sync + assertions: + - select: metadata + expect: + fields: + mode: sync + - select: summary + expect: + fields: + total_changes: 1 + by_action.UPDATE: 1 + - name: 001-diff-plan-text + outputFormat: text + parseAs: raw + run: + - diff + - --plan + - "{{ .workdir }}/plan-update.json" + assertions: + - select: stdout + expect: + fields: + "contains(@, 'event_gateway \"plan-sync-egw\" will be updated')": true + - name: 002-sync-plan + outputFormat: json + run: + - sync + - --plan + - "{{ .workdir }}/plan-update.json" + - --auto-approve + assertions: + - select: summary + expect: + fields: + applied: 1 + failed: 0 + - select: plan.summary + expect: + fields: + by_action.UPDATE: 1 + - name: 003-get-event-gateways + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.egwRef }}'] | [0]" + expect: + fields: + name: "{{ .vars.egwName }}" + description: "{{ .vars.egwUpdatedDesc }}" + - name: 004-diff-no-changes + outputFormat: text + parseAs: raw + run: + - diff + - -f + - "{{ .workdir }}/config.yaml" + assertions: + - select: stdout + expect: + fields: + "contains(@, 'No changes detected. Konnect is up to date.')": true diff --git a/test/e2e/scenarios/protected-resources/event-gateways/overlays/002-attempt-delete/event_gateways.yaml b/test/e2e/scenarios/protected-resources/event-gateways/overlays/002-attempt-delete/event_gateways.yaml new file mode 100644 index 00000000..b8c7c713 --- /dev/null +++ b/test/e2e/scenarios/protected-resources/event-gateways/overlays/002-attempt-delete/event_gateways.yaml @@ -0,0 +1,9 @@ +# Overlay: Remove all Event Gateway Control Planes from config +# This should trigger DELETE plans in sync mode +# Protected Event Gateway Control Planes should cause plan generation to fail + +_defaults: + kongctl: + namespace: "egw-protection" + +event_gateways: [] diff --git a/test/e2e/scenarios/protected-resources/event-gateways/overlays/003-unprotect/event_gateways.yaml b/test/e2e/scenarios/protected-resources/event-gateways/overlays/003-unprotect/event_gateways.yaml new file mode 100644 index 00000000..2dcbd96c --- /dev/null +++ b/test/e2e/scenarios/protected-resources/event-gateways/overlays/003-unprotect/event_gateways.yaml @@ -0,0 +1,17 @@ +# Overlay: Set protected: false on Customer Event Gateway Control Plane +# This should allow the protection label to be removed + +_defaults: + kongctl: + namespace: "egw-protection" + +event_gateways: + - ref: egw-protected-top-level + name: "My Protected Event Gateway CP" + description: "Initial description for My Protected Event Gateway CP" + labels: + owner: "platform" + lifecycle: "dev" + kongctl: + namespace: "egw-protection" + protected: false \ No newline at end of file diff --git a/test/e2e/scenarios/protected-resources/event-gateways/overlays/004-delete-unprotected/event_gateways.yaml b/test/e2e/scenarios/protected-resources/event-gateways/overlays/004-delete-unprotected/event_gateways.yaml new file mode 100644 index 00000000..90ea44a2 --- /dev/null +++ b/test/e2e/scenarios/protected-resources/event-gateways/overlays/004-delete-unprotected/event_gateways.yaml @@ -0,0 +1,8 @@ +# Overlay: Empty config to delete all Event Gateway Control Planes +# Now that Customer Event Gateway Control Plane is unprotected, this should succeed + +_defaults: + kongctl: + namespace: "egw-protection" + +event_gateways: [] diff --git a/test/e2e/scenarios/protected-resources/event-gateways/scenario.yaml b/test/e2e/scenarios/protected-resources/event-gateways/scenario.yaml new file mode 100644 index 00000000..97bd9771 --- /dev/null +++ b/test/e2e/scenarios/protected-resources/event-gateways/scenario.yaml @@ -0,0 +1,173 @@ +baseInputsPath: ../../../testdata/declarative/protected + +env: + KONGCTL_LOG_LEVEL: info + +vars: + protectedEventGatewayName: "My Protected Event Gateway CP" + +defaults: + mask: + dropKeys: + - id + - created_at + - updated_at + +steps: + # Step 0: Clean environment + - name: 000-reset-org + skipInputs: true + commands: + - resetOrg: true + + # Step 1: Initial sync - create protected and unprotected resources + - name: 001-initial-sync + commands: + - name: 000-sync + run: + - sync + - -f + - "{{ .workdir }}/event-gateways.yaml" + - --auto-approve + assertions: + # Verify sync mode + - select: "plan.metadata" + expect: + fields: + mode: sync + + # Verify protected Event Gateway created + - select: >- + plan.changes[?resource_type=='event_gateway' && + resource_ref=='egw-protected-top-level'] | [0] + expect: + fields: + action: CREATE + + # Verify protection changes + - select: "plan.summary.protection_changes" + expect: + fields: + protecting: 1 + unprotecting: 0 + + # Verify resources exist with proper labels + - name: 001-get-event-gateway-control-planes + run: ["get", "event-gateways", "-o", "json"] + assertions: + # Verify Protected Event Gateway CP has protected label + - select: "[?name=='{{ .vars.protectedEventGatewayName }}'] | [0]" + expect: + fields: + name: "My Protected Event Gateway CP" + labels."KONGCTL-protected": "true" + labels."KONGCTL-namespace": "egw-protection" + + # Step 2: Attempt to delete protected resource (should fail) + - name: 002-attempt-delete-protected + inputOverlayDirs: + - overlays/002-attempt-delete + commands: + - name: 000-sync-should-fail + run: + - sync + - -f + - "{{ .workdir }}/event_gateways.yaml" + - --auto-approve + expectFailure: + exitCode: 1 + contains: "protected" + + # Verify protected resource still exists + - name: 001-verify-protected-exists + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.protectedEventGatewayName }}']" + expect: + fields: + "length(@)": 1 + + # Verify it still has protected label + - select: "[?name=='{{ .vars.protectedEventGatewayName }}'] | [0]" + expect: + fields: + labels."KONGCTL-protected": "true" + + # Step 3: Unprotect the resource + - name: 003-unprotect-resource + inputOverlayDirs: + - overlays/003-unprotect + commands: + - name: 000-sync-unprotect + run: + - sync + - -f + - "{{ .workdir }}/event_gateways.yaml" + - --auto-approve + assertions: + # Should see UPDATE action for protection change + - select: >- + plan.changes[?resource_type=='event_gateway' && + resource_ref=='egw-protected-top-level'] | [0] + expect: + fields: + action: UPDATE + + # Verify execution succeeded + - select: "summary" + expect: + fields: + applied: 1 + failed: 0 + + # Verify protection changes + - select: "plan.summary.protection_changes" + expect: + fields: + protecting: 0 + unprotecting: 1 + + # Verify protected label removed + - name: 001-verify-unprotected + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.protectedEventGatewayName }}'] | [0]" + expect: + fields: + labels."KONGCTL-namespace": "egw-protection" + + # Step 4: Delete now-unprotected resource (should succeed) + - name: 004-delete-unprotected + inputOverlayDirs: + - overlays/004-delete-unprotected + commands: + - name: 000-sync-delete + run: + - sync + - -f + - "{{ .workdir }}/event_gateways.yaml" + - --auto-approve + assertions: + # Verify DELETE action planned + - select: >- + plan.changes[?resource_type=='event_gateway' && + resource_ref=='My Protected Event Gateway CP'] | [0] + expect: + fields: + action: DELETE + + # Verify execution succeeded + - select: "summary" + expect: + fields: + applied: 1 + failed: 0 + + # Verify resource deleted + - name: 001-verify-deleted + run: ["get", "event-gateways", "-o", "json"] + assertions: + - select: "[?name=='{{ .vars.protectedEventGatewayName }}']" + expect: + fields: + "length(@)": 0 diff --git a/test/e2e/testdata/declarative/event-gateways/comprehensive-fields/event-gateways.yaml b/test/e2e/testdata/declarative/event-gateways/comprehensive-fields/event-gateways.yaml new file mode 100644 index 00000000..02077da2 --- /dev/null +++ b/test/e2e/testdata/declarative/event-gateways/comprehensive-fields/event-gateways.yaml @@ -0,0 +1,10 @@ +event_gateways: + - ref: egw-top-level + name: "My Event Gateway CP" + description: "Initial description for My Event Gateway CP" + labels: + owner: "platform" + lifecycle: "dev" + kongctl: + namespace: "event-gateways-comprehensive" + protected: false diff --git a/test/e2e/testdata/declarative/event-gateways/plan/apply/config.yaml b/test/e2e/testdata/declarative/event-gateways/plan/apply/config.yaml new file mode 100644 index 00000000..2f7620a9 --- /dev/null +++ b/test/e2e/testdata/declarative/event-gateways/plan/apply/config.yaml @@ -0,0 +1,8 @@ +_defaults: + kongctl: + namespace: egw-plan-apply + +event_gateways: + - ref: plan-apply-egw + name: plan-apply-egw + description: "Initial description for plan apply workflow" diff --git a/test/e2e/testdata/declarative/event-gateways/plan/sync/config.yaml b/test/e2e/testdata/declarative/event-gateways/plan/sync/config.yaml new file mode 100644 index 00000000..15313837 --- /dev/null +++ b/test/e2e/testdata/declarative/event-gateways/plan/sync/config.yaml @@ -0,0 +1,8 @@ +_defaults: + kongctl: + namespace: egw-plan-sync + +event_gateways: + - ref: plan-sync-egw + name: plan-sync-egw + description: "Initial description for plan sync workflow" \ No newline at end of file diff --git a/test/e2e/testdata/declarative/protected/event-gateways.yaml b/test/e2e/testdata/declarative/protected/event-gateways.yaml new file mode 100644 index 00000000..645785b7 --- /dev/null +++ b/test/e2e/testdata/declarative/protected/event-gateways.yaml @@ -0,0 +1,10 @@ +event_gateways: + - ref: egw-protected-top-level + name: "My Protected Event Gateway CP" + description: "Initial description for My Protected Event Gateway CP" + labels: + owner: "platform" + lifecycle: "dev" + kongctl: + namespace: "egw-protection" + protected: true