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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/declarative-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,34 @@ Get JSON output for scripting:
kongctl diff -f config.yaml --format json
```

### adopt

Add namespace labels to existing Konnect resources without modifying any other fields. This is useful when you
want to bring previously created assets under kongctl management:

Adopt a portal by name:
```shell
kongctl adopt portal my-portal --namespace team-alpha
```

Adopt a control plane by ID:
```shell
kongctl adopt control-plane 22cd8a0b-72e7-4212-9099-0764f8e9c5ac --namespace platform
```

Adopt an API by name:
```shell
kongctl adopt api payments --namespace team-alpha
```

Adopt an application auth strategy by name:
```shell
kongctl adopt auth-strategy key-auth --namespace shared
```

If the resource already has a `KONGCTL-namespace` label, the command fails without making changes. Only the
namespace label is applied; protection flags and other metadata remain untouched.

**Use cases**:
- Quick visual review of pending changes
- Validating configuration before creating a plan
Expand Down
25 changes: 25 additions & 0 deletions internal/cmd/root/products/konnect/adopt/adopt_test_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package adopt

import (
"github.com/kong/kongctl/internal/config"
"github.com/spf13/pflag"
)

type stubConfig struct {
pageSize int
}

func (s stubConfig) Save() error { return nil }
func (s stubConfig) GetString(string) string { return "" }
func (s stubConfig) GetBool(string) bool { return false }
func (s stubConfig) GetInt(string) int { return s.pageSize }
func (s stubConfig) GetIntOrElse(_ string, orElse int) int { return orElse }
func (s stubConfig) GetStringSlice(string) []string { return nil }
func (s stubConfig) SetString(string, string) {}
func (s stubConfig) Set(string, any) {}
func (s stubConfig) Get(string) any { return nil }
func (s stubConfig) BindFlag(string, *pflag.Flag) error { return nil }
func (s stubConfig) GetProfile() string { return "default" }
func (s stubConfig) GetPath() string { return "" }

var _ config.Hook = stubConfig{}
233 changes: 233 additions & 0 deletions internal/cmd/root/products/konnect/adopt/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package adopt

import (
"fmt"
"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 NewAPICmd(
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 = "api <api-id|api-name>"
cmd.Short = "Adopt an existing Konnect API into namespace management"
cmd.Long = "Apply the KONGCTL-namespace label to an existing Konnect API " +
"that is not currently managed by kongctl."
cmd.Args = func(_ *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("exactly one API identifier (name or ID) is required")
}
if trimmed := strings.TrimSpace(args[0]); trimmed == "" {
return fmt.Errorf("API 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 := adoptAPI(helper, sdk.GetAPIAPI(), 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 API %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 adoptAPI(
helper cmdpkg.Helper,
apiClient helpers.APIAPI,
cfg config.Hook,
namespace string,
identifier string,
) (*adoptResult, error) {
api, err := resolveAPI(helper, apiClient, cfg, identifier)
if err != nil {
return nil, err
}

if existing := api.Labels; existing != nil {
if currentNamespace, ok := existing[labels.NamespaceKey]; ok && currentNamespace != "" {
return nil, &cmdpkg.ConfigurationError{
Err: fmt.Errorf("API %q already has namespace label %q", api.Name, currentNamespace),
}
}
}

updateReq := kkComps.UpdateAPIRequest{
Labels: pointerLabelMap(api.Labels, namespace),
}

ctx := ensureContext(helper.GetContext())

resp, err := apiClient.UpdateAPI(ctx, api.ID, updateReq)
if err != nil {
attrs := cmdpkg.TryConvertErrorToAttrs(err)
return nil, cmdpkg.PrepareExecutionError("failed to update API", err, helper.GetCmd(), attrs...)
}

updated := resp.APIResponseSchema
if updated == nil {
return nil, fmt.Errorf("update API response missing data")
}

ns := namespace
if updated.Labels != nil {
if v, ok := updated.Labels[labels.NamespaceKey]; ok && v != "" {
ns = v
}
}

return &adoptResult{
ResourceType: "api",
ID: updated.ID,
Name: updated.Name,
Namespace: ns,
}, nil
}

func resolveAPI(
helper cmdpkg.Helper,
apiClient helpers.APIAPI,
cfg config.Hook,
identifier string,
) (*kkComps.APIResponseSchema, error) {
ctx := ensureContext(helper.GetContext())

if util.IsValidUUID(identifier) {
res, err := apiClient.FetchAPI(ctx, identifier)
if err != nil {
attrs := cmdpkg.TryConvertErrorToAttrs(err)
return nil, cmdpkg.PrepareExecutionError("failed to retrieve API", err, helper.GetCmd(), attrs...)
}
api := res.GetAPIResponseSchema()
if api == nil {
return nil, fmt.Errorf("API %s not found", identifier)
}
return api, nil
}

pageSize := cfg.GetInt(common.RequestPageSizeConfigPath)
if pageSize < 1 {
pageSize = common.DefaultRequestPageSize
}

var pageNumber int64 = 1
for {
req := kkOps.ListApisRequest{
PageSize: kk.Int64(int64(pageSize)),
PageNumber: kk.Int64(pageNumber),
}

res, err := apiClient.ListApis(ctx, req)
if err != nil {
attrs := cmdpkg.TryConvertErrorToAttrs(err)
return nil, cmdpkg.PrepareExecutionError("failed to list APIs", err, helper.GetCmd(), attrs...)
}

list := res.ListAPIResponse
if list == nil || len(list.Data) == 0 {
break
}

for _, api := range list.Data {
if api.Name == identifier {
apiCopy := api
return &apiCopy, nil
}
}

if len(list.Data) < pageSize {
break
}
pageNumber++
}

return nil, &cmdpkg.ConfigurationError{
Err: fmt.Errorf("API %q not found", identifier),
}
}
Loading
Loading