diff --git a/docs/stackit_alpha.md b/docs/stackit_alpha.md index d47173934..2d97662e9 100644 --- a/docs/stackit_alpha.md +++ b/docs/stackit_alpha.md @@ -41,5 +41,6 @@ stackit alpha [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit alpha folder](./stackit_alpha_folder.md) - Manages the lifecycle of folders. * [stackit alpha vpc](./stackit_alpha_vpc.md) - Manages vpcs diff --git a/docs/stackit_alpha_folder.md b/docs/stackit_alpha_folder.md new file mode 100644 index 000000000..1d3b6f0e7 --- /dev/null +++ b/docs/stackit_alpha_folder.md @@ -0,0 +1,36 @@ +## stackit alpha folder + +Manages the lifecycle of folders. + +### Synopsis + +Manages the lifecycle of folders. +A folder provides a structure for organizing projects within an organization. + +``` +stackit alpha folder [flags] +``` + +### Options + +``` + -h, --help Help for "stackit alpha folder" +``` + +### 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 alpha](./stackit_alpha.md) - Contains alpha STACKIT CLI commands +* [stackit alpha folder describe](./stackit_alpha_folder_describe.md) - Shows details of a STACKIT folder +* [stackit alpha folder list](./stackit_alpha_folder_list.md) - Lists STACKIT folders + diff --git a/docs/stackit_alpha_folder_describe.md b/docs/stackit_alpha_folder_describe.md new file mode 100644 index 000000000..0804faa81 --- /dev/null +++ b/docs/stackit_alpha_folder_describe.md @@ -0,0 +1,47 @@ +## stackit alpha folder describe + +Shows details of a STACKIT folder + +### Synopsis + +Shows details of a STACKIT folder. + +``` +stackit alpha folder describe [flags] +``` + +### Examples + +``` + Get the details of the configured STACKIT folder + $ stackit folder describe + + Get the details of a STACKIT folder by explicitly providing the container ID + $ stackit folder describe xxx + + Get the details of the configured STACKIT folder, including details of the parent resources + $ stackit folder describe --include-parents +``` + +### Options + +``` + -h, --help Help for "stackit alpha folder describe" + --include-parents When true, the details of the parent resources will be included in the output +``` + +### 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 alpha folder](./stackit_alpha_folder.md) - Manages the lifecycle of folders. + diff --git a/docs/stackit_alpha_folder_list.md b/docs/stackit_alpha_folder_list.md new file mode 100644 index 000000000..5240d8f49 --- /dev/null +++ b/docs/stackit_alpha_folder_list.md @@ -0,0 +1,55 @@ +## stackit alpha folder list + +Lists STACKIT folders + +### Synopsis + +Lists all STACKIT folders that match certain criteria. + +``` +stackit alpha folder list [flags] +``` + +### Examples + +``` + List all STACKIT folders that are children of the configured project + $ stackit folder list + + List all STACKIT folders that are children of a specific parent + $ stackit folder list --parent-id xxx + + List all STACKIT folders that match the given project IDs, located under the same parent resource + $ stackit folder list --project-id-like xxx,yyy,zzz + + List all STACKIT folders that a certain user is a member of + $ stackit folder list --member example@email.com +``` + +### Options + +``` + --creation-time-after string Filter by creation timestamp, in a date-time with the RFC3339 layout format, e.g. 2023-01-01T00:00:00Z. The list of folders that were created after the given timestamp will be shown + -h, --help Help for "stackit alpha folder list" + --limit int Maximum number of entries to list + --member string Filter by member. The list of folders of which the member is part of will be shown + --page-size int Number of items fetched in each API call. Does not affect the number of items in the command output (default 50) + --parent-id string Filter by parent identifier + --project-id-like strings Filter by project identifier. Multiple project IDs can be provided, but they need to belong to the same parent resource (default []) +``` + +### 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 alpha folder](./stackit_alpha_folder.md) - Manages the lifecycle of folders. + diff --git a/internal/cmd/alpha/alpha.go b/internal/cmd/alpha/alpha.go index dac1d5ce1..5d7a655aa 100644 --- a/internal/cmd/alpha/alpha.go +++ b/internal/cmd/alpha/alpha.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/folder" "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,5 +36,6 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(folder.NewCmd(params)) cmd.AddCommand(vpc.NewCmd(params)) } diff --git a/internal/cmd/alpha/folder/describe/describe.go b/internal/cmd/alpha/folder/describe/describe.go new file mode 100644 index 000000000..92a2d1041 --- /dev/null +++ b/internal/cmd/alpha/folder/describe/describe.go @@ -0,0 +1,144 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/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/resourcemanager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + resourcemanager "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/v0api" +) + +const ( + includeParentsFlag = "include-parents" + + containerIdArg = "CONTAINER_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ArgContainerId string + IncludeParents bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Shows details of a STACKIT folder", + Long: "Shows details of a STACKIT folder.", + Args: args.SingleOptionalArg(containerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get the details of the configured STACKIT folder`, + "$ stackit folder describe"), + examples.NewExample( + `Get the details of a STACKIT folder by explicitly providing the container ID`, + "$ stackit folder describe xxx"), + examples.NewExample( + `Get the details of the configured STACKIT folder, including details of the parent resources`, + "$ stackit folder describe --include-parents"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read folder details: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(includeParentsFlag, false, "When true, the details of the parent resources will be included in the output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + var containerId string + if len(inputArgs) > 0 { + containerId = inputArgs[0] + } + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" && containerId == "" { + return nil, fmt.Errorf("container ID needs to be provided either as an argument or as a flag") + } + + if containerId == "" { + containerId = globalFlags.ProjectId + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ArgContainerId: containerId, + IncludeParents: flags.FlagToBoolValue(p, cmd, includeParentsFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiGetFolderDetailsRequest { + req := apiClient.DefaultAPI.GetFolderDetails(ctx, model.ArgContainerId) + req.IncludeParents(model.IncludeParents) + return req +} + +func outputResult(p *print.Printer, outputFormat string, folder *resourcemanager.GetFolderDetailsResponse) error { + if folder == nil { + return fmt.Errorf("response not set") + } + + return p.OutputResult(outputFormat, folder, func() error { + table := tables.NewTable() + table.AddRow("ID", folder.FolderId) + table.AddSeparator() + table.AddRow("NAME", folder.Name) + table.AddSeparator() + table.AddRow("CREATION", folder.CreationTime) + table.AddSeparator() + table.AddRow("CONTAINER ID", folder.ContainerId) + table.AddSeparator() + table.AddRow("PARENT ID", folder.Parent.Id) + + var labels string + if folder.Labels != nil { + labels = utils.JoinStringMap(*folder.Labels, "=", ", ") + } + table.AddSeparator() + table.AddRow("LABELS", labels) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/folder/describe/describe_test.go b/internal/cmd/alpha/folder/describe/describe_test.go new file mode 100644 index 000000000..2cabf48cf --- /dev/null +++ b/internal/cmd/alpha/folder/describe/describe_test.go @@ -0,0 +1,203 @@ +package describe + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + resourcemanager "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/v0api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var containerIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{DefaultAPI: &resourcemanager.DefaultAPIService{}} +var testFolderId = uuid.NewString() +var testFolderId2 = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testFolderId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + includeParentsFlag: "false", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: "", + Verbosity: globalflags.VerbosityDefault, + }, + IncludeParents: false, + ArgContainerId: testFolderId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiGetFolderDetailsRequest)) resourcemanager.ApiGetFolderDetailsRequest { + request := testClient.DefaultAPI.GetFolderDetails(testCtx, testFolderId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + labelValues []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: "container id arg takes precedence", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[containerIdFlag] = testFolderId2 + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = testFolderId2 + }), + }, + { + description: "container id arg missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[containerIdFlag] = testFolderId + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = testFolderId + }), + }, + { + description: "project id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, containerIdFlag) + }), + isValid: false, + }, + { + description: "container id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[containerIdFlag] = "" + }), + isValid: false, + }, + { + description: "container id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[containerIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiGetFolderDetailsRequest + }{ + { + 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, resourcemanager.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + folder *resourcemanager.GetFolderDetailsResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"empty", args{}, true}, + {"base", args{"", &resourcemanager.GetFolderDetailsResponse{}}, false}, + {"complete", args{"", &resourcemanager.GetFolderDetailsResponse{ + FolderId: "4711", + Name: "name", + CreationTime: time.Now(), + ContainerId: "4646", + Parent: resourcemanager.Parent{Id: "parent id"}, + Labels: &map[string]string{"testlabel1": "one", "testLabel2": "two"}, + }, + }, false}, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.folder); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/alpha/folder/folder.go b/internal/cmd/alpha/folder/folder.go new file mode 100644 index 000000000..6791dbf42 --- /dev/null +++ b/internal/cmd/alpha/folder/folder.go @@ -0,0 +1,33 @@ +package folder + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/folder/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/folder/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "folder", + Short: "Manages the lifecycle of folders.", + Long: fmt.Sprintf("%s\n%s", + "Manages the lifecycle of folders.", + "A folder provides a structure for organizing projects within an organization.", + ), + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/alpha/folder/list/list.go b/internal/cmd/alpha/folder/list/list.go new file mode 100644 index 000000000..5c4ee4375 --- /dev/null +++ b/internal/cmd/alpha/folder/list/list.go @@ -0,0 +1,232 @@ +package list + +import ( + "context" + "fmt" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + resourcemanager "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/v0api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "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/resourcemanager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + parentIdFlag = "parent-id" + projectIdLikeFlag = "project-id-like" + memberFlag = "member" + creationTimeAfterFlag = "creation-time-after" + limitFlag = "limit" + pageSizeFlag = "page-size" + + creationTimeAfterFormat = time.RFC3339 + pageSizeDefault = 50 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ParentId *string + ProjectIdLike []string + Member *string + CreationTimeAfter *time.Time + Limit *int64 + PageSize int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists STACKIT folders", + Long: "Lists all STACKIT folders that match certain criteria.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT folders that are children of the configured project`, + "$ stackit folder list"), + examples.NewExample( + `List all STACKIT folders that are children of a specific parent`, + "$ stackit folder list --parent-id xxx"), + examples.NewExample( + `List all STACKIT folders that match the given project IDs, located under the same parent resource`, + "$ stackit folder list --project-id-like xxx,yyy,zzz"), + examples.NewExample( + `List all STACKIT folders that a certain user is a member of`, + "$ stackit folder list --member example@email.com"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Fetch folders + folders, err := fetchFolders(ctx, model, apiClient.DefaultAPI) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, folders) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(parentIdFlag, "", "Filter by parent identifier") + cmd.Flags().Var(flags.UUIDSliceFlag(), projectIdLikeFlag, "Filter by project identifier. Multiple project IDs can be provided, but they need to belong to the same parent resource") + cmd.Flags().String(memberFlag, "", "Filter by member. The list of folders of which the member is part of will be shown") + cmd.Flags().String(creationTimeAfterFlag, "", "Filter by creation timestamp, in a date-time with the RFC3339 layout format, e.g. 2023-01-01T00:00:00Z. The list of folders that were created after the given timestamp will be shown") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Int64(pageSizeFlag, pageSizeDefault, "Number of items fetched in each API call. Does not affect the number of items in the command output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + creationTimeAfter, err := flags.FlagToDateTimePointer(p, cmd, creationTimeAfterFlag, creationTimeAfterFormat) + if err != nil { + return nil, &errors.FlagValidationError{ + Flag: creationTimeAfterFlag, + Details: err.Error(), + } + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + pageSize := flags.FlagWithDefaultToInt64Value(p, cmd, pageSizeFlag) + if pageSize < 1 { + return nil, &errors.FlagValidationError{ + Flag: pageSizeFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ParentId: flags.FlagToStringPointer(p, cmd, parentIdFlag), + ProjectIdLike: flags.FlagToStringSliceValue(p, cmd, projectIdLikeFlag), + Member: flags.FlagToStringPointer(p, cmd, memberFlag), + CreationTimeAfter: creationTimeAfter, + Limit: limit, + PageSize: pageSize, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient resourceManagerClient, offset int) (resourcemanager.ApiListFoldersRequest, error) { + req := apiClient.ListFolders(ctx) + if model.ParentId != nil { + req = req.ContainerParentId(*model.ParentId) + } + if model.Member != nil { + req = req.Member(*model.Member) + } + if model.CreationTimeAfter != nil { + req = req.CreationTimeStart(*model.CreationTimeAfter) + } + + if model.ParentId == nil && model.ProjectIdLike == nil && model.Member == nil { + email, err := auth.GetAuthEmail() + if err != nil { + return req, fmt.Errorf("get email of authenticated user: %w", err) + } + req = req.Member(email) + } + req = req.Limit(float32(model.PageSize)) + req = req.Offset(float32(offset)) + return req, nil +} + +type resourceManagerClient interface { + ListFolders(ctx context.Context) resourcemanager.ApiListFoldersRequest +} + +func fetchFolders(ctx context.Context, model *inputModel, apiClient resourceManagerClient) ([]resourcemanager.ListFoldersResponseItemsInner, error) { + if model.Limit != nil && *model.Limit < model.PageSize { + model.PageSize = *model.Limit + } + + offset := 0 + folders := []resourcemanager.ListFoldersResponseItemsInner{} + for { + // Call API + req, err := buildRequest(ctx, model, apiClient, offset) + if err != nil { + return nil, fmt.Errorf("build list folders request: %w", err) + } + resp, err := req.Execute() + if err != nil { + return nil, fmt.Errorf("get folders: %w", err) + } + respFolders := resp.GetItems() + if len(respFolders) == 0 { + break + } + folders = append(folders, respFolders...) + // Stop if no more pages + if len(respFolders) < int(model.PageSize) { + break + } + + // Stop and truncate if limit is reached + if model.Limit != nil && len(folders) >= int(*model.Limit) { + folders = folders[:*model.Limit] + break + } + offset += int(model.PageSize) + } + return folders, nil +} + +func outputResult(p *print.Printer, outputFormat string, folders []resourcemanager.ListFoldersResponseItemsInner) error { + return p.OutputResult(outputFormat, folders, func() error { + if len(folders) == 0 { + p.Outputf("No folders found matching the criteria\n") + return nil + } + table := tables.NewTable() + table.SetHeader("ID", "NAME", "PARENT ID") + for i := range folders { + f := folders[i] + + table.AddRow( + f.FolderId, + f.Name, + f.Parent.Id, + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/folder/list/list_test.go b/internal/cmd/alpha/folder/list/list_test.go new file mode 100644 index 000000000..3a56e14a6 --- /dev/null +++ b/internal/cmd/alpha/folder/list/list_test.go @@ -0,0 +1,497 @@ +package list + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + resourcemanager "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager/v0api" + "github.com/zalando/go-keyring" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{DefaultAPI: &resourcemanager.DefaultAPIService{}} +var testParentId = uuid.NewString() +var testProjectIdLike = uuid.NewString() +var testCreationTimeAfter = "2023-01-01T00:00:00Z" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + parentIdFlag: testParentId, + memberFlag: "member", + creationTimeAfterFlag: testCreationTimeAfter, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + testCreationTimeAfter, err := time.Parse(creationTimeAfterFormat, testCreationTimeAfter) + if err != nil { + return &inputModel{} + } + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + ParentId: utils.Ptr(testParentId), + Member: utils.Ptr("member"), + CreationTimeAfter: utils.Ptr(testCreationTimeAfter), + PageSize: pageSizeDefault, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiListFoldersRequest)) resourcemanager.ApiListFoldersRequest { + request := testClient.DefaultAPI.ListFolders(testCtx) + request = request.ContainerParentId(testParentId) + + testCreationTimeAfter, err := time.Parse(creationTimeAfterFormat, testCreationTimeAfter) + if err != nil { + return resourcemanager.ApiListFoldersRequest{} + } + request = request.CreationTimeStart(testCreationTimeAfter) + request = request.Member("member") + request = request.Limit(pageSizeDefault) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + projectIdLikevalues *[]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "parentId empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[parentIdFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ParentId = utils.Ptr("") + }), + }, + { + description: "member empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[memberFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Member = utils.Ptr("") + }), + }, + { + description: "projectIdLike one value", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{testProjectIdLike}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = []string{testProjectIdLike} + }), + }, + { + description: "projectIdLike multiple values", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{testProjectIdLike, testProjectIdLike}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = []string{testProjectIdLike, testProjectIdLike} + }), + }, + { + description: "projectIdLike empty", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ParentId = nil + model.Member = nil + model.CreationTimeAfter = nil + }), + }, + { + description: "projectIdLike invalid", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{""}), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ParentId = nil + model.Member = nil + model.CreationTimeAfter = nil + }), + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "creationTimeAfter empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "" + }), + isValid: false, + }, + { + description: "creationTimeAfter invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "test" + }), + isValid: false, + }, + { + description: "creationTimeAfter invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "11:00 12/12/2023" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + projectIdLikeFlag: utils.GetSliceFromPointer(tt.projectIdLikevalues), + }, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + keyring.MockInit() + err := auth.SetAuthFlow(auth.AUTH_FLOW_USER_TOKEN) + if err != nil { + t.Fatalf("Failed to set auth flow: %v", err) + } + + err = auth.SetAuthField(auth.USER_EMAIL, "test@test.com") + if err != nil { + t.Fatalf("Failed to set auth user email: %v", err) + } + + authUserEmail, err := auth.GetAuthField(auth.USER_EMAIL) + if err != nil { + t.Fatalf("Failed to get auth user email: %v", err) + } + + tests := []struct { + description string + model *inputModel + projectIdLike []string + offset int + expectedRequest resourcemanager.ApiListFoldersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + offset: 1, + expectedRequest: fixtureRequest().Offset(1), + }, + { + description: "base 2", + model: fixtureInputModel(), + offset: 10, + expectedRequest: fixtureRequest().Offset(10), + }, + { + description: "fetch email from auth user", + model: &inputModel{ + PageSize: pageSizeDefault, + }, + offset: 1, + expectedRequest: testClient.DefaultAPI.ListFolders(testCtx).Offset(1).Limit(pageSizeDefault).Member(authUserEmail), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient.DefaultAPI, tt.offset) + if err != nil { + t.Fatalf("Failed to build request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, resourcemanager.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFetchFolders(t *testing.T) { + tests := []struct { + description string + model *inputModel + totalItems int + apiCallFails bool + expectedNumAPICalls int + expectedNumItems int + }{ + { + description: "no limit and pageSize>totalItems", + model: fixtureInputModel(), + totalItems: 10, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 10, + }, + { + description: "no limit and pageSizetotalItems and pageSize>totalItems", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(200)) + model.PageSize = 300 + }), + totalItems: 50, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 50, + }, + { + description: "limit>totalItems and pageSize= tt.totalItems { + numItemsToReturn = 0 // Total items reached + } else if offset+limit < tt.totalItems { + numItemsToReturn = limit // Full intermediate page + } else { + numItemsToReturn = tt.totalItems - offset // Last page + } + + folders := make([]resourcemanager.ListFoldersResponseItemsInner, numItemsToReturn) + mockedResp := resourcemanager.ListFoldersResponse{ + Items: folders, + } + + mockedRespBytes, err := json.Marshal(mockedResp) + if err != nil { + t.Fatalf("Failed to marshal mocked response: %v", err) + } + + _, err = w.Write(mockedRespBytes) + if err != nil { + t.Errorf("Failed to write response: %v", err) + } + }) + mockedServer := httptest.NewServer(handler) + defer mockedServer.Close() + client, err := resourcemanager.NewAPIClient( + sdkConfig.WithEndpoint(mockedServer.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + folders, err := fetchFolders(testCtx, tt.model, client.DefaultAPI) + if err != nil { + if !tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + return + } + if tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + if numAPICalls != tt.expectedNumAPICalls { + t.Fatalf("Expected %d API calls, got %d", tt.expectedNumAPICalls, numAPICalls) + } + if len(folders) != tt.expectedNumItems { + t.Fatalf("Expected %d projects, got %d", tt.totalItems, len(folders)) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + folders []resourcemanager.ListFoldersResponseItemsInner + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"empty", args{}, false}, + {"base", args{"", []resourcemanager.ListFoldersResponseItemsInner{{}}}, false}, + {"complete", args{"", []resourcemanager.ListFoldersResponseItemsInner{ + { + ContainerId: "container-id1", + CreationTime: time.Now(), + UpdateTime: time.Now(), + Labels: &map[string]string{"foo": "bar"}, + Name: "some name", + Parent: resourcemanager.Parent{ + Id: "parent-id", + }, + FolderId: "folder-id1", + }, + { + ContainerId: "container-id2", + CreationTime: time.Now(), + UpdateTime: time.Now(), + Labels: &map[string]string{"foo": "bar"}, + Name: "some name", + Parent: resourcemanager.Parent{ + Id: "parent-id", + }, + FolderId: "folder-id2", + }, + }}, false}, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.folders); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}