From 7cd88626586e22387c0743e3871f179174271708 Mon Sep 17 00:00:00 2001 From: Devansh Thakur Date: Tue, 25 Nov 2025 08:58:26 +0100 Subject: [PATCH 1/2] intake and intake user on STACKIT cli --- .../cmd/beta/intake/instance/create/create.go | 249 +++++++++++ .../intake/instance/create/create_test.go | 396 ++++++++++++++++++ .../cmd/beta/intake/instance/delete/delete.go | 116 +++++ .../intake/instance/delete/delete_test.go | 203 +++++++++ .../beta/intake/instance/describe/describe.go | 145 +++++++ .../intake/instance/describe/describe_test.go | 238 +++++++++++ .../cmd/beta/intake/instance/list/list.go | 151 +++++++ .../beta/intake/instance/list/list_test.go | 240 +++++++++++ .../cmd/beta/intake/instance/update/update.go | 267 ++++++++++++ .../intake/instance/update/update_test.go | 283 +++++++++++++ internal/cmd/beta/intake/intake.go | 14 + .../cmd/beta/intake/user/create/create.go | 169 ++++++++ .../beta/intake/user/create/create_test.go | 293 +++++++++++++ .../cmd/beta/intake/user/delete/delete.go | 137 ++++++ .../beta/intake/user/delete/delete_test.go | 207 +++++++++ .../cmd/beta/intake/user/describe/describe.go | 146 +++++++ .../intake/user/describe/describe_test.go | 249 +++++++++++ internal/cmd/beta/intake/user/list/list.go | 163 +++++++ .../cmd/beta/intake/user/list/list_test.go | 244 +++++++++++ .../cmd/beta/intake/user/update/update.go | 190 +++++++++ .../beta/intake/user/update/update_test.go | 296 +++++++++++++ internal/cmd/beta/intake/user/user.go | 33 ++ 22 files changed, 4429 insertions(+) create mode 100644 internal/cmd/beta/intake/instance/create/create.go create mode 100644 internal/cmd/beta/intake/instance/create/create_test.go create mode 100644 internal/cmd/beta/intake/instance/delete/delete.go create mode 100644 internal/cmd/beta/intake/instance/delete/delete_test.go create mode 100644 internal/cmd/beta/intake/instance/describe/describe.go create mode 100644 internal/cmd/beta/intake/instance/describe/describe_test.go create mode 100644 internal/cmd/beta/intake/instance/list/list.go create mode 100644 internal/cmd/beta/intake/instance/list/list_test.go create mode 100644 internal/cmd/beta/intake/instance/update/update.go create mode 100644 internal/cmd/beta/intake/instance/update/update_test.go create mode 100644 internal/cmd/beta/intake/user/create/create.go create mode 100644 internal/cmd/beta/intake/user/create/create_test.go create mode 100644 internal/cmd/beta/intake/user/delete/delete.go create mode 100644 internal/cmd/beta/intake/user/delete/delete_test.go create mode 100644 internal/cmd/beta/intake/user/describe/describe.go create mode 100644 internal/cmd/beta/intake/user/describe/describe_test.go create mode 100644 internal/cmd/beta/intake/user/list/list.go create mode 100644 internal/cmd/beta/intake/user/list/list_test.go create mode 100644 internal/cmd/beta/intake/user/update/update.go create mode 100644 internal/cmd/beta/intake/user/update/update_test.go create mode 100644 internal/cmd/beta/intake/user/user.go diff --git a/internal/cmd/beta/intake/instance/create/create.go b/internal/cmd/beta/intake/instance/create/create.go new file mode 100644 index 000000000..b7f222742 --- /dev/null +++ b/internal/cmd/beta/intake/instance/create/create.go @@ -0,0 +1,249 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +const ( + displayNameFlag = "display-name" + runnerIdFlag = "runner-id" + descriptionFlag = "description" + labelsFlag = "labels" + catalogURIFlag = "catalog-uri" + catalogWarehouseFlag = "catalog-warehouse" + catalogNamespaceFlag = "catalog-namespace" + catalogTableNameFlag = "catalog-table-name" + catalogPartitioningFlag = "catalog-partitioning" + catalogPartitionByFlag = "catalog-partition-by" + catalogAuthTypeFlag = "catalog-auth-type" + dremioTokenEndpointFlag = "dremio-token-endpoint" //nolint:gosec // false positive + dremioPatFlag = "dremio-pat" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + + // Top-level fields + DisplayName *string + RunnerId *string + Description *string + Labels *map[string]string + + // Catalog fields + CatalogURI *string + CatalogWarehouse *string + CatalogNamespace *string + CatalogTableName *string + CatalogPartitioning *string + CatalogPartitionBy *[]string + + // Auth fields + CatalogAuthType *string + DremioTokenEndpoint *string + DremioToken *string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake", + Long: "Creates a new Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake with required parameters`, + `$ stackit beta intake create --display-name my-intake --runner-id xxx --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse"`), + examples.NewExample( + `Create a new Intake with a description, labels, and Dremio authentication`, + `$ stackit beta intake create --display-name my-intake --runner-id xxx --description "Production intake" --labels "env=prod,team=billing" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-auth-type "dremio" --dremio-token-endpoint "https://auth.dremio.cloud/oauth/token" --dremio-pat "MY_TOKEN"`), + examples.NewExample( + `Create a new Intake with manual partitioning by a date field`, + `$ stackit beta intake create --display-name my-partitioned-intake --runner-id xxx --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-partitioning "manual" --catalog-partition-by "day(__intake_ts)"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an Intake for project %q?", projectLabel) + err = p.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("create Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Creating STACKIT Intake instance") + _, err = wait.CreateOrUpdateIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + // Top-level flags + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), runnerIdFlag, "The UUID of the Intake Runner to use") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas. Example: --labels \"key1=value1,key2=value2\"") + + // Catalog flags + cmd.Flags().String(catalogURIFlag, "", "The URI to the Iceberg catalog endpoint") + cmd.Flags().String(catalogWarehouseFlag, "", "The Iceberg warehouse to connect to") + cmd.Flags().String(catalogNamespaceFlag, "", "The namespace to which data shall be written (default: 'intake')") + cmd.Flags().String(catalogTableNameFlag, "", "The table name to identify the table in Iceberg") + cmd.Flags().String(catalogPartitioningFlag, "", "The target table's partitioning. One of 'none', 'intake-time', 'manual'") + cmd.Flags().StringSlice(catalogPartitionByFlag, nil, "List of Iceberg partitioning expressions. Only used when --catalog-partitioning is 'manual'") + + // Auth flags + cmd.Flags().String(catalogAuthTypeFlag, "", "Authentication type for the catalog (e.g., 'none', 'dremio')") + cmd.Flags().String(dremioTokenEndpointFlag, "", "Dremio OAuth 2.0 token endpoint URL. Required if auth-type is 'dremio'") + cmd.Flags().String(dremioPatFlag, "", "Dremio personal access token. Required if auth-type is 'dremio'") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, runnerIdFlag, catalogURIFlag, catalogWarehouseFlag, catalogAuthTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + // Top-level fields + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + RunnerId: flags.FlagToStringPointer(p, cmd, runnerIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + + // Catalog fields + CatalogURI: flags.FlagToStringPointer(p, cmd, catalogURIFlag), + CatalogWarehouse: flags.FlagToStringPointer(p, cmd, catalogWarehouseFlag), + CatalogNamespace: flags.FlagToStringPointer(p, cmd, catalogNamespaceFlag), + CatalogTableName: flags.FlagToStringPointer(p, cmd, catalogTableNameFlag), + CatalogPartitioning: flags.FlagToStringPointer(p, cmd, catalogPartitioningFlag), + CatalogPartitionBy: flags.FlagToStringSlicePointer(p, cmd, catalogPartitionByFlag), + + // Auth fields + CatalogAuthType: flags.FlagToStringPointer(p, cmd, catalogAuthTypeFlag), + DremioTokenEndpoint: flags.FlagToStringPointer(p, cmd, dremioTokenEndpointFlag), + DremioToken: flags.FlagToStringPointer(p, cmd, dremioPatFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeRequest { + req := apiClient.CreateIntake(ctx, model.ProjectId, model.Region) + + // Build catalog authentication + var catalogAuth *intake.CatalogAuth + if model.CatalogAuthType != nil { + authType := intake.CatalogAuthType(*model.CatalogAuthType) + catalogAuth = &intake.CatalogAuth{ + Type: &authType, + } + if *model.CatalogAuthType == "dremio" { + catalogAuth.Dremio = &intake.DremioAuth{ + TokenEndpoint: model.DremioTokenEndpoint, + PersonalAccessToken: model.DremioToken, + } + } + } + + var partitioning *intake.PartitioningType + if model.CatalogPartitioning != nil { + partitioning = utils.Ptr(intake.PartitioningType(*model.CatalogPartitioning)) + } + + // Build catalog + catalogPayload := intake.IntakeCatalog{ + Uri: model.CatalogURI, + Warehouse: model.CatalogWarehouse, + Namespace: model.CatalogNamespace, + TableName: model.CatalogTableName, + Partitioning: partitioning, + PartitionBy: model.CatalogPartitionBy, + Auth: catalogAuth, + } + + // Build main payload + payload := intake.CreateIntakePayload{ + DisplayName: model.DisplayName, + IntakeRunnerId: model.RunnerId, + Description: model.Description, + Labels: model.Labels, + Catalog: &catalogPayload, + } + req = req.CreateIntakePayload(payload) + + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake for project %q, but no intake ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake for project %q. Intake ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/instance/create/create_test.go b/internal/cmd/beta/intake/instance/create/create_test.go new file mode 100644 index 000000000..cd5ea7fb0 --- /dev/null +++ b/internal/cmd/beta/intake/instance/create/create_test.go @@ -0,0 +1,396 @@ +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/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + + testDisplayName = "testintake" + testDescription = "This is a test intake" + testLabelsString = "env=test,team=dev" + testCatalogURI = "http://dremio.example.com" + testCatalogWarehouse = "my-warehouse" + testCatalogNamespace = "test-namespace" + testCatalogTableName = "test-table" + testCatalogPartitioning = "manual" + testCatalogPartitionByFlag = "year,month" + testCatalogAuthType = "dremio" + testDremioTokenEndpoint = "https://auth.dremio.cloud/oauth/token" //nolint:gosec // false url + testDremioToken = "dremio-secret-token" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} + testCatalogPartitionBy = []string{"year", "month"} +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + runnerIdFlag: testRunnerId, + descriptionFlag: testDescription, + labelsFlag: testLabelsString, + catalogURIFlag: testCatalogURI, + catalogWarehouseFlag: testCatalogWarehouse, + catalogNamespaceFlag: testCatalogNamespace, + catalogTableNameFlag: testCatalogTableName, + catalogPartitionByFlag: testCatalogPartitionByFlag, + catalogPartitioningFlag: testCatalogPartitioning, + catalogAuthTypeFlag: testCatalogAuthType, + dremioTokenEndpointFlag: testDremioTokenEndpoint, + dremioPatFlag: testDremioToken, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + RunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + CatalogURI: utils.Ptr(testCatalogURI), + CatalogWarehouse: utils.Ptr(testCatalogWarehouse), + CatalogNamespace: utils.Ptr(testCatalogNamespace), + CatalogTableName: utils.Ptr(testCatalogTableName), + CatalogPartitionBy: utils.Ptr(testCatalogPartitionBy), + CatalogPartitioning: utils.Ptr(testCatalogPartitioning), + CatalogAuthType: utils.Ptr(testCatalogAuthType), + DremioTokenEndpoint: utils.Ptr(testDremioTokenEndpoint), + DremioToken: utils.Ptr(testDremioToken), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakePayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakePayload)) intake.CreateIntakePayload { + authType := intake.CatalogAuthType(testCatalogAuthType) + testPartitioningType := intake.PartitioningType(testCatalogPartitioning) + payload := intake.CreateIntakePayload{ + DisplayName: utils.Ptr(testDisplayName), + IntakeRunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + Catalog: &intake.IntakeCatalog{ + Uri: utils.Ptr(testCatalogURI), + Warehouse: utils.Ptr(testCatalogWarehouse), + Namespace: utils.Ptr(testCatalogNamespace), + TableName: utils.Ptr(testCatalogTableName), + Partitioning: &testPartitioningType, + PartitionBy: utils.Ptr(testCatalogPartitionBy), + Auth: &intake.CatalogAuth{ + Type: &authType, + Dremio: &intake.DremioAuth{ + TokenEndpoint: utils.Ptr(testDremioTokenEndpoint), + PersonalAccessToken: utils.Ptr(testDremioToken), + }, + }, + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeRequest)) intake.ApiCreateIntakeRequest { + request := testClient.CreateIntake(testCtx, testProjectId, testRegion) + request = request.CreateIntakePayload(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: "runner-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, runnerIdFlag) + }), + isValid: false, + }, + { + description: "catalog-uri missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, catalogURIFlag) + }), + isValid: false, + }, + { + description: "catalog-warehouse missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, catalogWarehouseFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + runnerIdFlag: testRunnerId, + catalogURIFlag: testCatalogURI, + catalogWarehouseFlag: testCatalogWarehouse, + catalogAuthTypeFlag: testCatalogAuthType, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.CatalogNamespace = nil + model.CatalogTableName = nil + model.CatalogPartitioning = nil + model.CatalogPartitionBy = nil + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + }, + } + + 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 intake.ApiCreateIntakeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.CatalogNamespace = nil + model.CatalogTableName = nil + model.CatalogPartitioning = nil + model.CatalogPartitionBy = nil + model.CatalogAuthType = nil + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRequest) { + *request = (*request).CreateIntakePayload(fixtureCreatePayload(func(payload *intake.CreateIntakePayload) { + payload.Description = nil + payload.Labels = nil + payload.Catalog.Namespace = nil + payload.Catalog.TableName = nil + payload.Catalog.PartitionBy = nil + payload.Catalog.Partitioning = nil + payload.Catalog.Auth = nil + })) + }), + }, + { + description: "auth type none", + model: fixtureInputModel(func(model *inputModel) { + model.CatalogAuthType = utils.Ptr("none") + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRequest) { + *request = (*request).CreateIntakePayload(fixtureCreatePayload(func(payload *intake.CreateIntakePayload) { + authType := intake.CatalogAuthType("none") + payload.Catalog.Auth.Type = &authType + payload.Catalog.Auth.Dremio = nil + })) + }), + }, + } + + 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 { + model *inputModel + projectLabel string + resp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.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.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/instance/delete/delete.go b/internal/cmd/beta/intake/instance/delete/delete.go new file mode 100644 index 000000000..c138f378d --- /dev/null +++ b/internal/cmd/beta/intake/instance/delete/delete.go @@ -0,0 +1,116 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + intakeIdArg = "INTAKE_ID" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string +} + +// NewCmd creates a new cobra command for deleting an Intake +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", intakeIdArg), + Short: "Deletes an Intake", + Long: "Deletes an Intake.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake with ID "xxx"`, + `$ stackit beta intake delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete Intake %q?", model.IntakeId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Deleting STACKIT Intake instance") + _, err = wait.DeleteIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Info("%s stackit Intake instance %s \n", operationState, model.IntakeId) + + return nil + }, + } + return cmd +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeRequest { + req := apiClient.DeleteIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + return req +} diff --git a/internal/cmd/beta/intake/instance/delete/delete_test.go b/internal/cmd/beta/intake/instance/delete/delete_test.go new file mode 100644 index 000000000..5d088ed6c --- /dev/null +++ b/internal/cmd/beta/intake/instance/delete/delete_test.go @@ -0,0 +1,203 @@ +package delete + +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/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIntakeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// fixtureFlagValues generates a map of flag values for tests +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 +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeRequest)) intake.ApiDeleteIntakeRequest { + request := testClient.DeleteIntake(testCtx, testProjectId, testRegion, testIntakeId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %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 intake.ApiDeleteIntakeRequest + }{ + { + 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/beta/intake/instance/describe/describe.go b/internal/cmd/beta/intake/instance/describe/describe.go new file mode 100644 index 000000000..4f266fcca --- /dev/null +++ b/internal/cmd/beta/intake/instance/describe/describe.go @@ -0,0 +1,145 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdArg = "INTAKE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", intakeIdArg), + Short: "Shows details of an Intake", + Long: "Shows details of an Intake.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake with ID "xxx"`, + `$ stackit beta intake describe xxx`), + examples.NewExample( + `Get details of an Intake with ID "xxx" in JSON format`, + `$ stackit beta intake describe xxx --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeRequest { + req := apiClient.GetIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, intake *intake.IntakeResponse) error { + if intake == nil { + return fmt.Errorf("received nil response, could not display details") + } + + return p.OutputResult(outputFormat, intake, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", intake.GetId()) + table.AddRow("Name", intake.GetDisplayName()) + table.AddRow("State", intake.GetState()) + table.AddRow("Runner ID", intake.GetIntakeRunnerId()) + table.AddRow("Created", intake.GetCreateTime()) + table.AddRow("Labels", intake.GetLabels()) + + if description := intake.GetDescription(); description != "" { + table.AddRow("Description", description) + } + + if failureMessage := intake.GetFailureMessage(); failureMessage != "" { + table.AddRow("Failure Message", failureMessage) + } + + table.AddSeparator() + table.AddRow("Ingestion URI", intake.GetUri()) + table.AddRow("Topic", intake.GetTopic()) + table.AddRow("Dead Letter Topic", intake.GetDeadLetterTopic()) + table.AddRow("Undelivered Messages", intake.GetUndeliveredMessageCount()) + + table.AddSeparator() + catalog := intake.GetCatalog() + table.AddRow("Catalog URI", catalog.GetUri()) + table.AddRow("Catalog Warehouse", catalog.GetWarehouse()) + if namespace := catalog.GetNamespace(); namespace != "" { + table.AddRow("Catalog Namespace", namespace) + } + if tableName := catalog.GetTableName(); tableName != "" { + table.AddRow("Catalog Table Name", tableName) + } + table.AddRow("Catalog Partitioning", catalog.GetPartitioning()) + if partitionBy := catalog.GetPartitionBy(); partitionBy != nil && len(*partitionBy) > 0 { + table.AddRow("Catalog Partition By", strings.Join(*partitionBy, ", ")) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/intake/instance/describe/describe_test.go b/internal/cmd/beta/intake/instance/describe/describe_test.go new file mode 100644 index 000000000..7d84ee6d0 --- /dev/null +++ b/internal/cmd/beta/intake/instance/describe/describe_test.go @@ -0,0 +1,238 @@ +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIntakeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + 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, + }, + IntakeId: testIntakeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeRequest)) intake.ApiGetIntakeRequest { + request := testClient.GetIntake(testCtx, testProjectId, testRegion, testIntakeId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %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 intake.ApiGetIntakeRequest + }{ + { + 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 + intakeResp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "nil response", + args: args{intakeResp: nil}, + 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.intakeResp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/instance/list/list.go b/internal/cmd/beta/intake/instance/list/list.go new file mode 100644 index 000000000..17d70cf93 --- /dev/null +++ b/internal/cmd/beta/intake/instance/list/list.go @@ -0,0 +1,151 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intakes +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intakes", + Long: "Lists all Intakes for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Intakes`, + `$ stackit beta intake list`), + examples.NewExample( + `List all Intakes in JSON format`, + `$ stackit beta intake list --output-format json`), + examples.NewExample( + `List up to 5 Intakes`, + `$ stackit beta intake list --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intakes: %w", err) + } + intakes := resp.GetIntakes() + + // Truncate output + if model.Limit != nil && len(intakes) > int(*model.Limit) { + intakes = intakes[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(intakes) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, intakes) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the --limit flag to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intakes +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakesRequest { + req := apiClient.ListIntakes(ctx, model.ProjectId, model.Region) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel string, intakes []intake.IntakeResponse) error { + return p.OutputResult(outputFormat, intakes, func() error { + if len(intakes) == 0 { + p.Outputf("No intakes found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "RUNNER ID") + for i := range intakes { + intakeItem := intakes[i] + table.AddRow( + intakeItem.GetId(), + intakeItem.GetDisplayName(), + intakeItem.GetState(), + intakeItem.GetIntakeRunnerId(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/instance/list/list_test.go b/internal/cmd/beta/intake/instance/list/list_test.go new file mode 100644 index 000000000..53f2bd74f --- /dev/null +++ b/internal/cmd/beta/intake/instance/list/list_test.go @@ -0,0 +1,240 @@ +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testLimit = int64(5) +) + +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 *intake.ApiListIntakesRequest)) intake.ApiListIntakesRequest { + request := testClient.ListIntakes(testCtx, testProjectId, testRegion) + 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: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-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 intake.ApiListIntakesRequest + }{ + { + 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 + projectLabel string + intakes []intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{intakes: nil}, + wantErr: false, + }, + { + name: "empty intake in slice", + args: args{ + intakes: []intake.IntakeResponse{{}}, + }, + wantErr: false, + }, + { + name: "with project label", + args: args{ + projectLabel: "my-project", + intakes: []intake.IntakeResponse{}, + }, + 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.projectLabel, tt.args.intakes); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/instance/update/update.go b/internal/cmd/beta/intake/instance/update/update.go new file mode 100644 index 000000000..3c555daf6 --- /dev/null +++ b/internal/cmd/beta/intake/instance/update/update.go @@ -0,0 +1,267 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdArg = "INTAKE_ID" + + // Top-level flags + displayNameFlag = "display-name" + runnerIdFlag = "runner-id" + descriptionFlag = "description" + labelsFlag = "labels" + + // Catalog flags + catalogURIFlag = "catalog-uri" + catalogWarehouseFlag = "catalog-warehouse" + catalogNamespaceFlag = "catalog-namespace" + catalogTableNameFlag = "catalog-table-name" + + // Auth flags + catalogAuthTypeFlag = "catalog-auth-type" + dremioTokenEndpointFlag = "dremio-token-endpoint" //nolint:gosec // false positive + dremioPatFlag = "dremio-pat" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + // Main + IntakeId string + DisplayName *string + RunnerId *string + Description *string + Labels *map[string]string + + // Catalog + CatalogURI *string + CatalogWarehouse *string + CatalogNamespace *string + CatalogTableName *string + + // Auth + CatalogAuthType *string + DremioTokenEndpoint *string + DremioToken *string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", intakeIdArg), + Short: "Updates an Intake", + Long: "Updates an Intake. Only the specified fields are updated.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake with ID "xxx"`, + `$ stackit beta intake update xxx --runner-id yyy --display-name new-intake-name`), + examples.NewExample( + `Update the catalog details for an Intake with ID "xxx"`, + `$ stackit beta intake update xxx --runner-id yyy --catalog-uri "http://new.uri" --catalog-warehouse "new-warehouse"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Updating STACKIT Intake Runner instance") + _, err = wait.CreateOrUpdateIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + // Top-level flags + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), runnerIdFlag, "The UUID of the Intake Runner to use") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) + + // Catalog flags + cmd.Flags().String(catalogURIFlag, "", "The URI to the Iceberg catalog endpoint") + cmd.Flags().String(catalogWarehouseFlag, "", "The Iceberg warehouse to connect to") + cmd.Flags().String(catalogNamespaceFlag, "", "The namespace to which data shall be written") + cmd.Flags().String(catalogTableNameFlag, "", "The table name to identify the table in Iceberg") + + // Auth flags + cmd.Flags().String(catalogAuthTypeFlag, "", "Authentication type for the catalog (e.g., 'none', 'dremio')") + cmd.Flags().String(dremioTokenEndpointFlag, "", "Dremio OAuth 2.0 token endpoint URL") + cmd.Flags().String(dremioPatFlag, "", "Dremio personal access token") + + err := flags.MarkFlagsRequired(cmd, runnerIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + RunnerId: flags.FlagToStringPointer(p, cmd, runnerIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + CatalogURI: flags.FlagToStringPointer(p, cmd, catalogURIFlag), + CatalogWarehouse: flags.FlagToStringPointer(p, cmd, catalogWarehouseFlag), + CatalogNamespace: flags.FlagToStringPointer(p, cmd, catalogNamespaceFlag), + CatalogTableName: flags.FlagToStringPointer(p, cmd, catalogTableNameFlag), + CatalogAuthType: flags.FlagToStringPointer(p, cmd, catalogAuthTypeFlag), + DremioTokenEndpoint: flags.FlagToStringPointer(p, cmd, dremioTokenEndpointFlag), + DremioToken: flags.FlagToStringPointer(p, cmd, dremioPatFlag), + } + + // Check if any optional flag was provided + if model.DisplayName == nil && model.Description == nil && model.Labels == nil && + model.CatalogURI == nil && model.CatalogWarehouse == nil && model.CatalogNamespace == nil && + model.CatalogTableName == nil && model.CatalogAuthType == nil && + model.DremioTokenEndpoint == nil && model.DremioToken == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeRequest { + req := apiClient.UpdateIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + + payload := intake.UpdateIntakePayload{ + IntakeRunnerId: model.RunnerId, // This is required by the API + DisplayName: model.DisplayName, + Description: model.Description, + Labels: model.Labels, + } + + // Build catalog patch payload only if catalog-related flags are set + catalogPatch := &intake.IntakeCatalogPatch{} + catalogNeedsPatching := false + + if model.CatalogURI != nil { + catalogPatch.Uri = model.CatalogURI + catalogNeedsPatching = true + } + if model.CatalogWarehouse != nil { + catalogPatch.Warehouse = model.CatalogWarehouse + catalogNeedsPatching = true + } + if model.CatalogNamespace != nil { + catalogPatch.Namespace = model.CatalogNamespace + catalogNeedsPatching = true + } + if model.CatalogTableName != nil { + catalogPatch.TableName = model.CatalogTableName + catalogNeedsPatching = true + } + + // Build auth patch payload only if auth-related flags are set + authPatch := &intake.CatalogAuthPatch{} + authNeedsPatching := false + + if model.CatalogAuthType != nil { + authType := intake.CatalogAuthType(*model.CatalogAuthType) + authPatch.Type = &authType + authNeedsPatching = true + } + + dremioPatch := &intake.DremioAuthPatch{} + dremioNeedsPatching := false + if model.DremioTokenEndpoint != nil { + dremioPatch.TokenEndpoint = model.DremioTokenEndpoint + dremioNeedsPatching = true + } + if model.DremioToken != nil { + dremioPatch.PersonalAccessToken = model.DremioToken + dremioNeedsPatching = true + } + + if dremioNeedsPatching { + authPatch.Dremio = dremioPatch + authNeedsPatching = true + } + + if authNeedsPatching { + catalogPatch.Auth = authPatch + catalogNeedsPatching = true + } + + if catalogNeedsPatching { + payload.Catalog = catalogPatch + } + + req = req.UpdateIntakePayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Updated Intake for project %q, but no intake ID was returned.\n", projectLabel) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake for project %q. Intake ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/instance/update/update_test.go b/internal/cmd/beta/intake/instance/update/update_test.go new file mode 100644 index 000000000..078f87c12 --- /dev/null +++ b/internal/cmd/beta/intake/instance/update/update_test.go @@ -0,0 +1,283 @@ +package update + +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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testIntakeId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + runnerIdFlag: testRunnerId, + displayNameFlag: "new-display-name", + } + 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, + }, + IntakeId: testIntakeId, + RunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("new-display-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no optional flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + runnerIdFlag: testRunnerId, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "new description" + flagValues[labelsFlag] = "env=prod,team=sre" + flagValues[catalogURIFlag] = "new-uri" + flagValues[catalogWarehouseFlag] = "new-warehouse" + flagValues[catalogNamespaceFlag] = "new-namespace" + flagValues[catalogTableNameFlag] = "new-table" + flagValues[catalogAuthTypeFlag] = "dremio" + flagValues[dremioTokenEndpointFlag] = "new-endpoint" + flagValues[dremioPatFlag] = "new-pat" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + model.CatalogURI = utils.Ptr("new-uri") + model.CatalogWarehouse = utils.Ptr("new-warehouse") + model.CatalogNamespace = utils.Ptr("new-namespace") + model.CatalogTableName = utils.Ptr("new-table") + model.CatalogAuthType = utils.Ptr("dremio") + model.DremioTokenEndpoint = utils.Ptr("new-endpoint") + model.DremioToken = utils.Ptr("new-pat") + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "runner-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, runnerIdFlag) + }), + 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 + expectedReq intake.ApiUpdateIntakeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("new-display-name"), + }), + }, + { + description: "update description and catalog uri", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + model.CatalogURI = utils.Ptr("new-uri") + }), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr("new-desc"), + Catalog: &intake.IntakeCatalogPatch{ + Uri: utils.Ptr("new-uri"), + }, + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + model.CatalogURI = utils.Ptr("final-uri") + model.CatalogWarehouse = utils.Ptr("final-warehouse") + model.CatalogNamespace = utils.Ptr("final-namespace") + model.CatalogTableName = utils.Ptr("final-table") + model.CatalogAuthType = utils.Ptr("dremio") + model.DremioTokenEndpoint = utils.Ptr("final-endpoint") + model.DremioToken = utils.Ptr("final-token") + }), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("another-name"), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + Catalog: &intake.IntakeCatalogPatch{ + Uri: utils.Ptr("final-uri"), + Warehouse: utils.Ptr("final-warehouse"), + Namespace: utils.Ptr("final-namespace"), + TableName: utils.Ptr("final-table"), + Auth: &intake.CatalogAuthPatch{ + Type: utils.Ptr(intake.CatalogAuthType("dremio")), + Dremio: &intake.DremioAuthPatch{ + TokenEndpoint: utils.Ptr("final-endpoint"), + PersonalAccessToken: utils.Ptr("final-token"), + }, + }, + }, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + 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 + intakeId string + resp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", intakeId: "intake-id-123", resp: &intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", 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, &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: tt.args.outputFormat}}, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/intake.go b/internal/cmd/beta/intake/intake.go index 96533f29c..c691ca734 100644 --- a/internal/cmd/beta/intake/intake.go +++ b/internal/cmd/beta/intake/intake.go @@ -2,7 +2,13 @@ package intake import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/instance/update" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user" "github.com/stackitcloud/stackit-cli/internal/cmd/params" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -23,4 +29,12 @@ func NewCmd(params *params.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(runner.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) + + // Intake instance subcommands + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) } diff --git a/internal/cmd/beta/intake/user/create/create.go b/internal/cmd/beta/intake/user/create/create.go new file mode 100644 index 000000000..3a76be8a4 --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create.go @@ -0,0 +1,169 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdFlag = "intake-id" + displayNameFlag = "display-name" + passwordFlag = "password" + descriptionFlag = "description" + typeFlag = "type" + labelsFlag = "labels" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + + IntakeId *string + DisplayName *string + Password *string + Description *string + Type *string + Labels *map[string]string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake User", + Long: "Creates a new Intake User, providing secure access credentials for applications to connect to a data stream.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake User with a display name and password for a specific Intake`, + `$ stackit beta intake user create --intake-id xxx --display-name my-intake-user --password "my-secret-password"`), + examples.NewExample( + `Create a new dead-letter queue user with a description and labels`, + `$ stackit beta intake user create --intake-id xxx --display-name my-dlq-reader --password "another-secret" --type "dead-letter" --description "User for reading undelivered messages" --labels "owner=team-alpha,scope=dlq"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a new User for Intake %q?", *model.IntakeId) + err = p.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("create Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Creating STACKIT Intake User instance") + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.IntakeId, resp.GetId()).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, *model.IntakeId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "ID of the Intake to which the user belongs") + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(passwordFlag, "", "User password") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().String(typeFlag, "", "Type of user, 'intake' for writing to the stream or 'dead-letter' for reading from the dead-letter queue") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas. Example: --labels \"key1=value1,key2=value2\"") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag, displayNameFlag, passwordFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Type: flags.FlagToStringPointer(p, cmd, typeFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeUserRequest { + req := apiClient.CreateIntakeUser(ctx, model.ProjectId, model.Region, *model.IntakeId) + + // Build main payload + payload := intake.CreateIntakeUserPayload{ + DisplayName: model.DisplayName, + Password: model.Password, + Description: model.Description, + Labels: model.Labels, + } + + if model.Type != nil { + payload.Type = (*intake.UserType)(model.Type) + } + + req = req.CreateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, intakeId string, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Created Intake User for Intake %q, but no intake ID was returned.\n", intakeId) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake User for Intake %q. User ID: %s\n", operationState, intakeId, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/create/create_test.go b/internal/cmd/beta/intake/user/create/create_test.go new file mode 100644 index 000000000..8faaae77d --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create_test.go @@ -0,0 +1,293 @@ +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + + testDisplayName = "testuser" + testPassword = "my-secret-password" + testDescription = "This is a test user" + testType = "intake" + testLabelsString = "env=test,owner=team-blue" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "owner": "team-blue"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + displayNameFlag: testDisplayName, + passwordFlag: testPassword, + descriptionFlag: testDescription, + typeFlag: testType, + labelsFlag: testLabelsString, + } + 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, + }, + IntakeId: utils.Ptr(testIntakeId), + DisplayName: utils.Ptr(testDisplayName), + Password: utils.Ptr(testPassword), + Description: utils.Ptr(testDescription), + Type: utils.Ptr(testType), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakeUserPayload)) intake.CreateIntakeUserPayload { + payload := intake.CreateIntakeUserPayload{ + DisplayName: utils.Ptr(testDisplayName), + Password: utils.Ptr(testPassword), + Description: utils.Ptr(testDescription), + Type: (*intake.UserType)(utils.Ptr(testType)), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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: "intake-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "display-name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "password missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, passwordFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + displayNameFlag: testDisplayName, + passwordFlag: testPassword, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Type = nil + model.Labels = nil + }), + }, + } + + 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiCreateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: testClient.CreateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId). + CreateIntakeUserPayload(fixtureCreatePayload()), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Type = nil + model.Labels = nil + }), + expectedReq: testClient.CreateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId). + CreateIntakeUserPayload(fixtureCreatePayload(func(payload *intake.CreateIntakeUserPayload) { + payload.Description = nil + payload.Type = nil + payload.Labels = nil + })), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + outputFormat string + intakeId string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeId: "intake-id-123", resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel()}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel()}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + })}, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{outputFormat: print.JSONOutputFormat, resp: nil, model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + })}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", intakeId: "intake-id-123", resp: nil, model: fixtureInputModel()}, + 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.intakeId, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/delete/delete.go b/internal/cmd/beta/intake/user/delete/delete.go new file mode 100644 index 000000000..429ca69a1 --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete.go @@ -0,0 +1,137 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +// NewCmd creates a new cobra command for deleting an Intake User +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Deletes an Intake User", + Long: "Deletes an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake User with ID "xxx" from an Intake with ID "yyy"`, + `$ stackit beta intake user delete xxx --intake-id yyy`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete Intake User %q from Intake %q?", model.UserId, model.IntakeId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake User: %w", err) + } + p.Printer.Info("Deleted user %s\n", model.UserId) + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Deleting STACKIT Intake User instance") + _, err = wait.DeleteIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Info("%s stackit Intake User instance %s \n", operationState, model.UserId) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(intakeIdFlag, "", "ID of the Intake to which the user belongs") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + intakeId := flags.FlagToStringValue(p, cmd, intakeIdFlag) + if intakeId == "" { + return nil, &cliErr.FlagValidationError{ + Flag: intakeIdFlag, + Details: "can't be empty", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake User +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeUserRequest { + req := apiClient.DeleteIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} diff --git a/internal/cmd/beta/intake/user/delete/delete_test.go b/internal/cmd/beta/intake/user/delete/delete_test.go new file mode 100644 index 000000000..af15dd6f4 --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete_test.go @@ -0,0 +1,207 @@ +package delete + +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + 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, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeUserRequest)) intake.ApiDeleteIntakeUserRequest { + request := testClient.DeleteIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiDeleteIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/describe/describe.go b/internal/cmd/beta/intake/user/describe/describe.go new file mode 100644 index 000000000..9578824df --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe.go @@ -0,0 +1,146 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Shows details of an Intake User", + Long: "Shows details of an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake User with ID "xxx" from an Intake with ID "yyy"`, + `$ stackit beta intake user describe xxx --intake-id yyy`), + examples.NewExample( + `Get details of an Intake User in JSON format`, + `$ stackit beta intake user describe xxx --intake-id yyy --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake User: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(intakeIdFlag, "", "ID of the Intake to which the user belongs") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + intakeId := flags.FlagToStringValue(p, cmd, intakeIdFlag) + if intakeId == "" { + return nil, &cliErr.FlagValidationError{ + Flag: intakeIdFlag, + Details: "can't be empty", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeUserRequest { + req := apiClient.GetIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, user *intake.IntakeUserResponse) error { + if user == nil { + return fmt.Errorf("received nil response, could not display details") + } + + return p.OutputResult(outputFormat, user, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", user.GetId()) + table.AddRow("Name", user.GetDisplayName()) + table.AddRow("State", user.GetState()) + table.AddRow("Created", user.GetCreateTime()) + table.AddRow("Labels", user.GetLabels()) + table.AddRow("Type", user.GetType()) + table.AddRow("Username", user.GetUser()) + + if description := user.GetDescription(); description != "" { + table.AddRow("Description", description) + } + table.AddSeparator() + + clientConfig := user.GetClientConfig() + table.AddRow("Java Client Config", clientConfig.GetJava()) + table.AddSeparator() + table.AddRow("librdkafka Client Config", clientConfig.GetLibrdkafka()) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/describe/describe_test.go b/internal/cmd/beta/intake/user/describe/describe_test.go new file mode 100644 index 000000000..83d28c6ce --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe_test.go @@ -0,0 +1,249 @@ +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + 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, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeUserRequest)) intake.ApiGetIntakeUserRequest { + request := testClient.GetIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiGetIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + userResp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", userResp: &intake.IntakeUserResponse{ClientConfig: &intake.ClientConfig{}}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, userResp: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, userResp: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil response", + args: args{userResp: nil}, + 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.userResp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/list/list.go b/internal/cmd/beta/intake/user/list/list.go new file mode 100644 index 000000000..ea926c4c9 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list.go @@ -0,0 +1,163 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + intakeIdFlag = "intake-id" + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intake Users +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intake Users for an Intake", + Long: "Lists all Intake Users for a specific Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Intake Users for an Intake with ID "xxx"`, + `$ stackit beta intake user list --intake-id xxx`), + examples.NewExample( + `List up to 5 Intake Users for an Intake with ID "xxx"`, + `$ stackit beta intake user list --intake-id xxx --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intake Users: %w", err) + } + users := resp.GetIntakeUsers() + + // Truncate output + if model.Limit != nil && len(users) > int(*model.Limit) { + users = users[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(users) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, users) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(intakeIdFlag, "", "ID of the Intake") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + intakeId := flags.FlagToStringValue(p, cmd, intakeIdFlag) + if intakeId == "" { + return nil, &cliErr.FlagValidationError{ + Flag: intakeIdFlag, + Details: "can't be empty", + } + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intake Users +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakeUsersRequest { + req := apiClient.ListIntakeUsers(ctx, model.ProjectId, model.Region, model.IntakeId) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel string, users []intake.IntakeUserResponse) error { + return p.OutputResult(outputFormat, users, func() error { + if len(users) == 0 { + p.Outputf("No intake users found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "TYPE", "USERNAME") + for i := range users { + user := users[i] + table.AddRow( + user.GetId(), + user.GetDisplayName(), + user.GetState(), + user.GetType(), + user.GetUser(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/list/list_test.go b/internal/cmd/beta/intake/user/list/list_test.go new file mode 100644 index 000000000..b8cc7d9d6 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list_test.go @@ -0,0 +1,244 @@ +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testLimit = int64(5) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + 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, + }, + IntakeId: testIntakeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakeUsersRequest)) intake.ApiListIntakeUsersRequest { + request := testClient.ListIntakeUsers(testCtx, testProjectId, testRegion, testIntakeId) + 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: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiListIntakeUsersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + users []intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{users: nil}, + wantErr: false, + }, + { + name: "empty intake user in slice", + args: args{ + users: []intake.IntakeUserResponse{{}}, + }, + wantErr: false, + }, + { + name: "some users", + args: args{ + users: []intake.IntakeUserResponse{ + {Id: utils.Ptr("id1"), DisplayName: utils.Ptr("name1")}, + {Id: utils.Ptr("id2"), DisplayName: utils.Ptr("name2")}, + }, + }, + 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, "dummy-projectlabel", tt.args.users); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/update/update.go b/internal/cmd/beta/intake/user/update/update.go new file mode 100644 index 000000000..800651bb5 --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update.go @@ -0,0 +1,190 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" + displayNameFlag = "display-name" + passwordFlag = "password" + descriptionFlag = "description" + typeFlag = "type" + labelFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string + DisplayName *string + Password *string + Description *string + Type *string + Labels *map[string]string +} + +func NewCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Updates an Intake User", + Long: "Updates an Intake User. Only the specified fields are updated.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake User with ID "xxx"`, + `$ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name"`), + examples.NewExample( + `Update the password of an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --password "new-secret"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update Intake User %q?", model.UserId) + err = p.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("update Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Updating STACKIT Intake User instance") + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, model.IntakeId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(intakeIdFlag, "", "ID of the Intake") + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(passwordFlag, "", "User password") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().String(typeFlag, "", "Type of user, 'intake' for writing or 'dead-letter' for reading from the dead-letter queue") + cmd.Flags().StringToString(labelFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + intakeId := flags.FlagToStringValue(p, cmd, intakeIdFlag) + if intakeId == "" { + return nil, &cliErr.FlagValidationError{ + Flag: intakeIdFlag, + Details: "can't be empty", + } + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + UserId: userId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Type: flags.FlagToStringPointer(p, cmd, typeFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + if model.DisplayName == nil && model.Password == nil && model.Description == nil && model.Type == nil && model.Labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeUserRequest { + req := apiClient.UpdateIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + + payload := intake.UpdateIntakeUserPayload{} + if model.DisplayName != nil { + payload.DisplayName = model.DisplayName + } + if model.Password != nil { + payload.Password = model.Password + } + if model.Description != nil { + payload.Description = model.Description + } + if model.Type != nil { + // This line is only reached if model.Type is not nil. Therefore, the conversion is safe. + payload.Type = (*intake.UserType)(model.Type) + } + if model.Labels != nil { + payload.Labels = model.Labels + } + + req = req.UpdateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, intakeId string, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Updated Intake User for Intake %q, but no intake ID was returned.\n", intakeId) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake User for Intake %q. User ID: %s\n", operationState, intakeId, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/update/update_test.go b/internal/cmd/beta/intake/user/update/update_test.go new file mode 100644 index 000000000..54f214009 --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update_test.go @@ -0,0 +1,296 @@ +package update + +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/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testUserId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + displayNameFlag: "new-user-name", + } + 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, + }, + IntakeId: testIntakeId, + UserId: testUserId, + DisplayName: utils.Ptr("new-user-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no optional flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + intakeIdFlag: testIntakeId, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[passwordFlag] = "new-secret" + flagValues[descriptionFlag] = "new description" + flagValues[typeFlag] = "dead-letter" + flagValues[labelFlag] = "env=prod,team=sre" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Password = utils.Ptr("new-secret") + model.Description = utils.Ptr("new description") + model.Type = utils.Ptr("dead-letter") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiUpdateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: testClient.UpdateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId). + UpdateIntakeUserPayload(intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("new-user-name"), + }), + }, + { + description: "update description and labels", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + expectedReq: testClient.UpdateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId). + UpdateIntakeUserPayload(intake.UpdateIntakeUserPayload{ + Description: utils.Ptr("new-desc"), + Labels: utils.Ptr(map[string]string{"key": "value"}), + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.Password = utils.Ptr("new-secret") + model.Description = utils.Ptr("final-desc") + model.Type = utils.Ptr("dead-letter") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + }), + expectedReq: testClient.UpdateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId). + UpdateIntakeUserPayload(intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("another-name"), + Password: utils.Ptr("new-secret"), + Description: utils.Ptr("final-desc"), + Type: (*intake.UserType)(utils.Ptr("dead-letter")), + Labels: utils.Ptr(map[string]string{"a": "b"}), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + outputFormat string + intakeId string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeId: "intake-id-123", resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel()}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + })}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + })}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", intakeId: "intake-id-123", resp: nil, model: fixtureInputModel()}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) // p.Cmd is needed for the printer to have context. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.intakeId, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/user.go b/internal/cmd/beta/intake/user/user.go new file mode 100644 index 000000000..b7e588c8e --- /dev/null +++ b/internal/cmd/beta/intake/user/user.go @@ -0,0 +1,33 @@ +package user + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/update" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Provides functionality for Intake Users", + Long: "Provides functionality for Intake Users.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + + // Pass the params down to each action command + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + + return cmd +} From bde9e089be59cda5f0ec5cb3c9b57f77716944e9 Mon Sep 17 00:00:00 2001 From: Devansh Thakur Date: Tue, 25 Nov 2025 16:15:14 +0100 Subject: [PATCH 2/2] generated docs --- docs/stackit_beta_intake.md | 6 +++ docs/stackit_beta_intake_create.md | 59 +++++++++++++++++++++++ docs/stackit_beta_intake_delete.md | 40 +++++++++++++++ docs/stackit_beta_intake_describe.md | 43 +++++++++++++++++ docs/stackit_beta_intake_list.md | 47 ++++++++++++++++++ docs/stackit_beta_intake_update.md | 54 +++++++++++++++++++++ docs/stackit_beta_intake_user.md | 38 +++++++++++++++ docs/stackit_beta_intake_user_create.md | 49 +++++++++++++++++++ docs/stackit_beta_intake_user_delete.md | 41 ++++++++++++++++ docs/stackit_beta_intake_user_describe.md | 44 +++++++++++++++++ docs/stackit_beta_intake_user_list.md | 45 +++++++++++++++++ docs/stackit_beta_intake_user_update.md | 49 +++++++++++++++++++ 12 files changed, 515 insertions(+) create mode 100644 docs/stackit_beta_intake_create.md create mode 100644 docs/stackit_beta_intake_delete.md create mode 100644 docs/stackit_beta_intake_describe.md create mode 100644 docs/stackit_beta_intake_list.md create mode 100644 docs/stackit_beta_intake_update.md create mode 100644 docs/stackit_beta_intake_user.md create mode 100644 docs/stackit_beta_intake_user_create.md create mode 100644 docs/stackit_beta_intake_user_delete.md create mode 100644 docs/stackit_beta_intake_user_describe.md create mode 100644 docs/stackit_beta_intake_user_list.md create mode 100644 docs/stackit_beta_intake_user_update.md diff --git a/docs/stackit_beta_intake.md b/docs/stackit_beta_intake.md index f44d3c12d..fa29e493f 100644 --- a/docs/stackit_beta_intake.md +++ b/docs/stackit_beta_intake.md @@ -30,5 +30,11 @@ stackit beta intake [flags] ### SEE ALSO * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta intake create](./stackit_beta_intake_create.md) - Creates a new Intake +* [stackit beta intake delete](./stackit_beta_intake_delete.md) - Deletes an Intake +* [stackit beta intake describe](./stackit_beta_intake_describe.md) - Shows details of an Intake +* [stackit beta intake list](./stackit_beta_intake_list.md) - Lists all Intakes * [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners +* [stackit beta intake update](./stackit_beta_intake_update.md) - Updates an Intake +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users diff --git a/docs/stackit_beta_intake_create.md b/docs/stackit_beta_intake_create.md new file mode 100644 index 000000000..5742a772e --- /dev/null +++ b/docs/stackit_beta_intake_create.md @@ -0,0 +1,59 @@ +## stackit beta intake create + +Creates a new Intake + +### Synopsis + +Creates a new Intake. + +``` +stackit beta intake create [flags] +``` + +### Examples + +``` + Create a new Intake with required parameters + $ stackit beta intake create --display-name my-intake --runner-id xxx --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" + + Create a new Intake with a description, labels, and Dremio authentication + $ stackit beta intake create --display-name my-intake --runner-id xxx --description "Production intake" --labels "env=prod,team=billing" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-auth-type "dremio" --dremio-token-endpoint "https://auth.dremio.cloud/oauth/token" --dremio-pat "MY_TOKEN" + + Create a new Intake with manual partitioning by a date field + $ stackit beta intake create --display-name my-partitioned-intake --runner-id xxx --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-partitioning "manual" --catalog-partition-by "day(__intake_ts)" +``` + +### Options + +``` + --catalog-auth-type string Authentication type for the catalog (e.g., 'none', 'dremio') + --catalog-namespace string The namespace to which data shall be written (default: 'intake') + --catalog-partition-by strings List of Iceberg partitioning expressions. Only used when --catalog-partitioning is 'manual' + --catalog-partitioning string The target table's partitioning. One of 'none', 'intake-time', 'manual' + --catalog-table-name string The table name to identify the table in Iceberg + --catalog-uri string The URI to the Iceberg catalog endpoint + --catalog-warehouse string The Iceberg warehouse to connect to + --description string Description + --display-name string Display name + --dremio-pat string Dremio personal access token. Required if auth-type is 'dremio' + --dremio-token-endpoint string Dremio OAuth 2.0 token endpoint URL. Required if auth-type is 'dremio' + -h, --help Help for "stackit beta intake create" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2" (default []) + --runner-id string The UUID of the Intake Runner to use +``` + +### 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 intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_delete.md b/docs/stackit_beta_intake_delete.md new file mode 100644 index 000000000..305b81f90 --- /dev/null +++ b/docs/stackit_beta_intake_delete.md @@ -0,0 +1,40 @@ +## stackit beta intake delete + +Deletes an Intake + +### Synopsis + +Deletes an Intake. + +``` +stackit beta intake delete INTAKE_ID [flags] +``` + +### Examples + +``` + Delete an Intake with ID "xxx" + $ stackit beta intake delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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 beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_describe.md b/docs/stackit_beta_intake_describe.md new file mode 100644 index 000000000..9d13cc023 --- /dev/null +++ b/docs/stackit_beta_intake_describe.md @@ -0,0 +1,43 @@ +## stackit beta intake describe + +Shows details of an Intake + +### Synopsis + +Shows details of an Intake. + +``` +stackit beta intake describe INTAKE_ID [flags] +``` + +### Examples + +``` + Get details of an Intake with ID "xxx" + $ stackit beta intake describe xxx + + Get details of an Intake with ID "xxx" in JSON format + $ stackit beta intake describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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 intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_list.md b/docs/stackit_beta_intake_list.md new file mode 100644 index 000000000..5086a65fe --- /dev/null +++ b/docs/stackit_beta_intake_list.md @@ -0,0 +1,47 @@ +## stackit beta intake list + +Lists all Intakes + +### Synopsis + +Lists all Intakes for the current project. + +``` +stackit beta intake list [flags] +``` + +### Examples + +``` + List all Intakes + $ stackit beta intake list + + List all Intakes in JSON format + $ stackit beta intake list --output-format json + + List up to 5 Intakes + $ stackit beta intake list --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake list" + --limit int Maximum number of entries to list +``` + +### 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 intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_update.md b/docs/stackit_beta_intake_update.md new file mode 100644 index 000000000..3b36ac0b7 --- /dev/null +++ b/docs/stackit_beta_intake_update.md @@ -0,0 +1,54 @@ +## stackit beta intake update + +Updates an Intake + +### Synopsis + +Updates an Intake. Only the specified fields are updated. + +``` +stackit beta intake update INTAKE_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake with ID "xxx" + $ stackit beta intake update xxx --runner-id yyy --display-name new-intake-name + + Update the catalog details for an Intake with ID "xxx" + $ stackit beta intake update xxx --runner-id yyy --catalog-uri "http://new.uri" --catalog-warehouse "new-warehouse" +``` + +### Options + +``` + --catalog-auth-type string Authentication type for the catalog (e.g., 'none', 'dremio') + --catalog-namespace string The namespace to which data shall be written + --catalog-table-name string The table name to identify the table in Iceberg + --catalog-uri string The URI to the Iceberg catalog endpoint + --catalog-warehouse string The Iceberg warehouse to connect to + --description string Description + --display-name string Display name + --dremio-pat string Dremio personal access token + --dremio-token-endpoint string Dremio OAuth 2.0 token endpoint URL + -h, --help Help for "stackit beta intake update" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --runner-id string The UUID of the Intake Runner to use +``` + +### 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 intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_user.md b/docs/stackit_beta_intake_user.md new file mode 100644 index 000000000..695701847 --- /dev/null +++ b/docs/stackit_beta_intake_user.md @@ -0,0 +1,38 @@ +## stackit beta intake user + +Provides functionality for Intake Users + +### Synopsis + +Provides functionality for Intake Users. + +``` +stackit beta intake user [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user" +``` + +### 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 intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta intake user create](./stackit_beta_intake_user_create.md) - Creates a new Intake User +* [stackit beta intake user delete](./stackit_beta_intake_user_delete.md) - Deletes an Intake User +* [stackit beta intake user describe](./stackit_beta_intake_user_describe.md) - Shows details of an Intake User +* [stackit beta intake user list](./stackit_beta_intake_user_list.md) - Lists all Intake Users for an Intake +* [stackit beta intake user update](./stackit_beta_intake_user_update.md) - Updates an Intake User + diff --git a/docs/stackit_beta_intake_user_create.md b/docs/stackit_beta_intake_user_create.md new file mode 100644 index 000000000..53e9be63b --- /dev/null +++ b/docs/stackit_beta_intake_user_create.md @@ -0,0 +1,49 @@ +## stackit beta intake user create + +Creates a new Intake User + +### Synopsis + +Creates a new Intake User, providing secure access credentials for applications to connect to a data stream. + +``` +stackit beta intake user create [flags] +``` + +### Examples + +``` + Create a new Intake User with a display name and password for a specific Intake + $ stackit beta intake user create --intake-id xxx --display-name my-intake-user --password "my-secret-password" + + Create a new dead-letter queue user with a description and labels + $ stackit beta intake user create --intake-id xxx --display-name my-dlq-reader --password "another-secret" --type "dead-letter" --description "User for reading undelivered messages" --labels "owner=team-alpha,scope=dlq" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user create" + --intake-id string ID of the Intake to which the user belongs + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2" (default []) + --password string User password + --type string Type of user, 'intake' for writing to the stream or 'dead-letter' for reading from the dead-letter queue +``` + +### 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 intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_delete.md b/docs/stackit_beta_intake_user_delete.md new file mode 100644 index 000000000..edddbdc89 --- /dev/null +++ b/docs/stackit_beta_intake_user_delete.md @@ -0,0 +1,41 @@ +## stackit beta intake user delete + +Deletes an Intake User + +### Synopsis + +Deletes an Intake User. + +``` +stackit beta intake user delete USER_ID [flags] +``` + +### Examples + +``` + Delete an Intake User with ID "xxx" from an Intake with ID "yyy" + $ stackit beta intake user delete xxx --intake-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user delete" + --intake-id string ID of the Intake to which the user belongs +``` + +### 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 intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_describe.md b/docs/stackit_beta_intake_user_describe.md new file mode 100644 index 000000000..8442c05d1 --- /dev/null +++ b/docs/stackit_beta_intake_user_describe.md @@ -0,0 +1,44 @@ +## stackit beta intake user describe + +Shows details of an Intake User + +### Synopsis + +Shows details of an Intake User. + +``` +stackit beta intake user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of an Intake User with ID "xxx" from an Intake with ID "yyy" + $ stackit beta intake user describe xxx --intake-id yyy + + Get details of an Intake User in JSON format + $ stackit beta intake user describe xxx --intake-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user describe" + --intake-id string ID of the Intake to which the user belongs +``` + +### 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 intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_list.md b/docs/stackit_beta_intake_user_list.md new file mode 100644 index 000000000..19de06924 --- /dev/null +++ b/docs/stackit_beta_intake_user_list.md @@ -0,0 +1,45 @@ +## stackit beta intake user list + +Lists all Intake Users for an Intake + +### Synopsis + +Lists all Intake Users for a specific Intake. + +``` +stackit beta intake user list [flags] +``` + +### Examples + +``` + List all Intake Users for an Intake with ID "xxx" + $ stackit beta intake user list --intake-id xxx + + List up to 5 Intake Users for an Intake with ID "xxx" + $ stackit beta intake user list --intake-id xxx --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user list" + --intake-id string ID of the Intake + --limit int Maximum number of entries to list +``` + +### 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 intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_update.md b/docs/stackit_beta_intake_user_update.md new file mode 100644 index 000000000..4b03c293e --- /dev/null +++ b/docs/stackit_beta_intake_user_update.md @@ -0,0 +1,49 @@ +## stackit beta intake user update + +Updates an Intake User + +### Synopsis + +Updates an Intake User. Only the specified fields are updated. + +``` +stackit beta intake user update USER_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake User with ID "xxx" + $ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name" + + Update the password of an Intake User + $ stackit beta intake user update xxx --intake-id yyy --password "new-secret" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user update" + --intake-id string ID of the Intake + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --password string User password + --type string Type of user, 'intake' for writing or 'dead-letter' for reading from the dead-letter queue +``` + +### 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 intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users +