From 73592b5326b77614cb204a688f0e5e28e6dbf835 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Fri, 23 Oct 2026 13:46:36 +0200 Subject: [PATCH] feat(dns): Onboard move-zone commands STACKITCLI-41 --- docs/stackit_dns_zone.md | 4 + docs/stackit_dns_zone_delete-move-code.md | 40 +++ docs/stackit_dns_zone_generate-move-code.md | 45 +++ docs/stackit_dns_zone_move.md | 52 ++++ docs/stackit_dns_zone_validate-move-code.md | 44 +++ .../zone/delete-move-code/delete_move_code.go | 109 ++++++++ .../delete-move-code/delete_move_code_test.go | 195 +++++++++++++ .../generate-move-code/generate_move_code.go | 142 ++++++++++ .../generate_move_code_test.go | 256 ++++++++++++++++++ internal/cmd/dns/zone/move/move.go | 180 ++++++++++++ internal/cmd/dns/zone/move/move_test.go | 251 +++++++++++++++++ .../validate-move-code/validate_move_code.go | 129 +++++++++ .../validate_move_code_test.go | 232 ++++++++++++++++ internal/cmd/dns/zone/zone.go | 8 + internal/pkg/services/dns/utils/utils.go | 14 + internal/pkg/services/dns/utils/utils_test.go | 76 ++++++ 16 files changed, 1777 insertions(+) create mode 100644 docs/stackit_dns_zone_delete-move-code.md create mode 100644 docs/stackit_dns_zone_generate-move-code.md create mode 100644 docs/stackit_dns_zone_move.md create mode 100644 docs/stackit_dns_zone_validate-move-code.md create mode 100644 internal/cmd/dns/zone/delete-move-code/delete_move_code.go create mode 100644 internal/cmd/dns/zone/delete-move-code/delete_move_code_test.go create mode 100644 internal/cmd/dns/zone/generate-move-code/generate_move_code.go create mode 100644 internal/cmd/dns/zone/generate-move-code/generate_move_code_test.go create mode 100644 internal/cmd/dns/zone/move/move.go create mode 100644 internal/cmd/dns/zone/move/move_test.go create mode 100644 internal/cmd/dns/zone/validate-move-code/validate_move_code.go create mode 100644 internal/cmd/dns/zone/validate-move-code/validate_move_code_test.go diff --git a/docs/stackit_dns_zone.md b/docs/stackit_dns_zone.md index 4693699e7..7988ec84b 100644 --- a/docs/stackit_dns_zone.md +++ b/docs/stackit_dns_zone.md @@ -33,7 +33,11 @@ stackit dns zone [flags] * [stackit dns zone clone](./stackit_dns_zone_clone.md) - Clones a DNS zone * [stackit dns zone create](./stackit_dns_zone_create.md) - Creates a DNS zone * [stackit dns zone delete](./stackit_dns_zone_delete.md) - Deletes a DNS zone +* [stackit dns zone delete-move-code](./stackit_dns_zone_delete-move-code.md) - Deletes the move code of a DNS zone * [stackit dns zone describe](./stackit_dns_zone_describe.md) - Shows details of a DNS zone +* [stackit dns zone generate-move-code](./stackit_dns_zone_generate-move-code.md) - Generates a move code for a DNS zone * [stackit dns zone list](./stackit_dns_zone_list.md) - Lists DNS zones +* [stackit dns zone move](./stackit_dns_zone_move.md) - Moves a DNS zone to another project * [stackit dns zone update](./stackit_dns_zone_update.md) - Updates a DNS zone +* [stackit dns zone validate-move-code](./stackit_dns_zone_validate-move-code.md) - Validates the move code of a DNS zone diff --git a/docs/stackit_dns_zone_delete-move-code.md b/docs/stackit_dns_zone_delete-move-code.md new file mode 100644 index 000000000..6a2fd0800 --- /dev/null +++ b/docs/stackit_dns_zone_delete-move-code.md @@ -0,0 +1,40 @@ +## stackit dns zone delete-move-code + +Deletes the move code of a DNS zone + +### Synopsis + +Deletes (invalidates) the move code of a DNS zone, so it can no longer be used to move the zone to another project. + +``` +stackit dns zone delete-move-code ZONE_ID [flags] +``` + +### Examples + +``` + Delete the move code of a DNS zone with ID "xxx" + $ stackit dns zone delete-move-code xxx +``` + +### Options + +``` + -h, --help Help for "stackit dns zone delete-move-code" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_generate-move-code.md b/docs/stackit_dns_zone_generate-move-code.md new file mode 100644 index 000000000..3669cb646 --- /dev/null +++ b/docs/stackit_dns_zone_generate-move-code.md @@ -0,0 +1,45 @@ +## stackit dns zone generate-move-code + +Generates a move code for a DNS zone + +### Synopsis + +Generates a move code for a DNS zone, which is required to move the zone to another project. +The code is only shown once upon creation, so make sure to save it. By default, the code is valid for 30 days. + +``` +stackit dns zone generate-move-code ZONE_ID [flags] +``` + +### Examples + +``` + Generate a move code for a DNS zone with ID "xxx" + $ stackit dns zone generate-move-code xxx + + Generate a move code for a DNS zone with ID "xxx" that is valid for one hour + $ stackit dns zone generate-move-code xxx --ttl 3600 +``` + +### Options + +``` + -h, --help Help for "stackit dns zone generate-move-code" + --ttl int32 Validity duration of the move code in seconds (min 1, max 2592000 / 30 days). If not set, the API default is used +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_move.md b/docs/stackit_dns_zone_move.md new file mode 100644 index 000000000..6223979c9 --- /dev/null +++ b/docs/stackit_dns_zone_move.md @@ -0,0 +1,52 @@ +## stackit dns zone move + +Moves a DNS zone to another project + +### Synopsis + +Moves a DNS zone to another project. +The API call this command makes is always performed against the destination project (--target-project-id). Moving requires an authorizing move code for the zone, which can be obtained in one of two ways: + - Generated automatically by this command: omit --code. The configured project is then used to look up the zone and must be its current project. The generated move code is valid for 1 hour. + - Generated beforehand, by yourself or by whoever controls the zone's current project, via "stackit dns zone generate-move-code" (and optionally checked with "stackit dns zone validate-move-code"), then passed explicitly via --code. The configured project isn't used for the move itself in this case. + +``` +stackit dns zone move [flags] +``` + +### Examples + +``` + Move a DNS zone with domain name "www.my-zone.com" to project "xxx", automatically generating a move code against the zone's current project + $ stackit dns zone move --zone-domain-name www.my-zone.com --target-project-id xxx + + Move a DNS zone with domain name "www.my-zone.com" from project "yyy" to project "xxx", automatically generating a move code. --project-id explicitly sets the source project, i.e. the zone's current project + $ stackit dns zone move --project-id yyy --zone-domain-name www.my-zone.com --target-project-id xxx + + Move a DNS zone with domain name "www.my-zone.com" to project "xxx" using a move code generated beforehand (e.g. via "stackit dns zone generate-move-code") + $ stackit dns zone move --zone-domain-name www.my-zone.com --target-project-id xxx --code @./move-code.txt +``` + +### Options + +``` + --code secret Code. Use @ to read from a file (example: @./secret.txt), or - to read from stdin (hidden input on a terminal). Literal values are not supported. If not set, a move code is generated automatically for the zone in the configured project, which must then be the zone's current project. + -h, --help Help for "stackit dns zone move" + --target-project-id string ID of the project the zone should be moved to + --zone-domain-name string Domain name of the zone to move +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_validate-move-code.md b/docs/stackit_dns_zone_validate-move-code.md new file mode 100644 index 000000000..6036a953a --- /dev/null +++ b/docs/stackit_dns_zone_validate-move-code.md @@ -0,0 +1,44 @@ +## stackit dns zone validate-move-code + +Validates the move code of a DNS zone + +### Synopsis + +Validates the move code of a DNS zone. Checks that the provided code is valid, not expired, and belongs to the given zone. + +``` +stackit dns zone validate-move-code ZONE_ID [flags] +``` + +### Examples + +``` + Validate the move code stored in "move-code.txt" of a DNS zone with ID "xxx" + $ stackit dns zone validate-move-code xxx --code @./move-code.txt + + Validate a move code read from stdin for a DNS zone with ID "xxx" + $ echo yyy | stackit dns zone validate-move-code xxx --code - +``` + +### Options + +``` + --code secret Code. Use @ to read from a file (example: @./secret.txt), or - to read from stdin (hidden input on a terminal). Literal values are not supported. + -h, --help Help for "stackit dns zone validate-move-code" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/internal/cmd/dns/zone/delete-move-code/delete_move_code.go b/internal/cmd/dns/zone/delete-move-code/delete_move_code.go new file mode 100644 index 000000000..1ff0c9737 --- /dev/null +++ b/internal/cmd/dns/zone/delete-move-code/delete_move_code.go @@ -0,0 +1,109 @@ +package deletemovecode + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client" + dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +const ( + zoneIdArg = "ZONE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete-move-code %s", zoneIdArg), + Short: "Deletes the move code of a DNS zone", + Long: "Deletes (invalidates) the move code of a DNS zone, so it can no longer be used to move the zone to another project.", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete the move code of a DNS zone with ID "xxx"`, + "$ stackit dns zone delete-move-code xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient.DefaultAPI, model.ProjectId, model.ZoneId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) + zoneLabel = model.ZoneId + } + prompt := fmt.Sprintf("Are you sure you want to delete the move code of zone %q? (This cannot be undone)", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("delete DNS zone move code: %w", err) + } + + return outputResult(params.Printer, model, zoneLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiDeleteMoveCodeRequest { + req := apiClient.DefaultAPI.DeleteMoveCode(ctx, model.ProjectId, model.ZoneId) + return req +} + +func outputResult(p *print.Printer, model *inputModel, zoneLabel string, resp *dns.Message) error { + if resp == nil { + return fmt.Errorf("move code deletion response is empty") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + p.Outputf("Deleted move code of zone %s\n", zoneLabel) + return nil + }) +} diff --git a/internal/cmd/dns/zone/delete-move-code/delete_move_code_test.go b/internal/cmd/dns/zone/delete-move-code/delete_move_code_test.go new file mode 100644 index 000000000..278c6fac2 --- /dev/null +++ b/internal/cmd/dns/zone/delete-move-code/delete_move_code_test.go @@ -0,0 +1,195 @@ +package deletemovecode + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +type testCtxKey struct{} + +const testZoneLabel = "my-zone" + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{DefaultAPI: &dns.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ZoneId: testZoneId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiDeleteMoveCodeRequest)) dns.ApiDeleteMoveCodeRequest { + request := testClient.DefaultAPI.DeleteMoveCode(testCtx, testProjectId, testZoneId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiDeleteMoveCodeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, dns.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + zoneLabel string + resp *dns.Message + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "base", + args: args{ + model: fixtureInputModel(), + zoneLabel: testZoneLabel, + resp: &dns.Message{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.zoneLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/dns/zone/generate-move-code/generate_move_code.go b/internal/cmd/dns/zone/generate-move-code/generate_move_code.go new file mode 100644 index 000000000..9cc1b1815 --- /dev/null +++ b/internal/cmd/dns/zone/generate-move-code/generate_move_code.go @@ -0,0 +1,142 @@ +package generatemovecode + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client" + dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +const ( + zoneIdArg = "ZONE_ID" + + ttlFlag = "ttl" + + ttlMin = 1 + ttlMax = 2592000 // 30 days +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + Ttl *int32 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("generate-move-code %s", zoneIdArg), + Short: "Generates a move code for a DNS zone", + Long: "Generates a move code for a DNS zone, which is required to move the zone to another project.\nThe code is only shown once upon creation, so make sure to save it. By default, the code is valid for 30 days.", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Generate a move code for a DNS zone with ID "xxx"`, + "$ stackit dns zone generate-move-code xxx"), + examples.NewExample( + `Generate a move code for a DNS zone with ID "xxx" that is valid for one hour`, + "$ stackit dns zone generate-move-code xxx --ttl 3600"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient.DefaultAPI, model.ProjectId, model.ZoneId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) + zoneLabel = model.ZoneId + } + + prompt := fmt.Sprintf("Are you sure you want to generate a move code for zone %q?", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("generate DNS zone move code: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int32(ttlFlag, 0, "Validity duration of the move code in seconds (min 1, max 2592000 / 30 days). If not set, the API default is used") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + ttl := flags.FlagToInt32Pointer(p, cmd, ttlFlag) + if ttl != nil && (*ttl < ttlMin || *ttl > ttlMax) { + return nil, &errors.FlagValidationError{ + Flag: ttlFlag, + Details: fmt.Sprintf("must be between %d and %d", ttlMin, ttlMax), + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + Ttl: ttl, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiCreateMoveCodeRequest { + req := apiClient.DefaultAPI.CreateMoveCode(ctx, model.ProjectId, model.ZoneId) + if model.Ttl != nil { + req = req.CreateMoveCodePayload(dns.CreateMoveCodePayload{ + Ttl: model.Ttl, + }) + } + return req +} + +func outputResult(p *print.Printer, outputFormat string, resp *dns.MoveCodeResponse) error { + if resp == nil { + return fmt.Errorf("move code response is empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Generated move code. It is only shown once, so make sure to save it.\n") + p.Outputf("Code: %s\n", resp.Code) + p.Outputf("Expires at: %s\n", resp.ExpiresAt) + return nil + }) +} diff --git a/internal/cmd/dns/zone/generate-move-code/generate_move_code_test.go b/internal/cmd/dns/zone/generate-move-code/generate_move_code_test.go new file mode 100644 index 000000000..11378631e --- /dev/null +++ b/internal/cmd/dns/zone/generate-move-code/generate_move_code_test.go @@ -0,0 +1,256 @@ +package generatemovecode + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{DefaultAPI: &dns.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ZoneId: testZoneId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiCreateMoveCodeRequest)) dns.ApiCreateMoveCodeRequest { + request := testClient.DefaultAPI.CreateMoveCode(testCtx, testProjectId, testZoneId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with ttl", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "3600" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Ttl = utils.Ptr(int32(3600)) + }), + }, + { + description: "ttl min", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "1" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Ttl = utils.Ptr(int32(1)) + }), + }, + { + description: "ttl max", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "2592000" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Ttl = utils.Ptr(int32(2592000)) + }), + }, + { + description: "ttl zero", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "0" + }), + isValid: false, + }, + { + description: "ttl negative", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "-1" + }), + isValid: false, + }, + { + description: "ttl above max", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "2592001" + }), + isValid: false, + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiCreateMoveCodeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with ttl", + model: fixtureInputModel(func(model *inputModel) { + model.Ttl = utils.Ptr(int32(3600)) + }), + expectedRequest: fixtureRequest(func(request *dns.ApiCreateMoveCodeRequest) { + *request = request.CreateMoveCodePayload(dns.CreateMoveCodePayload{ + Ttl: utils.Ptr(int32(3600)), + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, dns.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp *dns.MoveCodeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "base", + args: args{ + resp: &dns.MoveCodeResponse{ + Code: "some-code", + ExpiresAt: "2023-08-08 08:58:25+00:00", + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/dns/zone/move/move.go b/internal/cmd/dns/zone/move/move.go new file mode 100644 index 000000000..ece0929be --- /dev/null +++ b/internal/cmd/dns/zone/move/move.go @@ -0,0 +1,180 @@ +package move + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client" + dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +const ( + zoneDomainNameFlag = "zone-domain-name" + targetProjectIdFlag = "target-project-id" + codeFlag = "code" + + // The generated move code by this command should not be valid for 30 days. + generatedMoveCodeTtl int32 = 3600 // 1 hour +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneDomainName string + TargetProjectId string + Code *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "move", + Short: "Moves a DNS zone to another project", + Long: "Moves a DNS zone to another project.\n" + + "The API call this command makes is always performed against the destination project (--target-project-id). Moving requires an authorizing move code for the zone, which can be obtained in one of two ways:\n" + + " - Generated automatically by this command: omit --code. The configured project is then used to look up the zone and must be its current project. The generated move code is valid for 1 hour.\n" + + " - Generated beforehand, by yourself or by whoever controls the zone's current project, via \"stackit dns zone generate-move-code\" (and optionally checked with \"stackit dns zone validate-move-code\"), then passed explicitly via --code." + + " The configured project isn't used for the move itself in this case.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Move a DNS zone with domain name "www.my-zone.com" to project "xxx", automatically generating a move code against the zone's current project`, + "$ stackit dns zone move --zone-domain-name www.my-zone.com --target-project-id xxx"), + examples.NewExample( + `Move a DNS zone with domain name "www.my-zone.com" from project "yyy" to project "xxx", automatically generating a move code. --project-id explicitly sets the source project, i.e. the zone's current project`, + "$ stackit dns zone move --project-id yyy --zone-domain-name www.my-zone.com --target-project-id xxx"), + examples.NewExample( + `Move a DNS zone with domain name "www.my-zone.com" to project "xxx" using a move code generated beforehand (e.g. via "stackit dns zone generate-move-code")`, + "$ stackit dns zone move --zone-domain-name www.my-zone.com --target-project-id xxx --code @./move-code.txt"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to move the zone with domain name %q to project %q?", model.ZoneDomainName, model.TargetProjectId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + code := model.Code + if code == nil { + generatedCode, err := generateMoveCode(ctx, params.Printer, apiClient, model) + if err != nil { + return err + } + code = &generatedCode + } + + // Call API + req := buildRequest(ctx, model, *code, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("move DNS zone: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + configureFlags(cmd, params) + return cmd +} + +func generateMoveCode(ctx context.Context, p *print.Printer, apiClient *dns.APIClient, model *inputModel) (string, error) { + zoneId, err := dnsUtils.GetZoneIdByDnsName(ctx, apiClient.DefaultAPI, model.ProjectId, model.ZoneDomainName) + if err != nil { + return "", fmt.Errorf("find DNS zone by domain name: %w", err) + } + + createResp, err := apiClient.DefaultAPI.CreateMoveCode(ctx, model.ProjectId, zoneId).CreateMoveCodePayload(dns.CreateMoveCodePayload{ + Ttl: utils.Ptr(generatedMoveCodeTtl), + }).Execute() + if err != nil { + return "", fmt.Errorf("generate DNS zone move code: %w", err) + } + + p.Debug(print.DebugLevel, "generated move code for zone %q", zoneId) + return createResp.Code, nil +} + +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { + cmd.Flags().String(zoneDomainNameFlag, "", "Domain name of the zone to move") + cmd.Flags().Var(flags.UUIDFlag(), targetProjectIdFlag, "ID of the project the zone should be moved to") + + code := flags.SecretFlag(codeFlag, params) + cmd.Flags().Var(code, codeFlag, fmt.Sprintf("%s If not set, a move code is generated automatically for the zone in the configured project, which must then be the zone's current project.", code.Usage())) + + err := flags.MarkFlagsRequired(cmd, zoneDomainNameFlag, targetProjectIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + // --code is optional: if omitted, ResolveSecretFlag returns nil without + // prompting, and a code is generated automatically instead (see + // generateMoveCode). + code, err := flags.ResolveSecretFlag(p, cmd, codeFlag) + if err != nil { + return nil, err + } + if code != nil { + // Move codes never contain surrounding whitespace, but files written + // by editors usually end with a trailing newline. + trimmed := strings.TrimSpace(*code) + code = &trimmed + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ZoneDomainName: flags.FlagToStringValue(p, cmd, zoneDomainNameFlag), + TargetProjectId: flags.FlagToStringValue(p, cmd, targetProjectIdFlag), + Code: code, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, code string, apiClient *dns.APIClient) dns.ApiMoveZoneRequest { + req := apiClient.DefaultAPI.MoveZone(ctx, model.TargetProjectId) + req = req.MoveZonePayload(dns.MoveZonePayload{ + Code: code, + ZoneDnsName: model.ZoneDomainName, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, resp *dns.Message) error { + if resp == nil { + return fmt.Errorf("move DNS zone response is empty") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + p.Outputf("Moved zone with domain name %q to project %q\n", model.ZoneDomainName, model.TargetProjectId) + return nil + }) +} diff --git a/internal/cmd/dns/zone/move/move_test.go b/internal/cmd/dns/zone/move/move_test.go new file mode 100644 index 000000000..e34f95ddd --- /dev/null +++ b/internal/cmd/dns/zone/move/move_test.go @@ -0,0 +1,251 @@ +package move + +import ( + "context" + "testing" + "testing/fstest" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testDnsName = "www.example.com" + testCode = "some-move-code" + testCodeArg = "@" + testCodeFile + testCodeFile = "move-code.txt" + testCodeFileTrailingNewline = "move-code-newline.txt" +) + +func cmdFactory(params *types.CmdParams) *cobra.Command { + params.Fs = fstest.MapFS{ + testCodeFile: {Data: []byte(testCode)}, + testCodeFileTrailingNewline: {Data: []byte(testCode + "\n")}, + } + return NewCmd(params) +} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{DefaultAPI: &dns.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testTargetProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + zoneDomainNameFlag: testDnsName, + targetProjectIdFlag: testTargetProjectId, + codeFlag: testCodeArg, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ZoneDomainName: testDnsName, + TargetProjectId: testTargetProjectId, + Code: utils.Ptr(testCode), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiMoveZoneRequest)) dns.ApiMoveZoneRequest { + request := testClient.DefaultAPI.MoveZone(testCtx, testTargetProjectId) + request = request.MoveZonePayload(dns.MoveZonePayload{ + Code: testCode, + ZoneDnsName: testDnsName, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "without code", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, codeFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Code = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "zone domain name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, zoneDomainNameFlag) + }), + isValid: false, + }, + { + description: "code file with trailing newline", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[codeFlag] = "@" + testCodeFileTrailingNewline + }), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "target project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetProjectIdFlag) + }), + isValid: false, + }, + { + description: "target project id missing without code", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetProjectIdFlag) + delete(flagValues, codeFlag) + }), + isValid: false, + }, + { + description: "target project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[targetProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id missing but code given", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id and code missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + delete(flagValues, codeFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "literal code rejected", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[codeFlag] = testCode + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, cmdFactory, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + code string + expectedRequest dns.ApiMoveZoneRequest + }{ + { + description: "base", + model: fixtureInputModel(), + code: testCode, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, tt.code, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, dns.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + resp *dns.Message + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "base", + args: args{ + model: fixtureInputModel(), + resp: &dns.Message{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/dns/zone/validate-move-code/validate_move_code.go b/internal/cmd/dns/zone/validate-move-code/validate_move_code.go new file mode 100644 index 000000000..54a589eff --- /dev/null +++ b/internal/cmd/dns/zone/validate-move-code/validate_move_code.go @@ -0,0 +1,129 @@ +package validatemovecode + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +const ( + zoneIdArg = "ZONE_ID" + + codeFlag = "code" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + Code string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("validate-move-code %s", zoneIdArg), + Short: "Validates the move code of a DNS zone", + Long: "Validates the move code of a DNS zone. Checks that the provided code is valid, not expired, and belongs to the given zone.", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Validate the move code stored in "move-code.txt" of a DNS zone with ID "xxx"`, + "$ stackit dns zone validate-move-code xxx --code @./move-code.txt"), + examples.NewExample( + `Validate a move code read from stdin for a DNS zone with ID "xxx"`, + "$ echo yyy | stackit dns zone validate-move-code xxx --code -"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("validate DNS zone move code: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd, params) + return cmd +} + +func configureFlags(cmd *cobra.Command, params *types.CmdParams) { + code := flags.SecretFlag(codeFlag, params) + cmd.Flags().Var(code, codeFlag, code.Usage()) + + err := flags.MarkFlagsRequired(cmd, codeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + code, err := flags.ResolveSecretFlag(p, cmd, codeFlag) + if err != nil { + return nil, err + } + if code == nil { + return nil, fmt.Errorf("flag --%s is required", codeFlag) + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + // Move codes never contain surrounding whitespace, but files written + // by editors usually end with a trailing newline. + Code: strings.TrimSpace(*code), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiValidateMoveCodeRequest { + req := apiClient.DefaultAPI.ValidateMoveCode(ctx, model.ProjectId, model.ZoneId) + req = req.ValidateMoveCodePayload(dns.ValidateMoveCodePayload{ + Code: model.Code, + }) + return req +} + +func outputResult(p *print.Printer, outputFormat string, resp *dns.Message) error { + if resp == nil { + return fmt.Errorf("move code validation response is empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("The move code is valid.\n") + return nil + }) +} diff --git a/internal/cmd/dns/zone/validate-move-code/validate_move_code_test.go b/internal/cmd/dns/zone/validate-move-code/validate_move_code_test.go new file mode 100644 index 000000000..3304e4381 --- /dev/null +++ b/internal/cmd/dns/zone/validate-move-code/validate_move_code_test.go @@ -0,0 +1,232 @@ +package validatemovecode + +import ( + "context" + "testing" + "testing/fstest" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +const ( + testCode = "some-move-code" + testCodeFile = "move-code.txt" + testCodeFileTrailingNewline = "move-code-newline.txt" + testCodeArg = "@" + testCodeFile +) + +func cmdFactory(params *types.CmdParams) *cobra.Command { + params.Fs = fstest.MapFS{ + testCodeFile: {Data: []byte(testCode)}, + testCodeFileTrailingNewline: {Data: []byte(testCode + "\n")}, + } + return NewCmd(params) +} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{DefaultAPI: &dns.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + codeFlag: testCodeArg, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + ZoneId: testZoneId, + Code: testCode, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiValidateMoveCodeRequest)) dns.ApiValidateMoveCodeRequest { + request := testClient.DefaultAPI.ValidateMoveCode(testCtx, testProjectId, testZoneId) + request = request.ValidateMoveCodePayload(dns.ValidateMoveCodePayload{ + Code: testCode, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "code file with trailing newline", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[codeFlag] = "@" + testCodeFileTrailingNewline + }), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "code missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, codeFlag) + }), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "literal code rejected", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[codeFlag] = testCode + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, cmdFactory, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiValidateMoveCodeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, dns.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp *dns.Message + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "base", + args: args{ + resp: &dns.Message{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/dns/zone/zone.go b/internal/cmd/dns/zone/zone.go index ecfb1240a..ab276e88d 100644 --- a/internal/cmd/dns/zone/zone.go +++ b/internal/cmd/dns/zone/zone.go @@ -4,9 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/clone" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/create" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/delete" + deletemovecode "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/delete-move-code" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/describe" + generatemovecode "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/generate-move-code" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/move" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/update" + validatemovecode "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/validate-move-code" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -33,4 +37,8 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(update.NewCmd(params)) cmd.AddCommand(delete.NewCmd(params)) cmd.AddCommand(clone.NewCmd(params)) + cmd.AddCommand(generatemovecode.NewCmd(params)) + cmd.AddCommand(move.NewCmd(params)) + cmd.AddCommand(validatemovecode.NewCmd(params)) + cmd.AddCommand(deletemovecode.NewCmd(params)) } diff --git a/internal/pkg/services/dns/utils/utils.go b/internal/pkg/services/dns/utils/utils.go index f58f9c453..f8360d1ed 100644 --- a/internal/pkg/services/dns/utils/utils.go +++ b/internal/pkg/services/dns/utils/utils.go @@ -18,6 +18,20 @@ func GetZoneName(ctx context.Context, apiClient dns.DefaultAPI, projectId, zoneI return resp.Zone.Name, nil } +func GetZoneIdByDnsName(ctx context.Context, apiClient dns.DefaultAPI, projectId, dnsName string) (string, error) { + resp, err := apiClient.ListZones(ctx, projectId).DnsNameEq(dnsName).Execute() + if err != nil { + return "", fmt.Errorf("list DNS zones: %w", err) + } + if resp == nil || len(resp.Zones) == 0 { + return "", fmt.Errorf("no DNS zone found with DNS name %q in project %q", dnsName, projectId) + } + if len(resp.Zones) > 1 { + return "", fmt.Errorf("multiple DNS zones found with DNS name %q in project %q", dnsName, projectId) + } + return resp.Zones[0].Id, nil +} + func GetRecordSetName(ctx context.Context, apiClient dns.DefaultAPI, projectId, zoneId, recordSetId string) (string, error) { resp, err := apiClient.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute() if err != nil { diff --git a/internal/pkg/services/dns/utils/utils_test.go b/internal/pkg/services/dns/utils/utils_test.go index 0330ce992..1b5df5426 100644 --- a/internal/pkg/services/dns/utils/utils_test.go +++ b/internal/pkg/services/dns/utils/utils_test.go @@ -33,6 +33,8 @@ type mockSettings struct { getZoneResp *dns.ZoneResponse getRecordSetFails bool getRecordSetResp *dns.RecordSetResponse + listZonesFails bool + listZonesResp *dns.ListZonesResponse } func newAPIMock(settings *mockSettings) dns.DefaultAPI { @@ -51,6 +53,13 @@ func newAPIMock(settings *mockSettings) dns.DefaultAPI { return settings.getRecordSetResp, nil }), + ListZonesExecuteMock: utils.Ptr(func(_ dns.ApiListZonesRequest) (*dns.ListZonesResponse, error) { + if settings.listZonesFails { + return nil, fmt.Errorf("could not list zones") + } + + return settings.listZonesResp, nil + }), } } @@ -104,6 +113,73 @@ func TestGetZoneName(t *testing.T) { } } +func TestGetZoneIdByDnsName(t *testing.T) { + tests := []struct { + description string + listZonesFails bool + listZonesResp *dns.ListZonesResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + listZonesResp: &dns.ListZonesResponse{ + Zones: []dns.Zone{ + {Id: testZoneId}, + }, + }, + isValid: true, + expectedOutput: testZoneId, + }, + { + description: "list zones fails", + listZonesFails: true, + isValid: false, + }, + { + description: "no zone found", + listZonesResp: &dns.ListZonesResponse{ + Zones: []dns.Zone{}, + }, + isValid: false, + }, + { + description: "multiple zones found", + listZonesResp: &dns.ListZonesResponse{ + Zones: []dns.Zone{ + {Id: testZoneId}, + {Id: uuid.NewString()}, + }, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := newAPIMock(&mockSettings{ + listZonesFails: tt.listZonesFails, + listZonesResp: tt.listZonesResp, + }) + + output, err := GetZoneIdByDnsName(context.Background(), client, testProjectId, testZoneName) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + func TestGetRecordSetName(t *testing.T) { tests := []struct { description string