diff --git a/docs/stackit_beta_sfs.md b/docs/stackit_beta_sfs.md index 7067bb52b..349c383c7 100644 --- a/docs/stackit_beta_sfs.md +++ b/docs/stackit_beta_sfs.md @@ -32,6 +32,7 @@ stackit beta sfs [flags] * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies * [stackit beta sfs performance-class](./stackit_beta_sfs_performance-class.md) - Provides functionality for SFS performance classes +* [stackit beta sfs project-lock](./stackit_beta_sfs_project-lock.md) - Provides functionality for SFS project locks * [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools * [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares * [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots diff --git a/docs/stackit_beta_sfs_project-lock.md b/docs/stackit_beta_sfs_project-lock.md new file mode 100644 index 000000000..a7d86e91b --- /dev/null +++ b/docs/stackit_beta_sfs_project-lock.md @@ -0,0 +1,36 @@ +## stackit beta sfs project-lock + +Provides functionality for SFS project locks + +### Synopsis + +Provides functionality for SFS project locks. + +``` +stackit beta sfs project-lock [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs project-lock" +``` + +### 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 beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs project-lock describe](./stackit_beta_sfs_project-lock_describe.md) - Get lock status for a project +* [stackit beta sfs project-lock lock](./stackit_beta_sfs_project-lock_lock.md) - Enables lock for a project +* [stackit beta sfs project-lock unlock](./stackit_beta_sfs_project-lock_unlock.md) - Clean up lock for a project + diff --git a/docs/stackit_beta_sfs_project-lock_describe.md b/docs/stackit_beta_sfs_project-lock_describe.md new file mode 100644 index 000000000..eb3713cbc --- /dev/null +++ b/docs/stackit_beta_sfs_project-lock_describe.md @@ -0,0 +1,40 @@ +## stackit beta sfs project-lock describe + +Get lock status for a project + +### Synopsis + +Get lock status for a project. + +``` +stackit beta sfs project-lock describe [flags] +``` + +### Examples + +``` + Get lock status for project + $ stackit beta sfs project-lock describe +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs project-lock 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 beta sfs project-lock](./stackit_beta_sfs_project-lock.md) - Provides functionality for SFS project locks + diff --git a/docs/stackit_beta_sfs_project-lock_lock.md b/docs/stackit_beta_sfs_project-lock_lock.md new file mode 100644 index 000000000..9cdbcf402 --- /dev/null +++ b/docs/stackit_beta_sfs_project-lock_lock.md @@ -0,0 +1,40 @@ +## stackit beta sfs project-lock lock + +Enables lock for a project + +### Synopsis + +Enables lock for a project. Necessary for immutable snapshots and to prevent accidental deletion of resources. + +``` +stackit beta sfs project-lock lock [flags] +``` + +### Examples + +``` + Enable lock for project + $ stackit beta sfs project-lock lock +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs project-lock lock" +``` + +### 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 beta sfs project-lock](./stackit_beta_sfs_project-lock.md) - Provides functionality for SFS project locks + diff --git a/docs/stackit_beta_sfs_project-lock_unlock.md b/docs/stackit_beta_sfs_project-lock_unlock.md new file mode 100644 index 000000000..c3673f51d --- /dev/null +++ b/docs/stackit_beta_sfs_project-lock_unlock.md @@ -0,0 +1,40 @@ +## stackit beta sfs project-lock unlock + +Clean up lock for a project + +### Synopsis + +Clean up lock for a project. + +``` +stackit beta sfs project-lock unlock [flags] +``` + +### Examples + +``` + Disable lock for project + $ stackit beta sfs project-lock unlock +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs project-lock unlock" +``` + +### 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 beta sfs project-lock](./stackit_beta_sfs_project-lock.md) - Provides functionality for SFS project locks + diff --git a/internal/cmd/beta/sfs/project-lock/describe/describe.go b/internal/cmd/beta/sfs/project-lock/describe/describe.go new file mode 100644 index 000000000..ef558ee45 --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/describe/describe.go @@ -0,0 +1,115 @@ +package describe + +import ( + "context" + "fmt" + "net/http" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + + "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/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Get lock status for a project", + Long: "Get lock status for a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get lock status for project`, + "$ stackit beta sfs project-lock describe"), + ), + 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 + } + + 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 + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + oapiErr, _ := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if oapiErr.StatusCode == http.StatusNotFound { + fmt.Printf("No active lock found for project %s\n", projectLabel) + return nil + } + return fmt.Errorf("get lock status for project: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiGetLockRequest { + req := apiClient.DefaultAPI.GetLock(ctx, model.Region, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, resp *sfs.GetLockResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("response is empty") + } + + table := tables.NewTable() + table.AddRow("LOCK ID", utils.PtrString(resp.LockId)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/sfs/project-lock/describe/describe_test.go b/internal/cmd/beta/sfs/project-lock/describe/describe_test.go new file mode 100644 index 000000000..96804e4fe --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/describe/describe_test.go @@ -0,0 +1,177 @@ +package describe + +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/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{DefaultAPI: &sfs.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiGetLockRequest)) sfs.ApiGetLockRequest { + request := testClient.DefaultAPI.GetLock(testCtx, testRegion, 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: "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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiGetLockRequest + }{ + { + 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, sfs.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + complianceLock *sfs.GetLockResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: true, + }, + { + name: "set empty project lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &sfs.GetLockResponse{}, + }, + wantErr: false, + }, + { + name: "set filled lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &sfs.GetLockResponse{ + LockId: utils.Ptr(uuid.New().String()), + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.complianceLock); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/project-lock/lock/lock.go b/internal/cmd/beta/sfs/project-lock/lock/lock.go new file mode 100644 index 000000000..378d98d22 --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/lock/lock.go @@ -0,0 +1,104 @@ +package lock + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" + + "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/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "lock", + Short: "Enables lock for a project", + Long: "Enables lock for a project. Necessary for immutable snapshots and to prevent accidental deletion of resources.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Enable lock for project`, + "$ stackit beta sfs project-lock lock", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + 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 + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to enable SFS lock for project %s?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("enable SFS project lock: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiEnableLockRequest { + req := apiClient.DefaultAPI.EnableLock(ctx, model.Region, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *sfs.EnableLockResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("enable project lock response is empty") + } + + p.Outputf("Project \"%s\" is successfully locked.\n", projectLabel) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/project-lock/lock/lock_test.go b/internal/cmd/beta/sfs/project-lock/lock/lock_test.go new file mode 100644 index 000000000..224414bae --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/lock/lock_test.go @@ -0,0 +1,178 @@ +package lock + +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/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{DefaultAPI: &sfs.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiEnableLockRequest)) sfs.ApiEnableLockRequest { + request := testClient.DefaultAPI.EnableLock(testCtx, testRegion, 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: "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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiEnableLockRequest + }{ + { + 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, sfs.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + projectLock *sfs.EnableLockResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: true, + }, + { + name: "set empty project lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + projectLock: &sfs.EnableLockResponse{}, + }, + wantErr: false, + }, + { + name: "set filled lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + projectLock: &sfs.EnableLockResponse{ + LockId: utils.Ptr(uuid.New().String()), + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.projectLock); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/project-lock/project-lock.go b/internal/cmd/beta/sfs/project-lock/project-lock.go new file mode 100644 index 000000000..df98ea76f --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/project-lock.go @@ -0,0 +1,30 @@ +package projectlock + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/project-lock/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/project-lock/lock" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/project-lock/unlock" + "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: "project-lock", + Short: "Provides functionality for SFS project locks", + Long: "Provides functionality for SFS project locks.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(lock.NewCmd(params)) + cmd.AddCommand(unlock.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/project-lock/unlock/unlock.go b/internal/cmd/beta/sfs/project-lock/unlock/unlock.go new file mode 100644 index 000000000..fe11997fc --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/unlock/unlock.go @@ -0,0 +1,94 @@ +package unlock + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" + + "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/sfs/client" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlock", + Short: "Clean up lock for a project", + Long: "Clean up lock for a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Disable lock for project`, + "$ stackit beta sfs project-lock unlock"), + ), + 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 + } + + 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 + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to disable lock for project %s?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + _, err = buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("disable project lock: %w", err) + } + + params.Printer.Outputf("Project \"%s\" is successfully unlocked.\n", projectLabel) + + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiDisableLockRequest { + req := apiClient.DefaultAPI.DisableLock(ctx, model.Region, model.ProjectId) + return req +} diff --git a/internal/cmd/beta/sfs/project-lock/unlock/unlock_test.go b/internal/cmd/beta/sfs/project-lock/unlock/unlock_test.go new file mode 100644 index 000000000..def8b77e1 --- /dev/null +++ b/internal/cmd/beta/sfs/project-lock/unlock/unlock_test.go @@ -0,0 +1,128 @@ +package unlock + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{DefaultAPI: &sfs.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiDisableLockRequest)) sfs.ApiDisableLockRequest { + request := testClient.DefaultAPI.DisableLock(testCtx, testRegion, 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: "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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiDisableLockRequest + }{ + { + 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, sfs.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/sfs.go b/internal/cmd/beta/sfs/sfs.go index 2477e4843..43ecf780a 100644 --- a/internal/cmd/beta/sfs/sfs.go +++ b/internal/cmd/beta/sfs/sfs.go @@ -3,6 +3,7 @@ package sfs import ( exportpolicy "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy" performanceclass "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/performance-class" + projectlock "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/project-lock" resourcepool "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot" @@ -31,4 +32,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(exportpolicy.NewCmd(params)) cmd.AddCommand(snapshot.NewCmd(params)) cmd.AddCommand(performanceclass.NewCmd(params)) + cmd.AddCommand(projectlock.NewCmd(params)) }