From 698524a50e512569f5d54178ce570ac15e834a7d Mon Sep 17 00:00:00 2001 From: Drew McLean Date: Tue, 28 Jul 2026 18:08:14 -0400 Subject: [PATCH] Add 'project' subcommand support --- temporalcloudcli/commands.gen.go | 270 ++++++++++++++++ temporalcloudcli/commands.project.go | 262 ++++++++++++++++ temporalcloudcli/commands.project_test.go | 365 ++++++++++++++++++++++ temporalcloudcli/commands.yml | 201 ++++++++++++ 4 files changed, 1098 insertions(+) create mode 100644 temporalcloudcli/commands.project.go create mode 100644 temporalcloudcli/commands.project_test.go diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 4bfc133..b98c493 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -250,6 +250,7 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { s.Command.AddCommand(&NewCloudLogoutCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNexusCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudRegionCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudServiceAccountCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserCommand(cctx, &s).Command) @@ -4624,6 +4625,275 @@ func NewCloudNexusEndpointUpdateCommand(cctx *CommandContext, parent *CloudNexus return &s } +type CloudProjectCommand struct { + Parent *CloudCommand + Command cobra.Command +} + +func NewCloudProjectCommand(cctx *CommandContext, parent *CloudCommand) *CloudProjectCommand { + var s CloudProjectCommand + s.Parent = parent + s.Command.Use = "project" + s.Command.Short = "Manage Temporal Cloud projects" + s.Command.Long = "Commands for managing Temporal Cloud projects.\n\nProjects provide an account-level grouping for Temporal Cloud resources." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudProjectApplyCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectCreateCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectDeleteCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectEditCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectUpdateCommand(cctx, &s).Command) + return &s +} + +type CloudProjectApplyCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + DiffOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string + Spec string +} + +func NewCloudProjectApplyCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectApplyCommand { + var s CloudProjectApplyCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "apply [flags]" + s.Command.Short = "Create or update a project from a specification" + if hasHighlighting { + s.Command.Long = "Apply a project configuration to Temporal Cloud. If --project-id is\nprovided, the existing project is updated. Otherwise, a new project is\ncreated because project IDs are generated by the server.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample:\n\n\x1b[1mtemporal cloud project apply --spec '{\"display_name\": \"Engineering\", \"description\": \"Engineering workloads\"}'\x1b[0m" + } else { + s.Command.Long = "Apply a project configuration to Temporal Cloud. If --project-id is\nprovided, the existing project is updated. Otherwise, a new project is\ncreated because project IDs are generated by the server.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample:\n\n```\ntemporal cloud project apply --spec '{\"display_name\": \"Engineering\", \"description\": \"Engineering workloads\"}'\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project to update. Omit to create a new project.") + s.Command.Flags().StringVar(&s.Spec, "spec", "", "Project configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "spec") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectCreateCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + AsyncOperationOptions + DisplayName string + Description string + EnableDeleteProtection bool +} + +func NewCloudProjectCreateCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectCreateCommand { + var s CloudProjectCreateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "create [flags]" + s.Command.Short = "Create a project" + if hasHighlighting { + s.Command.Long = "Create a new Temporal Cloud project.\n\nExample:\n\n\x1b[1mtemporal cloud project create --display-name \"Engineering\" --description \"Engineering workloads\"\x1b[0m" + } else { + s.Command.Long = "Create a new Temporal Cloud project.\n\nExample:\n\n```\ntemporal cloud project create --display-name \"Engineering\" --description \"Engineering workloads\"\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "The display name of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "display-name") + s.Command.Flags().StringVar(&s.Description, "description", "", "A description of the project.") + s.Command.Flags().BoolVar(&s.EnableDeleteProtection, "enable-delete-protection", false, "Prevent the project from being deleted while enabled.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectDeleteCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string +} + +func NewCloudProjectDeleteCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectDeleteCommand { + var s CloudProjectDeleteCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "delete [flags]" + s.Command.Short = "Delete a project" + if hasHighlighting { + s.Command.Long = "Delete a Temporal Cloud project.\n\nExample:\n\n\x1b[1mtemporal cloud project delete --project-id my-project-id\x1b[0m" + } else { + s.Command.Long = "Delete a Temporal Cloud project.\n\nExample:\n\n```\ntemporal cloud project delete --project-id my-project-id\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectEditCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + DiffOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string +} + +func NewCloudProjectEditCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectEditCommand { + var s CloudProjectEditCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "edit [flags]" + s.Command.Short = "Interactively edit a project" + if hasHighlighting { + s.Command.Long = "Open an existing project specification in your default editor and apply\nthe edited configuration.\n\nExample:\n\n\x1b[1mtemporal cloud project edit --project-id my-project-id\x1b[0m" + } else { + s.Command.Long = "Open an existing project specification in your default editor and apply\nthe edited configuration.\n\nExample:\n\n```\ntemporal cloud project edit --project-id my-project-id\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.DiffOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectGetCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + ProjectId string +} + +func NewCloudProjectGetCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectGetCommand { + var s CloudProjectGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Retrieve project details" + if hasHighlighting { + s.Command.Long = "Retrieve the configuration and status of a Temporal Cloud project.\n\nExample:\n\n\x1b[1mtemporal cloud project get --project-id my-project-id\x1b[0m" + } else { + s.Command.Long = "Retrieve the configuration and status of a Temporal Cloud project.\n\nExample:\n\n```\ntemporal cloud project get --project-id my-project-id\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectListCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + ProjectId []string + PageSize int + PageToken string +} + +func NewCloudProjectListCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectListCommand { + var s CloudProjectListCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list [flags]" + s.Command.Short = "List projects" + if hasHighlighting { + s.Command.Long = "List Temporal Cloud projects in the current account.\n\nExample:\n\n\x1b[1mtemporal cloud project list --page-size 50\x1b[0m" + } else { + s.Command.Long = "List Temporal Cloud projects in the current account.\n\nExample:\n\n```\ntemporal cloud project list --page-size 50\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringArrayVar(&s.ProjectId, "project-id", nil, "Filter by project ID. Can be specified multiple times.") + s.Command.Flags().IntVar(&s.PageSize, "page-size", 100, "Maximum number of projects to return.") + s.Command.Flags().StringVar(&s.PageToken, "page-token", "", "Token for retrieving the next page of results.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudProjectUpdateCommand struct { + Parent *CloudProjectCommand + Command cobra.Command + ClientOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string + DisplayName string + Description string + EnableDeleteProtection bool +} + +func NewCloudProjectUpdateCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectUpdateCommand { + var s CloudProjectUpdateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "update [flags]" + s.Command.Short = "Update a project" + if hasHighlighting { + s.Command.Long = "Update an existing Temporal Cloud project. Only explicitly provided flags\nare changed.\n\nExample:\n\n\x1b[1mtemporal cloud project update --project-id my-project-id --display-name \"Platform\"\x1b[0m" + } else { + s.Command.Long = "Update an existing Temporal Cloud project. Only explicitly provided flags\nare changed.\n\nExample:\n\n```\ntemporal cloud project update --project-id my-project-id --display-name \"Platform\"\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "The display name of the project.") + s.Command.Flags().StringVar(&s.Description, "description", "", "A description of the project.") + s.Command.Flags().BoolVar(&s.EnableDeleteProtection, "enable-delete-protection", false, "Prevent the project from being deleted while enabled.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudRegionCommand struct { Parent *CloudCommand Command cobra.Command diff --git a/temporalcloudcli/commands.project.go b/temporalcloudcli/commands.project.go new file mode 100644 index 0000000..b46007e --- /dev/null +++ b/temporalcloudcli/commands.project.go @@ -0,0 +1,262 @@ +package temporalcloudcli + +import ( + "errors" + "fmt" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + projectv1 "go.temporal.io/cloud-sdk/api/project/v1" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +func (c *CloudProjectListCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + res, err := client.GetProjects(cctx, &cloudservice.GetProjectsRequest{ + PageSize: int32(c.PageSize), + PageToken: c.PageToken, + ProjectIds: c.ProjectId, + }) + if err != nil { + return err + } + + return cctx.Printer.PrintResourceList( + struct { + Projects []*projectv1.Project + NextPageToken string + }{ + Projects: res.Projects, + NextPageToken: res.NextPageToken, + }, + printer.PrintResourceOptions{ + Fields: []string{"Id", "State", "CreatedTime"}, + SpecFields: []string{"DisplayName", "Description"}, + }, + printer.TableOptions{}, + ) +} + +func (c *CloudProjectGetCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + if err != nil { + return err + } + return cctx.Printer.PrintResource(res.Project, printer.PrintResourceOptions{}) +} + +func (c *CloudProjectCreateCommand) run(cctx *CommandContext, _ []string) error { + spec := projectSpecFromFlags(c.DisplayName, c.Description, c.EnableDeleteProtection) + + yes, err := cctx.GetPrompter().PromptYes("Create") + if err != nil { + return err + } + if !yes { + return errors.New("Aborting create.") + } + + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + resp, err := client.CreateProject(cctx, &cloudservice.CreateProjectRequest{ + Spec: spec, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleCreateAsyncOperationResponse(cctx, resp, err) +} + +func (c *CloudProjectUpdateCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + if err != nil { + return err + } + project := res.Project + newSpec := proto.Clone(project.Spec).(*projectv1.ProjectSpec) + + if c.Command.Flags().Changed("display-name") { + newSpec.DisplayName = c.DisplayName + } + if c.Command.Flags().Changed("description") { + newSpec.Description = c.Description + } + if c.Command.Flags().Changed("enable-delete-protection") { + if newSpec.Lifecycle == nil { + newSpec.Lifecycle = &projectv1.LifecycleSpec{} + } + newSpec.Lifecycle.EnableDeleteProtection = c.EnableDeleteProtection + } + + yes, err := cctx.GetPrompter().PromptApply(project.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting update.") + } + + rv := project.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ + ProjectId: c.ProjectId, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudProjectApplyCommand) run(cctx *CommandContext, _ []string) error { + specData, err := loadJSONSpec(c.Spec) + if err != nil { + return err + } + spec := &projectv1.ProjectSpec{} + if err := cctx.UnmarshalProtoJSON(specData, spec); err != nil { + return fmt.Errorf("failed to parse JSON spec: %w", err) + } + + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + if c.ProjectId == "" { + yes, err := cctx.GetPrompter().PromptApply((*projectv1.ProjectSpec)(nil), spec, c.VerboseDiff) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting apply.") + } + resp, err := client.CreateProject(cctx, &cloudservice.CreateProjectRequest{ + Spec: spec, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleCreateAsyncOperationResponse(cctx, resp, err) + } + + res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + if err != nil { + return err + } + project := res.Project + + yes, err := cctx.GetPrompter().PromptApply(project.Spec, spec, c.VerboseDiff) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting apply.") + } + + rv := project.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ + ProjectId: c.ProjectId, + Spec: spec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudProjectEditCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + if err != nil { + return err + } + project := res.Project + + edited, err := cctx.GetEditor().EditProto(project.Spec) + if err != nil { + return err + } + newSpec := edited.(*projectv1.ProjectSpec) + + yes, err := cctx.GetPrompter().PromptApply(project.Spec, newSpec, c.VerboseDiff) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting edit.") + } + + rv := project.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ + ProjectId: c.ProjectId, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudProjectDeleteCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + if err != nil { + return err + } + + yes, err := cctx.GetPrompter().PromptYes("Delete") + if err != nil { + return err + } + if !yes { + return errors.New("Aborting delete.") + } + + rv := res.Project.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.DeleteProject(cctx, &cloudservice.DeleteProjectRequest{ + ProjectId: c.ProjectId, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleDeleteOperation(cctx, resp, err) +} + +func projectSpecFromFlags(displayName, description string, enableDeleteProtection bool) *projectv1.ProjectSpec { + return &projectv1.ProjectSpec{ + DisplayName: displayName, + Description: description, + Lifecycle: &projectv1.LifecycleSpec{ + EnableDeleteProtection: enableDeleteProtection, + }, + } +} diff --git a/temporalcloudcli/commands.project_test.go b/temporalcloudcli/commands.project_test.go new file mode 100644 index 0000000..1d8ecaa --- /dev/null +++ b/temporalcloudcli/commands.project_test.go @@ -0,0 +1,365 @@ +package temporalcloudcli_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + operationv1 "go.temporal.io/cloud-sdk/api/operation/v1" + projectv1 "go.temporal.io/cloud-sdk/api/project/v1" + resourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" + "google.golang.org/protobuf/proto" + + cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +func testProject(id string) *projectv1.Project { + return &projectv1.Project{ + Id: id, + ResourceVersion: "rv-" + id, + State: resourcev1.ResourceState_RESOURCE_STATE_ACTIVE, + Spec: &projectv1.ProjectSpec{ + DisplayName: "Engineering", + Description: "Engineering workloads", + Lifecycle: &projectv1.LifecycleSpec{EnableDeleteProtection: true}, + }, + } +} + +func testAsyncOperation(id string) *operationv1.AsyncOperation { + return &operationv1.AsyncOperation{Id: id, State: operationv1.AsyncOperation_STATE_PENDING} +} + +func TestProjectList(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectListCommand{ + ProjectId: []string{"project-a", "project-b"}, + PageSize: 50, + PageToken: "next", + } + res := &cloudservice.GetProjectsResponse{ + Projects: []*projectv1.Project{testProject("project-a")}, + NextPageToken: "next-2", + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProjects(mock.Anything, &cloudservice.GetProjectsRequest{ + PageSize: 50, + PageToken: "next", + ProjectIds: []string{"project-a", "project-b"}, + }, mock.Anything). + Return(res, nil) + }, + JSONOutput: true, + ExpectedOutputJson: struct { + Projects []*projectv1.Project + NextPageToken string + }{Projects: res.Projects, NextPageToken: "next-2"}, + }) +} + +func TestProjectGet(t *testing.T) { + project := testProject("project-a") + + temporalcloudcli.TestCommand(t, &temporalcloudcli.CloudProjectGetCommand{ProjectId: "project-a"}, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: project}, nil) + }, + JSONOutput: true, + ExpectedOutputJson: project, + }) +} + +func TestProjectCreate(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectCreateCommand{ + DisplayName: "Engineering", + Description: "Engineering workloads", + EnableDeleteProtection: true, + } + wantSpec := &projectv1.ProjectSpec{ + DisplayName: "Engineering", + Description: "Engineering workloads", + Lifecycle: &projectv1.LifecycleSpec{EnableDeleteProtection: true}, + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + CreateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.CreateProjectRequest) bool { + return proto.Equal(req.Spec, wantSpec) + }), mock.Anything). + Return(&cloudservice.CreateProjectResponse{ + ProjectId: "project-a", + AsyncOperation: testAsyncOperation("op-create"), + }, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: "Create", + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-create"}, + JSONOutput: true, + }) +} + +func TestProjectCreate_PromptDecline(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectCreateCommand{DisplayName: "Engineering"} + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: "Create", + PromptResult: false, + }, + ExpectedError: "Aborting create.", + }) +} + +func TestProjectUpdate(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectUpdateCommand{ + ProjectId: "project-a", + DisplayName: "Platform", + EnableDeleteProtection: false, + } + cmd.Command.Flags().StringVar(&cmd.DisplayName, "display-name", "", "") + require.NoError(t, cmd.Command.Flags().Set("display-name", "Platform")) + cmd.Command.Flags().BoolVar(&cmd.EnableDeleteProtection, "enable-delete-protection", false, "") + require.NoError(t, cmd.Command.Flags().Set("enable-delete-protection", "false")) + + existing := testProject("project-a") + wantSpec := &projectv1.ProjectSpec{ + DisplayName: "Platform", + Description: "Engineering workloads", + Lifecycle: &projectv1.LifecycleSpec{EnableDeleteProtection: false}, + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + c.EXPECT(). + UpdateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateProjectRequest) bool { + return req.ProjectId == "project-a" && + req.ResourceVersion == "rv-project-a" && + proto.Equal(req.Spec, wantSpec) + }), mock.Anything). + Return(&cloudservice.UpdateProjectResponse{AsyncOperation: testAsyncOperation("op-update")}, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPrompApply: true, + ExpectPromptApplyExisting: existing.Spec, + ExpectPromptApplyModified: wantSpec, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-update"}, + JSONOutput: true, + }) +} + +func TestProjectUpdate_ResourceVersionOverride(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectUpdateCommand{ + ProjectId: "project-a", + Description: "New description", + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + } + cmd.Command.Flags().StringVar(&cmd.Description, "description", "", "") + require.NoError(t, cmd.Command.Flags().Set("description", "New description")) + + existing := testProject("project-a") + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT().GetProject(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + c.EXPECT(). + UpdateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateProjectRequest) bool { + return req.ResourceVersion == "rv-user" && req.Spec.Description == "New description" + }), mock.Anything). + Return(&cloudservice.UpdateProjectResponse{AsyncOperation: testAsyncOperation("op-update")}, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPrompApply: true, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-update"}, + JSONOutput: true, + }) +} + +func TestProjectApply_Create(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectApplyCommand{ + Spec: `{"display_name":"Engineering","description":"Engineering workloads"}`, + } + wantSpec := &projectv1.ProjectSpec{ + DisplayName: "Engineering", + Description: "Engineering workloads", + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + CreateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.CreateProjectRequest) bool { + return proto.Equal(req.Spec, wantSpec) + }), mock.Anything). + Return(&cloudservice.CreateProjectResponse{ + ProjectId: "project-a", + AsyncOperation: testAsyncOperation("op-create"), + }, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPrompApply: true, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-create"}, + JSONOutput: true, + }) +} + +func TestProjectApply_Update(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectApplyCommand{ + ProjectId: "project-a", + Spec: `{"display_name":"Platform","description":"Platform workloads"}`, + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + } + existing := testProject("project-a") + wantSpec := &projectv1.ProjectSpec{ + DisplayName: "Platform", + Description: "Platform workloads", + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + c.EXPECT(). + UpdateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateProjectRequest) bool { + return req.ProjectId == "project-a" && + req.ResourceVersion == "rv-user" && + proto.Equal(req.Spec, wantSpec) + }), mock.Anything). + Return(&cloudservice.UpdateProjectResponse{AsyncOperation: testAsyncOperation("op-update")}, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPrompApply: true, + ExpectPromptApplyExisting: existing.Spec, + ExpectPromptApplyModified: wantSpec, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-update"}, + JSONOutput: true, + }) +} + +func TestProjectApply_InvalidSpec(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectApplyCommand{Spec: `{`} + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + ExpectedError: "failed to parse JSON spec", + }) +} + +func TestProjectEdit(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectEditCommand{ + ProjectId: "project-a", + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + } + existing := testProject("project-a") + editedSpec := &projectv1.ProjectSpec{DisplayName: "Edited"} + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + c.EXPECT(). + UpdateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateProjectRequest) bool { + return req.ProjectId == "project-a" && + req.ResourceVersion == "rv-user" && + proto.Equal(req.Spec, editedSpec) + }), mock.Anything). + Return(&cloudservice.UpdateProjectResponse{AsyncOperation: testAsyncOperation("op-update")}, nil) + }, + EditorOptions: temporalcloudcli.TestEditorOptions{Modified: editedSpec}, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPrompApply: true, + ExpectPromptApplyExisting: existing.Spec, + ExpectPromptApplyModified: editedSpec, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-update"}, + JSONOutput: true, + }) +} + +func TestProjectEdit_EditorError(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectEditCommand{ProjectId: "project-a"} + existing := testProject("project-a") + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + }, + EditorOptions: temporalcloudcli.TestEditorOptions{EditorError: errors.New("editor failed")}, + ExpectedError: "editor failed", + }) +} + +func TestProjectDelete(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectDeleteCommand{ + ProjectId: "project-a", + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + } + existing := testProject("project-a") + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + c.EXPECT(). + DeleteProject(mock.Anything, &cloudservice.DeleteProjectRequest{ + ProjectId: "project-a", + ResourceVersion: "rv-user", + AsyncOperationId: "", + }, mock.Anything). + Return(&cloudservice.DeleteProjectResponse{AsyncOperation: testAsyncOperation("op-delete")}, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: "Delete", + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-delete"}, + JSONOutput: true, + }) +} + +func TestProjectDelete_PromptDecline(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectDeleteCommand{ProjectId: "project-a"} + existing := testProject("project-a") + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProject(mock.Anything, &cloudservice.GetProjectRequest{ProjectId: "project-a"}, mock.Anything). + Return(&cloudservice.GetProjectResponse{Project: existing}, nil) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: "Delete", + PromptResult: false, + }, + ExpectedError: "Aborting delete.", + }) +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 30623b1..13b9b28 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -1988,6 +1988,207 @@ commands: description: | A namespace to remove. Can be specified multiple times. + # Project commands + - name: cloud project + summary: Manage Temporal Cloud projects + description: | + Commands for managing Temporal Cloud projects. + + Projects provide an account-level grouping for Temporal Cloud resources. + has-init: false + docs: + keywords: + - project + - projects + - management + description-header: Project Management Commands + tags: + - projects + - name: cloud project list + summary: List projects + description: | + List Temporal Cloud projects in the current account. + + Example: + + ``` + temporal cloud project list --page-size 50 + ``` + has-init: false + option-sets: + - client + options: + - name: project-id + type: string[] + description: | + Filter by project ID. Can be specified multiple times. + - name: page-size + type: int + default: 100 + description: | + Maximum number of projects to return. + - name: page-token + type: string + description: | + Token for retrieving the next page of results. + - name: cloud project get + summary: Retrieve project details + description: | + Retrieve the configuration and status of a Temporal Cloud project. + + Example: + + ``` + temporal cloud project get --project-id my-project-id + ``` + has-init: false + option-sets: + - client + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + - name: cloud project create + summary: Create a project + description: | + Create a new Temporal Cloud project. + + Example: + + ``` + temporal cloud project create --display-name "Engineering" --description "Engineering workloads" + ``` + has-init: false + option-sets: + - client + - async-operation + options: + - name: display-name + type: string + required: true + description: | + The display name of the project. + - name: description + type: string + description: | + A description of the project. + - name: enable-delete-protection + type: bool + description: | + Prevent the project from being deleted while enabled. + - name: cloud project update + summary: Update a project + description: | + Update an existing Temporal Cloud project. Only explicitly provided flags + are changed. + + Example: + + ``` + temporal cloud project update --project-id my-project-id --display-name "Platform" + ``` + has-init: false + option-sets: + - client + - async-operation + - resource-version + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + - name: display-name + type: string + description: | + The display name of the project. + - name: description + type: string + description: | + A description of the project. + - name: enable-delete-protection + type: bool + description: | + Prevent the project from being deleted while enabled. + - name: cloud project apply + summary: Create or update a project from a specification + description: | + Apply a project configuration to Temporal Cloud. If --project-id is + provided, the existing project is updated. Otherwise, a new project is + created because project IDs are generated by the server. + + The specification can be provided as inline JSON or loaded from a file + by prefixing the path with '@'. + + Example: + + ``` + temporal cloud project apply --spec '{"display_name": "Engineering", "description": "Engineering workloads"}' + ``` + has-init: false + option-sets: + - client + - diff + - async-operation + - resource-version + options: + - name: project-id + type: string + description: | + The ID of the project to update. Omit to create a new project. + - name: spec + type: string + required: true + description: | + Project configuration in JSON format. Provide inline JSON directly, + or use '@path/to/file.json' to load from a file. + - name: cloud project edit + summary: Interactively edit a project + description: | + Open an existing project specification in your default editor and apply + the edited configuration. + + Example: + + ``` + temporal cloud project edit --project-id my-project-id + ``` + has-init: false + option-sets: + - client + - diff + - async-operation + - resource-version + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + - name: cloud project delete + summary: Delete a project + description: | + Delete a Temporal Cloud project. + + Example: + + ``` + temporal cloud project delete --project-id my-project-id + ``` + has-init: false + option-sets: + - client + - async-operation + - resource-version + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + # User group commands - name: cloud user-group summary: Manage Temporal Cloud user groups