mygithub.libinneed.workers.dev/stackitcloud/stackit-cli
Advanced tools
| ## stackit auth logout | ||
| Logs the user account out of the STACKIT CLI | ||
| ### Synopsis | ||
| Logs the user account out of the STACKIT CLI. | ||
| ``` | ||
| stackit auth logout [flags] | ||
| ``` | ||
| ### Examples | ||
| ``` | ||
| Log out of the STACKIT CLI. | ||
| $ stackit auth logout | ||
| ``` | ||
| ### Options | ||
| ``` | ||
| -h, --help Help for "stackit auth logout" | ||
| ``` | ||
| ### 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 | ||
| --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") | ||
| ``` | ||
| ### SEE ALSO | ||
| * [stackit auth](./stackit_auth.md) - Authenticates the STACKIT CLI | ||
| ## stackit dns zone clone | ||
| Clones a DNS zone | ||
| ### Synopsis | ||
| Clones an existing DNS zone with all record sets to a new zone with a different name. | ||
| ``` | ||
| stackit dns zone clone [flags] | ||
| ``` | ||
| ### Examples | ||
| ``` | ||
| Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" | ||
| $ stackit dns zone clone xxx --dns-name www.my-zone.com | ||
| Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and display name "new-zone" | ||
| $ stackit dns zone clone xxx --dns-name www.my-zone.com --name new-zone | ||
| Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and adjust records "true" | ||
| $ stackit dns zone clone xxx --dns-name www.my-zone.com --adjust-records | ||
| ``` | ||
| ### Options | ||
| ``` | ||
| --adjust-records Sets content and replaces the DNS name of the original zone with the new DNS name of the cloned zone | ||
| --description string New description for the cloned zone | ||
| --dns-name string Fully qualified domain name of the new DNS zone to clone | ||
| -h, --help Help for "stackit dns zone clone" | ||
| --name string User given new name for the cloned zone | ||
| ``` | ||
| ### 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 | ||
| --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 | ||
| package logout | ||
| import ( | ||
| "fmt" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/args" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/auth" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/examples" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/print" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
| func NewCmd(p *print.Printer) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "logout", | ||
| Short: "Logs the user account out of the STACKIT CLI", | ||
| Long: "Logs the user account out of the STACKIT CLI.", | ||
| Args: args.NoArgs, | ||
| Example: examples.Build( | ||
| examples.NewExample( | ||
| `Log out of the STACKIT CLI.`, | ||
| "$ stackit auth logout"), | ||
| ), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| err := auth.LogoutUser() | ||
| if err != nil { | ||
| return fmt.Errorf("log out failed: %w", err) | ||
| } | ||
| p.Info("Successfully logged out of the STACKIT CLI.\n") | ||
| return nil | ||
| }, | ||
| } | ||
| return cmd | ||
| } |
| package clone | ||
| import ( | ||
| "context" | ||
| "testing" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/print" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/utils" | ||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/google/go-cmp/cmp/cmpopts" | ||
| "github.com/google/uuid" | ||
| "github.com/stackitcloud/stackit-sdk-go/services/dns" | ||
| ) | ||
| var projectIdFlag = globalflags.ProjectIdFlag | ||
| type testCtxKey struct{} | ||
| var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") | ||
| var testClient = &dns.APIClient{} | ||
| 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{ | ||
| projectIdFlag: testProjectId, | ||
| nameFlag: "example", | ||
| dnsNameFlag: "example.com", | ||
| descriptionFlag: "Example", | ||
| adjustRecordsFlag: "false", | ||
| } | ||
| 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, | ||
| }, | ||
| Name: utils.Ptr("example"), | ||
| DnsName: utils.Ptr("example.com"), | ||
| Description: utils.Ptr("Example"), | ||
| AdjustRecords: utils.Ptr(false), | ||
| ZoneId: testZoneId, | ||
| } | ||
| for _, mod := range mods { | ||
| mod(model) | ||
| } | ||
| return model | ||
| } | ||
| func fixtureRequest(mods ...func(request *dns.ApiCloneZoneRequest)) dns.ApiCloneZoneRequest { | ||
| request := testClient.CloneZone(testCtx, testProjectId, testZoneId) | ||
| request = request.CloneZonePayload(dns.CloneZonePayload{ | ||
| Name: utils.Ptr("example"), | ||
| DnsName: utils.Ptr("example.com"), | ||
| Description: utils.Ptr("Example"), | ||
| AdjustRecords: utils.Ptr(false), | ||
| }) | ||
| 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: "required fields only", | ||
| argValues: []string{testZoneId}, | ||
| flagValues: map[string]string{ | ||
| projectIdFlag: testProjectId, | ||
| dnsNameFlag: "example.com", | ||
| }, | ||
| isValid: true, | ||
| expectedModel: &inputModel{ | ||
| GlobalFlagModel: &globalflags.GlobalFlagModel{ | ||
| ProjectId: testProjectId, | ||
| Verbosity: globalflags.VerbosityDefault, | ||
| }, | ||
| DnsName: utils.Ptr("example.com"), | ||
| ZoneId: testZoneId, | ||
| }, | ||
| }, | ||
| { | ||
| description: "project id missing", | ||
| argValues: fixtureArgValues(), | ||
| flagValues: fixtureFlagValues(func(flagValues map[string]string) { | ||
| delete(flagValues, projectIdFlag) | ||
| }), | ||
| isValid: false, | ||
| }, | ||
| { | ||
| description: "project id invalid 1", | ||
| argValues: fixtureArgValues(), | ||
| flagValues: fixtureFlagValues(func(flagValues map[string]string) { | ||
| flagValues[projectIdFlag] = "" | ||
| }), | ||
| isValid: false, | ||
| }, | ||
| { | ||
| description: "project id invalid 2", | ||
| argValues: fixtureArgValues(), | ||
| flagValues: fixtureFlagValues(func(flagValues map[string]string) { | ||
| flagValues[projectIdFlag] = "invalid-uuid" | ||
| }), | ||
| isValid: false, | ||
| }, | ||
| { | ||
| description: "zone id invalid 1", | ||
| argValues: []string{""}, | ||
| flagValues: fixtureFlagValues(), | ||
| isValid: false, | ||
| }, | ||
| { | ||
| description: "zone id invalid 2", | ||
| argValues: []string{"invalid-uuid"}, | ||
| flagValues: fixtureFlagValues(), | ||
| isValid: false, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.description, func(t *testing.T) { | ||
| p := print.NewPrinter() | ||
| cmd := NewCmd(p) | ||
| err := globalflags.Configure(cmd.Flags()) | ||
| if err != nil { | ||
| t.Fatalf("configure global flags: %v", err) | ||
| } | ||
| for flag, value := range tt.flagValues { | ||
| err := cmd.Flags().Set(flag, value) | ||
| if err != nil { | ||
| if !tt.isValid { | ||
| return | ||
| } | ||
| t.Fatalf("setting flag --%s=%s: %v", flag, value, err) | ||
| } | ||
| } | ||
| err = cmd.ValidateArgs(tt.argValues) | ||
| if err != nil { | ||
| if !tt.isValid { | ||
| return | ||
| } | ||
| t.Fatalf("error validating args: %v", err) | ||
| } | ||
| err = cmd.ValidateRequiredFlags() | ||
| if err != nil { | ||
| if !tt.isValid { | ||
| return | ||
| } | ||
| t.Fatalf("error validating flags: %v", err) | ||
| } | ||
| model, err := parseInput(p, cmd, tt.argValues) | ||
| if err != nil { | ||
| if !tt.isValid { | ||
| return | ||
| } | ||
| t.Fatalf("error parsing flags: %v", err) | ||
| } | ||
| if !tt.isValid { | ||
| t.Fatalf("did not fail on invalid input") | ||
| } | ||
| diff := cmp.Diff(model, tt.expectedModel) | ||
| if diff != "" { | ||
| t.Fatalf("Data does not match: %s", diff) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| func TestBuildRequest(t *testing.T) { | ||
| tests := []struct { | ||
| description string | ||
| model *inputModel | ||
| isValid bool | ||
| expectedRequest dns.ApiCloneZoneRequest | ||
| }{ | ||
| { | ||
| description: "base", | ||
| model: fixtureInputModel(), | ||
| isValid: true, | ||
| expectedRequest: fixtureRequest(), | ||
| }, | ||
| { | ||
| description: "required fields only", | ||
| model: &inputModel{ | ||
| GlobalFlagModel: &globalflags.GlobalFlagModel{ | ||
| ProjectId: testProjectId, | ||
| Verbosity: globalflags.VerbosityDefault, | ||
| }, | ||
| DnsName: utils.Ptr("example.com"), | ||
| ZoneId: testZoneId, | ||
| }, | ||
| expectedRequest: testClient.CloneZone(testCtx, testProjectId, testZoneId). | ||
| CloneZonePayload(dns.CloneZonePayload{ | ||
| DnsName: utils.Ptr("example.com"), | ||
| }), | ||
| }, | ||
| } | ||
| 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), | ||
| cmpopts.EquateComparable(testCtx), | ||
| ) | ||
| if diff != "" { | ||
| t.Fatalf("Data does not match: %s", diff) | ||
| } | ||
| }) | ||
| } | ||
| } |
| package clone | ||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "github.com/goccy/go-yaml" | ||
| "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/spinner" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/utils" | ||
| "github.com/spf13/cobra" | ||
| "github.com/stackitcloud/stackit-sdk-go/services/dns" | ||
| "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" | ||
| ) | ||
| const ( | ||
| nameFlag = "name" | ||
| dnsNameFlag = "dns-name" | ||
| descriptionFlag = "description" | ||
| adjustRecordsFlag = "adjust-records" | ||
| zoneIdArg = "ZONE_ID" | ||
| ) | ||
| type inputModel struct { | ||
| *globalflags.GlobalFlagModel | ||
| Name *string | ||
| DnsName *string | ||
| Description *string | ||
| AdjustRecords *bool | ||
| ZoneId string | ||
| } | ||
| func NewCmd(p *print.Printer) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "clone", | ||
| Short: "Clones a DNS zone", | ||
| Long: "Clones an existing DNS zone with all record sets to a new zone with a different name.", | ||
| Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), | ||
| Example: examples.Build( | ||
| examples.NewExample( | ||
| `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com"`, | ||
| "$ stackit dns zone clone xxx --dns-name www.my-zone.com"), | ||
| examples.NewExample( | ||
| `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and display name "new-zone"`, | ||
| "$ stackit dns zone clone xxx --dns-name www.my-zone.com --name new-zone"), | ||
| examples.NewExample( | ||
| `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and adjust records "true"`, | ||
| "$ stackit dns zone clone xxx --dns-name www.my-zone.com --adjust-records"), | ||
| ), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| ctx := context.Background() | ||
| model, err := parseInput(p, cmd, args) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| // Configure API client | ||
| apiClient, err := client.ConfigureClient(p) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) | ||
| if err != nil { | ||
| p.Debug(print.ErrorLevel, "get zone name: %v", err) | ||
| zoneLabel = model.ZoneId | ||
| } | ||
| if !model.AssumeYes { | ||
| prompt := fmt.Sprintf("Are you sure you want to clone the zone %q?", zoneLabel) | ||
| err = p.PromptForConfirmation(prompt) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| // Call API | ||
| req := buildRequest(ctx, model, apiClient) | ||
| resp, err := req.Execute() | ||
| if err != nil { | ||
| return fmt.Errorf("clone DNS zone: %w", err) | ||
| } | ||
| zoneId := *resp.Zone.Id | ||
| // Wait for async operation, if async mode not enabled | ||
| if !model.Async { | ||
| s := spinner.New(p) | ||
| s.Start("Cloning zone") | ||
| _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) | ||
| if err != nil { | ||
| return fmt.Errorf("wait for DNS zone cloning: %w", err) | ||
| } | ||
| s.Stop() | ||
| } | ||
| return outputResult(p, model, zoneLabel, resp) | ||
| }, | ||
| } | ||
| configureFlags(cmd) | ||
| return cmd | ||
| } | ||
| func configureFlags(cmd *cobra.Command) { | ||
| cmd.Flags().String(nameFlag, "", "User given new name for the cloned zone") | ||
| cmd.Flags().String(dnsNameFlag, "", "Fully qualified domain name of the new DNS zone to clone") | ||
| cmd.Flags().String(descriptionFlag, "", "New description for the cloned zone") | ||
| cmd.Flags().Bool(adjustRecordsFlag, false, "Sets content and replaces the DNS name of the original zone with the new DNS name of the cloned zone") | ||
| err := flags.MarkFlagsRequired(cmd, dnsNameFlag) | ||
| 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{} | ||
| } | ||
| model := inputModel{ | ||
| GlobalFlagModel: globalFlags, | ||
| Name: flags.FlagToStringPointer(p, cmd, nameFlag), | ||
| DnsName: flags.FlagToStringPointer(p, cmd, dnsNameFlag), | ||
| Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), | ||
| AdjustRecords: flags.FlagToBoolPointer(p, cmd, adjustRecordsFlag), | ||
| ZoneId: zoneId, | ||
| } | ||
| if p.IsVerbosityDebug() { | ||
| modelStr, err := print.BuildDebugStrFromInputModel(model) | ||
| if err != nil { | ||
| p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) | ||
| } else { | ||
| p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) | ||
| } | ||
| } | ||
| return &model, nil | ||
| } | ||
| func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiCloneZoneRequest { | ||
| req := apiClient.CloneZone(ctx, model.ProjectId, model.ZoneId) | ||
| req = req.CloneZonePayload(dns.CloneZonePayload{ | ||
| Name: model.Name, | ||
| DnsName: model.DnsName, | ||
| Description: model.Description, | ||
| AdjustRecords: model.AdjustRecords, | ||
| }) | ||
| return req | ||
| } | ||
| func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *dns.ZoneResponse) error { | ||
| switch model.OutputFormat { | ||
| case print.JSONOutputFormat: | ||
| details, err := json.MarshalIndent(resp, "", " ") | ||
| if err != nil { | ||
| return fmt.Errorf("marshal DNS zone: %w", err) | ||
| } | ||
| p.Outputln(string(details)) | ||
| return nil | ||
| case print.YAMLOutputFormat: | ||
| details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true)) | ||
| if err != nil { | ||
| return fmt.Errorf("marshal DNS zone: %w", err) | ||
| } | ||
| p.Outputln(string(details)) | ||
| return nil | ||
| default: | ||
| operationState := "Cloned" | ||
| if model.Async { | ||
| operationState = "Triggered cloning of" | ||
| } | ||
| p.Outputf("%s zone for project %q. Zone ID: %s\n", operationState, projectLabel, *resp.Zone.Id) | ||
| return nil | ||
| } | ||
| } |
@@ -16,5 +16,5 @@ name: Renovate | ||
| - name: Self-hosted Renovate | ||
| uses: renovatebot/github-action@v40.1.12 | ||
| uses: renovatebot/github-action@v40.2.3 | ||
| with: | ||
| configurationFile: .github/renovate.json | ||
| token: ${{ secrets.RENOVATE_TOKEN }} |
@@ -34,2 +34,3 @@ ## stackit auth | ||
| * [stackit auth login](./stackit_auth_login.md) - Logs in to the STACKIT CLI | ||
| * [stackit auth logout](./stackit_auth_logout.md) - Logs the user account out of the STACKIT CLI | ||
@@ -32,2 +32,3 @@ ## stackit dns zone | ||
| * [stackit dns](./stackit_dns.md) - Provides functionality for DNS | ||
| * [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 | ||
@@ -34,0 +35,0 @@ * [stackit dns zone delete](./stackit_dns_zone_delete.md) - Deletes a DNS zone |
+2
-2
@@ -29,4 +29,4 @@ module github.com/stackitcloud/stackit-cli | ||
| github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v0.2.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.18.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.3.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.19.0 | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.4.0 | ||
| github.com/zalando/go-keyring v0.2.5 | ||
@@ -33,0 +33,0 @@ golang.org/x/mod v0.19.0 |
+4
-4
@@ -162,6 +162,6 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= | ||
| github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v0.2.0/go.mod h1:z6XdA+ndaWzcPW/P0QrUIcTXJzKlajxgGZ5+EwXNS+c= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.18.0 h1:gxbOYBpHBLMuzltbOGz7OwRFtjCZ9X56a2apsPP/8uU= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.18.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.3.0 h1:M6tcXUMNM6XMfHVQeQzB6IjfPdAxnZar3YD+YstRStc= | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.3.0/go.mod h1:Qnn+06i21XtagtMQ4cTwOCR3OLnXX+t1n+Vf/HH49Yw= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.19.0 h1:vmkfa26HO1VA40pKPNnYMHkcNMxBEWAdYbX+5LVIo48= | ||
| github.com/stackitcloud/stackit-sdk-go/services/ske v0.19.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.4.0 h1:Ly8dDjjggO7udf5kyyQU6Lm6UPoWzJVJONYeaIDkFVM= | ||
| github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.4.0/go.mod h1:Qnn+06i21XtagtMQ4cTwOCR3OLnXX+t1n+Vf/HH49Yw= | ||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
@@ -168,0 +168,0 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= |
@@ -6,2 +6,3 @@ package auth | ||
| "github.com/stackitcloud/stackit-cli/internal/cmd/auth/login" | ||
| "github.com/stackitcloud/stackit-cli/internal/cmd/auth/logout" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/args" | ||
@@ -28,3 +29,4 @@ "github.com/stackitcloud/stackit-cli/internal/pkg/print" | ||
| cmd.AddCommand(login.NewCmd(p)) | ||
| cmd.AddCommand(logout.NewCmd(p)) | ||
| cmd.AddCommand(activateserviceaccount.NewCmd(p)) | ||
| } |
@@ -137,10 +137,2 @@ package describe | ||
| table.AddSeparator() | ||
| if database.CreateDate != nil { | ||
| table.AddRow("CREATE DATE", *database.CreateDate) | ||
| table.AddSeparator() | ||
| } | ||
| if database.Collation != nil { | ||
| table.AddRow("COLLATION", *database.Collation) | ||
| table.AddSeparator() | ||
| } | ||
| if database.Options != nil { | ||
@@ -151,6 +143,2 @@ if database.Options.CompatibilityLevel != nil { | ||
| } | ||
| if database.Options.IsEncrypted != nil { | ||
| table.AddRow("IS ENCRYPTED", *database.Options.IsEncrypted) | ||
| table.AddSeparator() | ||
| } | ||
| if database.Options.Owner != nil { | ||
@@ -160,4 +148,4 @@ table.AddRow("OWNER", *database.Options.Owner) | ||
| } | ||
| if database.Options.UserAccess != nil { | ||
| table.AddRow("USER ACCESS", *database.Options.UserAccess) | ||
| if database.Options.CollationName != nil { | ||
| table.AddRow("COLLATION", *database.Options.CollationName) | ||
| } | ||
@@ -164,0 +152,0 @@ } |
@@ -79,5 +79,5 @@ package delete | ||
| err = auth.DeleteProfileFromKeyring(model.Profile) | ||
| err = auth.DeleteProfileAuth(model.Profile) | ||
| if err != nil { | ||
| return fmt.Errorf("delete profile from keyring: %w", err) | ||
| return fmt.Errorf("delete profile authentication: %w", err) | ||
| } | ||
@@ -84,0 +84,0 @@ |
@@ -61,3 +61,3 @@ package delete | ||
| if !model.AssumeYes { | ||
| prompt := fmt.Sprintf("Are you sure you want to delete zone %s? (This cannot be undone)", zoneLabel) | ||
| prompt := fmt.Sprintf("Are you sure you want to delete zone %q? (This cannot be undone)", zoneLabel) | ||
| err = p.PromptForConfirmation(prompt) | ||
@@ -64,0 +64,0 @@ if err != nil { |
| package zone | ||
| import ( | ||
| "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/clone" | ||
| "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/create" | ||
@@ -34,2 +35,3 @@ "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/delete" | ||
| cmd.AddCommand(delete.NewCmd(p)) | ||
| cmd.AddCommand(clone.NewCmd(p)) | ||
| } |
| package auth | ||
| import ( | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| "github.com/zalando/go-keyring" | ||
| "github.com/stackitcloud/stackit-cli/internal/pkg/config" | ||
| ) | ||
@@ -320,3 +316,3 @@ | ||
| err = deleteAuthFieldProfile(tt.activeProfile) | ||
| err = deleteProfileFiles(tt.activeProfile) | ||
| if err != nil { | ||
@@ -472,2 +468,152 @@ t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err) | ||
| func TestDeleteAuthField(t *testing.T) { | ||
| tests := []struct { | ||
| description string | ||
| keyringFails bool | ||
| key authFieldKey | ||
| noKey bool | ||
| }{ | ||
| { | ||
| description: "base", | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "key doesnt exist", | ||
| key: "doesnt-exist", | ||
| noKey: true, | ||
| }, | ||
| { | ||
| description: "keyring fails", | ||
| keyringFails: true, | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "keyring fails, no key exists", | ||
| keyringFails: true, | ||
| noKey: true, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.description, func(t *testing.T) { | ||
| if !tt.keyringFails { | ||
| keyring.MockInit() | ||
| } else { | ||
| keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) | ||
| } | ||
| // Append random string to auth field key and value to avoid conflicts | ||
| testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339))) | ||
| testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339)) | ||
| if !tt.noKey { | ||
| err := SetAuthField(testField1, testValue1) | ||
| if err != nil { | ||
| t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err) | ||
| } | ||
| } | ||
| err := DeleteAuthField(tt.key) | ||
| if err != nil { | ||
| t.Fatalf("Failed to delete field \"%s\": %v", tt.key, err) | ||
| } | ||
| // Check if key still exists | ||
| _, err = GetAuthField(tt.key) | ||
| if err == nil { | ||
| t.Fatalf("Key \"%s\" still exists after deletion", tt.key) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| func TestDeleteAuthFieldWithProfile(t *testing.T) { | ||
| tests := []struct { | ||
| description string | ||
| keyringFails bool | ||
| profile string | ||
| key authFieldKey | ||
| noKey bool | ||
| }{ | ||
| { | ||
| description: "base", | ||
| profile: "default", | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "key doesnt exist", | ||
| profile: "default", | ||
| key: "doesnt-exist", | ||
| noKey: true, | ||
| }, | ||
| { | ||
| description: "keyring fails", | ||
| profile: "default", | ||
| keyringFails: true, | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "keyring fails, no key exists", | ||
| profile: "default", | ||
| keyringFails: true, | ||
| noKey: true, | ||
| }, | ||
| { | ||
| description: "base, custom profile", | ||
| profile: "test-profile", | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "key doesnt exist, custom profile", | ||
| profile: "test-profile", | ||
| key: "doesnt-exist", | ||
| noKey: true, | ||
| }, | ||
| { | ||
| description: "keyring fails, custom profile", | ||
| profile: "test-profile", | ||
| keyringFails: true, | ||
| key: "test-field-1", | ||
| }, | ||
| { | ||
| description: "keyring fails, no key exists, custom profile", | ||
| profile: "test-profile", | ||
| keyringFails: true, | ||
| noKey: true, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.description, func(t *testing.T) { | ||
| if !tt.keyringFails { | ||
| keyring.MockInit() | ||
| } else { | ||
| keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) | ||
| } | ||
| // Append random string to auth field key and value to avoid conflicts | ||
| testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339))) | ||
| testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339)) | ||
| if !tt.noKey { | ||
| err := SetAuthField(testField1, testValue1) | ||
| if err != nil { | ||
| t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err) | ||
| } | ||
| } | ||
| err := deleteAuthFieldWithProfile(tt.profile, tt.key) | ||
| if err != nil { | ||
| t.Fatalf("Failed to delete field \"%s\": %v", tt.key, err) | ||
| } | ||
| // Check if key still exists | ||
| _, err = GetAuthField(tt.key) | ||
| if err == nil { | ||
| t.Fatalf("Key \"%s\" still exists after deletion", tt.key) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| func TestDeleteAuthFieldKeyring(t *testing.T) { | ||
@@ -611,3 +757,3 @@ tests := []struct { | ||
| err := DeleteProfileFromKeyring(tt.activeProfile) | ||
| err := DeleteProfileAuth(tt.activeProfile) | ||
| if err != nil { | ||
@@ -773,3 +919,3 @@ if tt.isValid { | ||
| err = deleteAuthFieldProfile(tt.activeProfile) | ||
| err = deleteProfileFiles(tt.activeProfile) | ||
| if err != nil { | ||
@@ -932,3 +1078,3 @@ t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err) | ||
| err = deleteAuthFieldProfile(tt.activeProfile) | ||
| err = deleteProfileFiles(tt.activeProfile) | ||
| if err != nil { | ||
@@ -941,40 +1087,3 @@ t.Fatalf("Failed to remove profile: %v", err) | ||
| func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error { | ||
| err := createEncodedTextFile(activeProfile) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| textFileDir := config.GetProfileFolderPath(activeProfile) | ||
| textFilePath := filepath.Join(textFileDir, textFileName) | ||
| contentEncoded, err := os.ReadFile(textFilePath) | ||
| if err != nil { | ||
| return fmt.Errorf("read file: %w", err) | ||
| } | ||
| contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded)) | ||
| if err != nil { | ||
| return fmt.Errorf("decode file: %w", err) | ||
| } | ||
| content := map[authFieldKey]string{} | ||
| err = json.Unmarshal(contentBytes, &content) | ||
| if err != nil { | ||
| return fmt.Errorf("unmarshal file: %w", err) | ||
| } | ||
| delete(content, key) | ||
| contentBytes, err = json.Marshal(content) | ||
| if err != nil { | ||
| return fmt.Errorf("marshal file: %w", err) | ||
| } | ||
| contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes)) | ||
| err = os.WriteFile(textFilePath, contentEncoded, 0o600) | ||
| if err != nil { | ||
| return fmt.Errorf("write file: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
| func deleteAuthFieldProfile(activeProfile string) error { | ||
| func deleteProfileFiles(activeProfile string) error { | ||
| if activeProfile == config.DefaultProfileName { | ||
@@ -999,1 +1108,117 @@ // Do not delete the default profile | ||
| } | ||
| func TestAuthorizeDeauthorizeUserProfileAuth(t *testing.T) { | ||
| type args struct { | ||
| sessionExpiresAtUnix string | ||
| accessToken string | ||
| refreshToken string | ||
| email string | ||
| } | ||
| tests := []struct { | ||
| name string | ||
| args args | ||
| wantErr bool | ||
| }{ | ||
| { | ||
| name: "base", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "1234567890", | ||
| accessToken: "accessToken", | ||
| refreshToken: "refreshToken", | ||
| email: "test@example.com", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "no email", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "1234567890", | ||
| accessToken: "accessToken", | ||
| refreshToken: "refreshToken", | ||
| email: "", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "no session expires", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "", | ||
| accessToken: "accessToken", | ||
| refreshToken: "refreshToken", | ||
| email: "test@example.com", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "no access token", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "1234567890", | ||
| accessToken: "", | ||
| refreshToken: "refreshToken", | ||
| email: "test@example.com", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "no refresh token", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "1234567890", | ||
| accessToken: "accessToken", | ||
| refreshToken: "", | ||
| email: "test@example.com", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "all empty args", | ||
| args: args{ | ||
| sessionExpiresAtUnix: "", | ||
| accessToken: "", | ||
| refreshToken: "", | ||
| email: "", | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| keyring.MockInit() | ||
| if err := LoginUser(tt.args.email, tt.args.accessToken, tt.args.refreshToken, tt.args.sessionExpiresAtUnix); (err != nil) != tt.wantErr { | ||
| t.Errorf("AuthorizeUserProfileAuth() error = %v, wantErr %v", err, tt.wantErr) | ||
| } | ||
| // Test values | ||
| testLoginAuthFields := []string{ | ||
| tt.args.sessionExpiresAtUnix, | ||
| tt.args.accessToken, | ||
| tt.args.refreshToken, | ||
| tt.args.email, | ||
| } | ||
| // Check if the fields are set | ||
| for i := range loginAuthFieldKeys { | ||
| gotKey, err := GetAuthField(loginAuthFieldKeys[i]) | ||
| if err != nil { | ||
| t.Errorf("Field \"%s\" not set after authorization", loginAuthFieldKeys[i]) | ||
| } | ||
| expectedKey := testLoginAuthFields[i] | ||
| if gotKey != expectedKey { | ||
| t.Errorf("Field \"%s\" is wrong: expected \"%s\", got \"%s\"", loginAuthFieldKeys[i], expectedKey, gotKey) | ||
| } | ||
| } | ||
| if err := LogoutUser(); err != nil { | ||
| t.Errorf("DeauthorizeUserProfileAuth() error = %v", err) | ||
| } | ||
| // Check if the fields are deleted | ||
| for _, key := range loginAuthFieldKeys { | ||
| _, err := GetAuthField(key) | ||
| if err == nil { | ||
| t.Errorf("Field \"%s\" still exists after deauthorization", key) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } |
@@ -65,2 +65,11 @@ package auth | ||
| // All fields that are set when a user logs in | ||
| // These fields should match the ones in LoginUser, which is ensured by the tests | ||
| var loginAuthFieldKeys = []authFieldKey{ | ||
| SESSION_EXPIRES_AT_UNIX, | ||
| ACCESS_TOKEN, | ||
| REFRESH_TOKEN, | ||
| USER_EMAIL, | ||
| } | ||
| func SetAuthFlow(value AuthFlow) error { | ||
@@ -109,2 +118,61 @@ return SetAuthField(authFlowType, string(value)) | ||
| func DeleteAuthField(key authFieldKey) error { | ||
| activeProfile, err := config.GetProfile() | ||
| if err != nil { | ||
| return fmt.Errorf("get profile: %w", err) | ||
| } | ||
| return deleteAuthFieldWithProfile(activeProfile, key) | ||
| } | ||
| func deleteAuthFieldWithProfile(profile string, key authFieldKey) error { | ||
| err := deleteAuthFieldInKeyring(profile, key) | ||
| if err != nil { | ||
| // if the key is not found, we can ignore the error | ||
| if !errors.Is(err, keyring.ErrNotFound) { | ||
| errFallback := deleteAuthFieldInEncodedTextFile(profile, key) | ||
| if errFallback != nil { | ||
| return fmt.Errorf("delete from keyring failed (%w), try deleting from encoded text file: %w", err, errFallback) | ||
| } | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error { | ||
| err := createEncodedTextFile(activeProfile) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| textFileDir := config.GetProfileFolderPath(activeProfile) | ||
| textFilePath := filepath.Join(textFileDir, textFileName) | ||
| contentEncoded, err := os.ReadFile(textFilePath) | ||
| if err != nil { | ||
| return fmt.Errorf("read file: %w", err) | ||
| } | ||
| contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded)) | ||
| if err != nil { | ||
| return fmt.Errorf("decode file: %w", err) | ||
| } | ||
| content := map[authFieldKey]string{} | ||
| err = json.Unmarshal(contentBytes, &content) | ||
| if err != nil { | ||
| return fmt.Errorf("unmarshal file: %w", err) | ||
| } | ||
| delete(content, key) | ||
| contentBytes, err = json.Marshal(content) | ||
| if err != nil { | ||
| return fmt.Errorf("marshal file: %w", err) | ||
| } | ||
| contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes)) | ||
| err = os.WriteFile(textFilePath, contentEncoded, 0o600) | ||
| if err != nil { | ||
| return fmt.Errorf("write file: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
| func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error { | ||
@@ -278,3 +346,28 @@ keyringServiceLocal := keyringService | ||
| func DeleteProfileFromKeyring(profile string) error { | ||
| func LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix string) error { | ||
| authFields := map[authFieldKey]string{ | ||
| SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix, | ||
| ACCESS_TOKEN: accessToken, | ||
| REFRESH_TOKEN: refreshToken, | ||
| USER_EMAIL: email, | ||
| } | ||
| err := SetAuthFieldMap(authFields) | ||
| if err != nil { | ||
| return fmt.Errorf("set auth fields: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
| func LogoutUser() error { | ||
| for _, key := range loginAuthFieldKeys { | ||
| err := DeleteAuthField(key) | ||
| if err != nil { | ||
| return fmt.Errorf("delete auth field \"%s\": %w", key, err) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| func DeleteProfileAuth(profile string) error { | ||
| err := config.ValidateProfile(profile) | ||
@@ -290,8 +383,5 @@ if err != nil { | ||
| for _, key := range authFieldKeys { | ||
| err := deleteAuthFieldInKeyring(profile, key) | ||
| err := deleteAuthFieldWithProfile(profile, key) | ||
| if err != nil { | ||
| // if the key is not found, we can ignore the error | ||
| if !errors.Is(err, keyring.ErrNotFound) { | ||
| return fmt.Errorf("delete auth field \"%s\" from keyring: %w", key, err) | ||
| } | ||
| return fmt.Errorf("delete auth field \"%s\": %w", key, err) | ||
| } | ||
@@ -298,0 +388,0 @@ } |
@@ -170,9 +170,3 @@ package auth | ||
| authFields := map[authFieldKey]string{ | ||
| SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix, | ||
| ACCESS_TOKEN: accessToken, | ||
| REFRESH_TOKEN: refreshToken, | ||
| USER_EMAIL: email, | ||
| } | ||
| err = SetAuthFieldMap(authFields) | ||
| err = LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix) | ||
| if err != nil { | ||
@@ -179,0 +173,0 @@ errServer = fmt.Errorf("set in auth storage: %w", err) |