diff --git a/docs/stackit.md b/docs/stackit.md index 2dc456b83..3cc35f2f9 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -33,6 +33,7 @@ stackit [flags] * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint * [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit git](./stackit_git.md) - Provides functionality for STACKIT Git * [stackit image](./stackit_image.md) - Manage server images * [stackit key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs * [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer diff --git a/docs/stackit_git.md b/docs/stackit_git.md new file mode 100644 index 000000000..8a959c7d2 --- /dev/null +++ b/docs/stackit_git.md @@ -0,0 +1,37 @@ +## stackit git + +Provides functionality for STACKIT Git + +### Synopsis + +Provides functionality for STACKIT Git. + +``` +stackit git [flags] +``` + +### Options + +``` + -h, --help Help for "stackit git" +``` + +### 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](./stackit.md) - Manage STACKIT resources using the command line +* [stackit git create](./stackit_git_create.md) - Creates STACKIT Git instance +* [stackit git delete](./stackit_git_delete.md) - Deletes STACKIT Git instance +* [stackit git describe](./stackit_git_describe.md) - Describes STACKIT Git instance +* [stackit git list](./stackit_git_list.md) - Lists all instances of STACKIT Git. + diff --git a/docs/stackit_git_create.md b/docs/stackit_git_create.md new file mode 100644 index 000000000..fa7a2a97d --- /dev/null +++ b/docs/stackit_git_create.md @@ -0,0 +1,41 @@ +## stackit git create + +Creates STACKIT Git instance + +### Synopsis + +Create a STACKIT Git instance by name. + +``` +stackit git create [flags] +``` + +### Examples + +``` + Create a instance with name 'my-new-instance' + $ stackit git create --name my-new-instance +``` + +### Options + +``` + -h, --help Help for "stackit git create" + --name string The name of the instance. +``` + +### 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 git](./stackit_git.md) - Provides functionality for STACKIT Git + diff --git a/docs/stackit_git_delete.md b/docs/stackit_git_delete.md new file mode 100644 index 000000000..6f863ee60 --- /dev/null +++ b/docs/stackit_git_delete.md @@ -0,0 +1,40 @@ +## stackit git delete + +Deletes STACKIT Git instance + +### Synopsis + +Deletes a STACKIT Git instance by its internal ID. + +``` +stackit git delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a instance with ID "xxx" + $ stackit git delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit git delete" +``` + +### 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 git](./stackit_git.md) - Provides functionality for STACKIT Git + diff --git a/docs/stackit_git_describe.md b/docs/stackit_git_describe.md new file mode 100644 index 000000000..e2aedcc40 --- /dev/null +++ b/docs/stackit_git_describe.md @@ -0,0 +1,40 @@ +## stackit git describe + +Describes STACKIT Git instance + +### Synopsis + +Describes a STACKIT Git instance by its internal ID. + +``` +stackit git describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Describe instance "xxx" + $ stackit git describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit git describe" +``` + +### 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 git](./stackit_git.md) - Provides functionality for STACKIT Git + diff --git a/docs/stackit_git_list.md b/docs/stackit_git_list.md new file mode 100644 index 000000000..c0f65927b --- /dev/null +++ b/docs/stackit_git_list.md @@ -0,0 +1,44 @@ +## stackit git list + +Lists all instances of STACKIT Git. + +### Synopsis + +Lists all instances of STACKIT Git for the current project. + +``` +stackit git list [flags] +``` + +### Examples + +``` + List all STACKIT Git instances + $ stackit git instance list + + Lists up to 10 STACKIT Git instances + $ stackit git instance list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit git list" + --limit int Limit the output to the first n elements +``` + +### 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 git](./stackit_git.md) - Provides functionality for STACKIT Git + diff --git a/go.mod b/go.mod index d4fa80e97..5e4de09de 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.3 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.3 github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.3 + github.com/stackitcloud/stackit-sdk-go/services/git v0.3.3 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.2 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.0.1 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.21.2 diff --git a/go.sum b/go.sum index 8735251de..d16afaad1 100644 --- a/go.sum +++ b/go.sum @@ -570,6 +570,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.3 h1:aXVMNdiH github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.3/go.mod h1:dJ19ZwFjp2bfC5ZobXV3vUdSpE3quUw3GuoFSKLpHIo= github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.3 h1:WJnA6YTOlBxMJf9PRuxkXduxj+fyWi+wOks69PvN1qI= github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.3/go.mod h1:PMHoavoIaRZpkI9BA0nsnRjGoHASVSBon45XB3QyhMA= +github.com/stackitcloud/stackit-sdk-go/services/git v0.3.3 h1:/NXxiJf/NFpj+DFXc07vsx+0he/xufdhIskXUlSeyyw= +github.com/stackitcloud/stackit-sdk-go/services/git v0.3.3/go.mod h1:XhXHJpOVC9Rpwyf1G+EpMbprBafH9aZb8vWBdR+z0WM= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.2 h1:zh6e2eHgqex++fr6N0RK7wMMBGesC3QhBd42FdTq2Z8= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.2/go.mod h1:QNH50Pq0Hu21lLDOwa02PIjRjTl0LfEdHoz5snGQRn8= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.3 h1:StQg5p4h1aLFwIHQWWe5l44aFue+ZvQMGDbypP1DMYw= diff --git a/internal/cmd/git/create/create.go b/internal/cmd/git/create/create.go new file mode 100644 index 000000000..6df750b37 --- /dev/null +++ b/internal/cmd/git/create/create.go @@ -0,0 +1,167 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "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/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/git" + "github.com/stackitcloud/stackit-sdk-go/services/git/wait" +) + +const ( + nameFlag = "name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Id *string + Name string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates STACKIT Git instance", + Long: "Create a STACKIT Git instance by name.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a instance with name 'my-new-instance'`, + `$ stackit git create --name my-new-instance`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) (err error) { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create the instance %q?", model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + result, err := request.Execute() + if err != nil { + return fmt.Errorf("create stackit git instance: %w", err) + } + model.Id = result.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Creating stackit git instance") + _, err = wait.CreateGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, *model.Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for stackit git Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(params.Printer, model, result) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the instance.") + if err := flags.MarkFlagsRequired(cmd, nameFlag); err != nil { + cobra.CheckErr(err) + } +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + name := flags.FlagToStringValue(p, cmd, nameFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + } + + 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 *git.APIClient) git.ApiCreateInstanceRequest { + return apiClient.CreateInstance(ctx, model.ProjectId).CreateInstancePayload(createPayload(model)) +} + +func createPayload(model *inputModel) git.CreateInstancePayload { + return git.CreateInstancePayload{ + Name: &model.Name, + } +} + +func outputResult(p *print.Printer, model *inputModel, resp *git.Instance) error { + if model == nil { + return fmt.Errorf("input model is nil") + } + var outputFormat string + if model.GlobalFlagModel != nil { + outputFormat = model.OutputFormat + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal iminstanceage: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Created instance %q with id %s\n", model.Name, utils.PtrString(model.Id)) + return nil + } +} diff --git a/internal/cmd/git/create/create_test.go b/internal/cmd/git/create/create_test.go new file mode 100644 index 000000000..33ed0cc7e --- /dev/null +++ b/internal/cmd/git/create/create_test.go @@ -0,0 +1,257 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/stackitcloud/stackit-sdk-go/services/git" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + + testName = "test-instance" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + + nameFlag: testName, + } + 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: testName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureCreatePayload(mods ...func(payload *git.CreateInstancePayload)) (payload git.CreateInstancePayload) { + payload = git.CreateInstancePayload{ + Name: &testName, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *git.ApiCreateInstanceRequest)) git.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + + request = request.CreateInstancePayload(fixtureCreatePayload()) + + 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: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateFlagGroups(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %v", err) + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + 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 + expectedRequest git.ApiCreateInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "name flag", + model: fixtureInputModel(func(model *inputModel) { + model.Name = "new-name" + }), + expectedRequest: fixtureRequest(func(request *git.ApiCreateInstanceRequest) { + *request = request.CreateInstancePayload(fixtureCreatePayload(func(payload *git.CreateInstancePayload) { + payload.Name = utils.Ptr("new-name") + })) + }), + }, + } + + 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), + cmp.AllowUnexported(git.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + resp *git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil", + args: args{ + model: nil, + resp: nil, + }, + wantErr: true, + }, + { + name: "empty input", + args: args{ + model: &inputModel{}, + resp: &git.Instance{}, + }, + wantErr: false, + }, + { + name: "output json", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + OutputFormat: print.JSONOutputFormat, + }, + }, + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, 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/git/delete/delete.go b/internal/cmd/git/delete/delete.go new file mode 100644 index 000000000..67ae11438 --- /dev/null +++ b/internal/cmd/git/delete/delete.go @@ -0,0 +1,132 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + + "github.com/spf13/cobra" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + gitUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/git" + "github.com/stackitcloud/stackit-sdk-go/services/git/wait" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +const instanceIdArg = "INSTANCE_ID" + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes STACKIT Git instance", + Long: "Deletes a STACKIT Git instance by its internal ID.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Delete a instance with ID "xxx"`, `$ stackit git delete 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) + if err != nil { + return err + } + + projectName, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectName = model.ProjectId + } + + instanceName, err := gitUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get stackit git intance name: %v", err) + instanceName = model.InstanceId + } else if instanceName == "" { + instanceName = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the stackit git instance %q for %q?", instanceName, projectName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + err = request.Execute() + if err != nil { + return fmt.Errorf("delete instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Deleting stackit git instance") + _, err = wait.DeleteGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for stackit git instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Info("%s stackit git instance %s \n", operationState, model.InstanceId) + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: cliArgs[0], + } + + 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 *git.APIClient) git.ApiDeleteInstanceRequest { + return apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) +} diff --git a/internal/cmd/git/delete/delete_test.go b/internal/cmd/git/delete/delete_test.go new file mode 100644 index 000000000..8c90a4f1d --- /dev/null +++ b/internal/cmd/git/delete/delete_test.go @@ -0,0 +1,182 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +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}, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiDeleteInstanceRequest)) git.ApiDeleteInstanceRequest { + request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + args: []string{testInstanceId}, + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "no arguments", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple arguments", + flagValues: fixtureFlagValues(), + args: []string{"foo", "bar"}, + isValid: false, + }, + { + description: "invalid instance id", + flagValues: fixtureFlagValues(), + args: []string{"foo"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + cmd.SetArgs(tt.args) + + 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) + } + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.args) + 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 + expectedRequest git.ApiDeleteInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/git/describe/describe.go b/internal/cmd/git/describe/describe.go new file mode 100644 index 000000000..8e2f266e1 --- /dev/null +++ b/internal/cmd/git/describe/describe.go @@ -0,0 +1,152 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "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/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +const instanceIdArg = "INSTANCE_ID" + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Describes STACKIT Git instance", + Long: "Describes a STACKIT Git instance by its internal ID.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Describe instance "xxx"`, `$ stackit git describe 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) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + instance, err := request.Execute() + if err != nil { + return fmt.Errorf("get instance: %w", err) + } + + if err := outputResult(params.Printer, model.OutputFormat, instance); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: cliArgs[0], + } + + 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 *git.APIClient) git.ApiGetInstanceRequest { + return apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, resp *git.Instance) error { + if resp == nil { + return fmt.Errorf("instance not found") + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + if id := resp.Id; id != nil { + table.AddRow("ID", *id) + table.AddSeparator() + } + if name := resp.Name; name != nil { + table.AddRow("NAME", *name) + table.AddSeparator() + } + if url := resp.Url; url != nil { + table.AddRow("URL", *url) + table.AddSeparator() + } + if version := resp.Version; version != nil { + table.AddRow("VERSION", *version) + table.AddSeparator() + } + if state := resp.State; state != nil { + table.AddRow("STATE", *state) + table.AddSeparator() + } + if created := resp.Created; created != nil { + table.AddRow("CREATED", *created) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/git/describe/describe_test.go b/internal/cmd/git/describe/describe_test.go new file mode 100644 index 000000000..17cd8dae8 --- /dev/null +++ b/internal/cmd/git/describe/describe_test.go @@ -0,0 +1,226 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = []string{uuid.NewString()} +) + +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}, + InstanceId: testInstanceId[0], + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiGetInstanceRequest)) git.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId[0]) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + args []string + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + args: testInstanceId, + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + args: testInstanceId, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + args: testInstanceId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + args: testInstanceId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + args: testInstanceId, + isValid: false, + }, + { + description: "no instance id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple instance ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + { + description: "invalid instance id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + 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 + expectedRequest git.ApiGetInstanceRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp *git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + resp: &git.Instance{}, + }, + wantErr: false, + }, + { + name: "nil", + args: args{}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, 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/git/git.go b/internal/cmd/git/git.go new file mode 100644 index 000000000..fcacb4fcd --- /dev/null +++ b/internal/cmd/git/git.go @@ -0,0 +1,34 @@ +package git + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/git/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "Provides functionality for STACKIT Git", + Long: "Provides functionality for STACKIT Git.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand( + list.NewCmd(params), + describe.NewCmd(params), + create.NewCmd(params), + delete.NewCmd(params), + ) +} diff --git a/internal/cmd/git/list/list.go b/internal/cmd/git/list/list.go new file mode 100644 index 000000000..0057342ff --- /dev/null +++ b/internal/cmd/git/list/list.go @@ -0,0 +1,163 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const limitFlag = "limit" + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all instances of STACKIT Git.", + Long: "Lists all instances of STACKIT Git for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT Git instances`, + "$ stackit git instance list"), + examples.NewExample( + "Lists up to 10 STACKIT Git instances", + "$ stackit git instance list --limit=10", + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get STACKIT Git instances: %w", err) + } + instances := *resp.Instances + if len(instances) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + params.Printer.Info("No instances found for project %q\n", projectLabel) + return nil + } else if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = (instances)[:*model.Limit] + } + return outputResult(params.Printer, model.OutputFormat, instances) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + 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 *git.APIClient) git.ApiListInstancesRequest { + return apiClient.ListInstances(ctx, model.ProjectId) +} + +func outputResult(p *print.Printer, outputFormat string, instances []git.Instance) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal Observability instance list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal Observability instance list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "URL", "VERSION", "STATE", "CREATED") + for i := range instances { + instance := (instances)[i] + table.AddRow( + utils.PtrString(instance.Id), + utils.PtrString(instance.Name), + utils.PtrString(instance.Url), + utils.PtrString(instance.Version), + utils.PtrString(instance.State), + utils.PtrString(instance.Created), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/git/list/list_test.go b/internal/cmd/git/list/list_test.go new file mode 100644 index 000000000..f73297388 --- /dev/null +++ b/internal/cmd/git/list/list_test.go @@ -0,0 +1,237 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/stackitcloud/stackit-sdk-go/services/git" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &git.APIClient{} +var testProjectId = uuid.NewString() + +const ( + testLimit = 10 +) + +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, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiListInstancesRequest)) git.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId) + 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: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "with limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(testLimit) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(testLimit)) + }), + }, + { + description: "with limit flag == 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(0) + }), + isValid: false, + }, + { + description: "with limit flag < 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(-1) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + 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 + expectedRequest git.ApiListInstancesRequest + }{ + { + 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), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instances []git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty instances slice", + args: args{ + instances: []git.Instance{}, + }, + wantErr: false, + }, + { + name: "set empty instances in instances slice", + args: args{ + instances: []git.Instance{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6da6491d6..750bdfa9a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -14,6 +14,7 @@ import ( configCmd "github.com/stackitcloud/stackit-cli/internal/cmd/config" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" "github.com/stackitcloud/stackit-cli/internal/cmd/dns" + "github.com/stackitcloud/stackit-cli/internal/cmd/git" "github.com/stackitcloud/stackit-cli/internal/cmd/image" keypair "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair" loadbalancer "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer" @@ -191,6 +192,7 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(image.NewCmd(params)) cmd.AddCommand(quota.NewCmd(params)) cmd.AddCommand(affinityGroups.NewCmd(params)) + cmd.AddCommand(git.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index da282c335..957d7c475 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -45,6 +45,7 @@ const ( SQLServerFlexCustomEndpointKey = "sqlserverflex_custom_endpoint" IaaSCustomEndpointKey = "iaas_custom_endpoint" TokenCustomEndpointKey = "token_custom_endpoint" + GitCustomEndpointKey = "git_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -103,6 +104,7 @@ var ConfigKeys = []string{ SQLServerFlexCustomEndpointKey, IaaSCustomEndpointKey, TokenCustomEndpointKey, + GitCustomEndpointKey, } var defaultConfigFolderPath string @@ -187,6 +189,7 @@ func setConfigDefaults() { viper.SetDefault(SQLServerFlexCustomEndpointKey, "") viper.SetDefault(IaaSCustomEndpointKey, "") viper.SetDefault(TokenCustomEndpointKey, "") + viper.SetDefault(GitCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/git/client/client.go b/internal/pkg/services/git/client/client.go new file mode 100644 index 000000000..dde3b7be6 --- /dev/null +++ b/internal/pkg/services/git/client/client.go @@ -0,0 +1,45 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +func ConfigureClient(p *print.Printer) (*git.APIClient, error) { + var err error + var apiClient *git.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.GitCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err = git.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/git/utils/utils.go b/internal/pkg/services/git/utils/utils.go new file mode 100644 index 000000000..3a875c920 --- /dev/null +++ b/internal/pkg/services/git/utils/utils.go @@ -0,0 +1,23 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type GitClient interface { + GetInstanceExecute(ctx context.Context, projectId string, instanceId string) (*git.Instance, error) +} + +func GetInstanceName(ctx context.Context, apiClient GitClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get instance: %w", err) + } + if resp.Name == nil { + return "", nil + } + return *resp.Name, nil +} diff --git a/internal/pkg/services/git/utils/utils_test.go b/internal/pkg/services/git/utils/utils_test.go new file mode 100644 index 000000000..7ec5dc494 --- /dev/null +++ b/internal/pkg/services/git/utils/utils_test.go @@ -0,0 +1,66 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type GitClientMocked struct { + GetInstanceFails bool + GetInstanceResp *git.Instance +} + +func (m *GitClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*git.Instance, error) { + if m.GetInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.GetInstanceResp, nil +} + +func TestGetinstanceName(t *testing.T) { + tests := []struct { + name string + instanceResp *git.Instance + instanceErr bool + want string + wantErr bool + }{ + { + name: "successful retrieval", + instanceResp: &git.Instance{Name: utils.Ptr("test-instance")}, + want: "test-instance", + wantErr: false, + }, + { + name: "error on retrieval", + instanceErr: true, + wantErr: true, + }, + { + name: "nil name", + instanceErr: false, + instanceResp: &git.Instance{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &GitClientMocked{ + GetInstanceFails: tt.instanceErr, + GetInstanceResp: tt.instanceResp, + } + got, err := GetInstanceName(context.Background(), client, "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetInstanceName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetInstanceName() = %v, want %v", got, tt.want) + } + }) + } +}