diff --git a/internal/namespace/client.go b/internal/namespace/client.go index 056827b..f5a0be1 100644 --- a/internal/namespace/client.go +++ b/internal/namespace/client.go @@ -47,6 +47,7 @@ type GetNamespacesParams struct { PageSize int32 PageToken string Name string + ProjectID string } func (c *Client) GetNamespaces(ctx context.Context, params GetNamespacesParams) ([]*namespacev1.Namespace, string, error) { @@ -54,6 +55,7 @@ func (c *Client) GetNamespaces(ctx context.Context, params GetNamespacesParams) PageSize: params.PageSize, PageToken: params.PageToken, Name: params.Name, + ProjectId: params.ProjectID, }) if err != nil { return nil, "", err diff --git a/internal/namespace/client_test.go b/internal/namespace/client_test.go new file mode 100644 index 0000000..15ee1ff --- /dev/null +++ b/internal/namespace/client_test.go @@ -0,0 +1,40 @@ +package namespace_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + + "github.com/temporalio/cloud-cli/internal/namespace" + namespacemock "github.com/temporalio/cloud-cli/internal/namespace/mock" +) + +func TestClientGetNamespaces_ProjectID(t *testing.T) { + cloud := namespacemock.NewMockCloudService(t) + client := &namespace.Client{Cloud: cloud} + + cloud.EXPECT(). + GetNamespaces(mock.Anything, &cloudservice.GetNamespacesRequest{ + PageSize: 50, + PageToken: "page-1", + Name: "payments", + ProjectId: "project-123", + }, mock.Anything). + Return(&cloudservice.GetNamespacesResponse{ + Namespaces: []*namespacev1.Namespace{{Namespace: "payments.account"}}, + }, nil) + + namespaces, nextPageToken, err := client.GetNamespaces(context.Background(), namespace.GetNamespacesParams{ + PageSize: 50, + PageToken: "page-1", + Name: "payments", + ProjectID: "project-123", + }) + require.NoError(t, err) + require.Empty(t, nextPageToken) + require.Len(t, namespaces, 1) +} diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index b98c493..e34f442 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -1984,6 +1984,7 @@ type CloudNamespaceApplyCommand struct { ClientOptions DiffOptions Spec string + ProjectId string AsyncOperationId string Idempotent bool Async bool @@ -2004,6 +2005,7 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Spec, "spec", "", "Namespace 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.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project to create the namespace in. If omitted, the namespace is created in the account's default project.") s.Command.Flags().StringVar(&s.AsyncOperationId, "async-operation-id", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") s.Command.Flags().BoolVar(&s.Idempotent, "idempotent", false, "Succeed silently if the namespace already matches the specification. Without this flag, the command errors when no changes are needed.") s.Command.Flags().BoolVar(&s.Async, "async", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") @@ -2363,6 +2365,7 @@ type CloudNamespaceCreateCommand struct { EnableTaskQueueFairness bool SearchAttribute []string ConnectionRuleId []string + ProjectId string } func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceCreateCommand { @@ -2388,6 +2391,7 @@ func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespace s.Command.Flags().BoolVar(&s.EnableTaskQueueFairness, "enable-task-queue-fairness", false, "Enable task queue fairness for the namespace.") s.Command.Flags().StringArrayVar(&s.SearchAttribute, "search-attribute", nil, "Custom search attribute as 'name=Type' (e.g. --search-attribute myAttr=Keyword). Valid types: Text, Keyword, Int, Double, Bool, Datetime, KeywordList. Repeat to add multiple.") s.Command.Flags().StringArrayVar(&s.ConnectionRuleId, "connection-rule-id", nil, "Private connectivity rule ID. Repeat to specify multiple.") + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project to create the namespace in. If omitted, the namespace is created in the account's default project.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) s.CodecServerOptions.BuildFlags(s.Command.Flags()) @@ -3377,6 +3381,7 @@ type CloudNamespaceListCommand struct { PageSize int PageToken string Name string + ProjectId string } func NewCloudNamespaceListCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceListCommand { @@ -3394,6 +3399,7 @@ func NewCloudNamespaceListCommand(cctx *CommandContext, parent *CloudNamespaceCo s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Number of namespaces to return per page. Use for paginated results.") s.Command.Flags().StringVar(&s.PageToken, "page-token", "", "Token for retrieving the next page of results in a paginated list.") s.Command.Flags().StringVar(&s.Name, "name", "", "Filter namespaces by the name as defined in the specification of the namespace.") + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "Filter namespaces by 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 { @@ -4654,8 +4660,7 @@ type CloudProjectApplyCommand struct { DiffOptions AsyncOperationOptions ResourceVersionOptions - ProjectId string - Spec string + Spec string } func NewCloudProjectApplyCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectApplyCommand { @@ -4665,12 +4670,11 @@ func NewCloudProjectApplyCommand(cctx *CommandContext, parent *CloudProjectComma 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" + s.Command.Long = "Apply a project configuration to Temporal Cloud. An existing project with\nthe specification's display_name is updated if found; if no matching\nproject exists, a new project is created.\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.Long = "Apply a project configuration to Temporal Cloud. An existing project with\nthe specification's display_name is updated if found; if no matching\nproject exists, a new project is created.\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()) diff --git a/temporalcloudcli/commands.namespace.create_test.go b/temporalcloudcli/commands.namespace.create_test.go index 4114cfc..ff00d91 100644 --- a/temporalcloudcli/commands.namespace.create_test.go +++ b/temporalcloudcli/commands.namespace.create_test.go @@ -51,6 +51,12 @@ func createReqMatcher(expected *namespacev1.NamespaceSpec) interface{} { }) } +func createReqMatcherWithProject(expected *namespacev1.NamespaceSpec, projectID string) interface{} { + return mock.MatchedBy(func(req *cloudservice.CreateNamespaceRequest) bool { + return proto.Equal(req.Spec, expected) && req.ProjectId == projectID + }) +} + // TestCreateNamespace_Success verifies that CreateNamespace calls HandleOperation with the API response. func TestCreateNamespace_Success(t *testing.T) { mockCloud := cloudmock.NewMockCloudServiceClient(t) @@ -84,6 +90,39 @@ func TestCreateNamespace_Success(t *testing.T) { require.NoError(t, err) } +func TestCreateNamespace_ProjectID(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + spec := baseNamespaceSpec() + + mockPrompter.EXPECT(). + PromptApply(&namespacev1.NamespaceSpec{}, specMatcher(spec), false). + Return(nil) + + mockCloud.EXPECT(). + CreateNamespace(context.Background(), createReqMatcherWithProject(spec, "project-123")). + Return(defaultCreateResponse, nil) + + mockHandler.EXPECT(). + HandleOperation(defaultCreateResponse.AsyncOperation, "my-namespace.my-account"). + Return(nil) + + var buf bytes.Buffer + err := temporalcloudcli.CreateNamespace(context.Background(), temporalcloudcli.CreateNamespaceParams{ + Name: "my-namespace", + Regions: []string{"aws-us-east-1"}, + ProjectID: "project-123", + Cloud: mockCloud, + Printer: &printer.Printer{Output: &buf, JSON: true}, + Prompter: mockPrompter, + UnmarshalProtoJSON: noopUnmarshalProtoJSON, + OperationHandler: mockHandler, + }) + require.NoError(t, err) +} + // TestCreateNamespace_BuildsSpec verifies that CreateNamespace correctly wires params into the NamespaceSpec. func TestCreateNamespace_BuildsSpec(t *testing.T) { expectedSpec := &namespacev1.NamespaceSpec{ diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index d416080..49c39f9 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -124,10 +124,11 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error return err } client := newNamespaceClient(withCloudClient(cloudClient)) + projectID := c.ProjectId // Step 4: Retrieve existing namespace var found bool - existing, err := client.getNamespaceByName(cctx.Context, spec.Name) + existing, err := client.getNamespaceByName(cctx.Context, spec.Name, projectID) if err != nil && !isNotFoundErr(err) { return err } else if err == nil { @@ -178,6 +179,7 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error res, err := client.createNamespace(cctx.Context, createNamespaceParams{ spec: spec, asyncOperationID: c.AsyncOperationId, + projectID: projectID, }) if err != nil { return fmt.Errorf("failed to create namespace: %w", err) @@ -281,11 +283,13 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error } client := newNamespaceClient(withCloudClient(cloudClient)) + projectID := c.ProjectId namespaces, nextPageToken, err := client.getNamespaces(cctx.Context, getNamespacesParams{ pageSize: int32(c.PageSize), pageToken: c.PageToken, name: c.Name, + projectID: projectID, }) if err != nil { return err @@ -300,7 +304,7 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error NextPageToken: nextPageToken, }, printer.PrintResourceOptions{ - Fields: []string{"Namespace", "State", "CreatedTime"}, + Fields: []string{"Namespace", "ProjectId", "State", "CreatedTime"}, SpecFields: []string{"Regions"}, }, printer.TableOptions{}, @@ -324,6 +328,7 @@ type ( CodecPassAccessToken bool CodecIncludeCrossOriginCredentials bool ConnectionRuleIDs []string + ProjectID string Cloud cloudservice.CloudServiceClient Printer *printer.Printer @@ -425,6 +430,7 @@ func CreateNamespace(ctx context.Context, params CreateNamespaceParams) error { return createNamespace(ctx, &cloudservice.CreateNamespaceRequest{ Spec: spec, AsyncOperationId: params.AsyncOperationID, + ProjectId: params.ProjectID, }) } @@ -440,6 +446,7 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro if c.Command.Flags().Changed("enable-task-queue-fairness") { enableTaskQueueFairness = &c.EnableTaskQueueFairness } + projectID := c.ProjectId return CreateNamespace(cctx.Context, CreateNamespaceParams{ Name: c.Name, @@ -457,6 +464,7 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro CodecPassAccessToken: c.CodecPassAccessToken, CodecIncludeCrossOriginCredentials: c.CodecIncludeCrossOriginCredentials, ConnectionRuleIDs: c.ConnectionRuleId, + ProjectID: projectID, Cloud: cloudClient.CloudService(), Printer: cctx.Printer, Prompter: newPrompter(cctx), diff --git a/temporalcloudcli/commands.project.go b/temporalcloudcli/commands.project.go index b46007e..81d9529 100644 --- a/temporalcloudcli/commands.project.go +++ b/temporalcloudcli/commands.project.go @@ -1,6 +1,7 @@ package temporalcloudcli import ( + "context" "errors" "fmt" @@ -11,6 +12,8 @@ import ( "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) +const projectLookupPageSize = 1000 + func (c *CloudProjectListCommand) run(cctx *CommandContext, _ []string) error { client, err := cctx.GetCloudClient(c.ClientOptions) if err != nil { @@ -48,11 +51,11 @@ func (c *CloudProjectGetCommand) run(cctx *CommandContext, _ []string) error { return err } - res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + project, err := resolveProject(cctx, client, c.ProjectId) if err != nil { return err } - return cctx.Printer.PrintResource(res.Project, printer.PrintResourceOptions{}) + return cctx.Printer.PrintResource(project, printer.PrintResourceOptions{}) } func (c *CloudProjectCreateCommand) run(cctx *CommandContext, _ []string) error { @@ -83,11 +86,10 @@ func (c *CloudProjectUpdateCommand) run(cctx *CommandContext, _ []string) error return err } - res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + project, err := resolveProject(cctx, client, c.ProjectId) if err != nil { return err } - project := res.Project newSpec := proto.Clone(project.Spec).(*projectv1.ProjectSpec) if c.Command.Flags().Changed("display-name") { @@ -116,7 +118,7 @@ func (c *CloudProjectUpdateCommand) run(cctx *CommandContext, _ []string) error rv = c.ResourceVersion } resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ - ProjectId: c.ProjectId, + ProjectId: project.Id, Spec: newSpec, ResourceVersion: rv, AsyncOperationId: c.AsyncOperationId, @@ -139,7 +141,12 @@ func (c *CloudProjectApplyCommand) run(cctx *CommandContext, _ []string) error { return err } - if c.ProjectId == "" { + project, err := projectForApply(cctx, client, spec) + if err != nil { + return err + } + + if project == nil { yes, err := cctx.GetPrompter().PromptApply((*projectv1.ProjectSpec)(nil), spec, c.VerboseDiff) if err != nil { return err @@ -154,12 +161,6 @@ func (c *CloudProjectApplyCommand) run(cctx *CommandContext, _ []string) error { 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 @@ -173,7 +174,7 @@ func (c *CloudProjectApplyCommand) run(cctx *CommandContext, _ []string) error { rv = c.ResourceVersion } resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ - ProjectId: c.ProjectId, + ProjectId: project.Id, Spec: spec, ResourceVersion: rv, AsyncOperationId: c.AsyncOperationId, @@ -187,11 +188,10 @@ func (c *CloudProjectEditCommand) run(cctx *CommandContext, _ []string) error { return err } - res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + project, err := resolveProject(cctx, client, c.ProjectId) if err != nil { return err } - project := res.Project edited, err := cctx.GetEditor().EditProto(project.Spec) if err != nil { @@ -212,7 +212,7 @@ func (c *CloudProjectEditCommand) run(cctx *CommandContext, _ []string) error { rv = c.ResourceVersion } resp, err := client.UpdateProject(cctx, &cloudservice.UpdateProjectRequest{ - ProjectId: c.ProjectId, + ProjectId: project.Id, Spec: newSpec, ResourceVersion: rv, AsyncOperationId: c.AsyncOperationId, @@ -226,7 +226,7 @@ func (c *CloudProjectDeleteCommand) run(cctx *CommandContext, _ []string) error return err } - res, err := client.GetProject(cctx, &cloudservice.GetProjectRequest{ProjectId: c.ProjectId}) + project, err := resolveProject(cctx, client, c.ProjectId) if err != nil { return err } @@ -239,12 +239,12 @@ func (c *CloudProjectDeleteCommand) run(cctx *CommandContext, _ []string) error return errors.New("Aborting delete.") } - rv := res.Project.ResourceVersion + rv := project.ResourceVersion if c.ResourceVersion != "" { rv = c.ResourceVersion } resp, err := client.DeleteProject(cctx, &cloudservice.DeleteProjectRequest{ - ProjectId: c.ProjectId, + ProjectId: project.Id, ResourceVersion: rv, AsyncOperationId: c.AsyncOperationId, }) @@ -260,3 +260,59 @@ func projectSpecFromFlags(displayName, description string, enableDeleteProtectio }, } } + +func projectForApply( + ctx context.Context, + client cloudservice.CloudServiceClient, + spec *projectv1.ProjectSpec, +) (*projectv1.Project, error) { + if spec.GetDisplayName() == "" { + return nil, nil + } + project, err := getProjectByName(ctx, client, spec.GetDisplayName()) + if err != nil { + return nil, err + } + return project, nil +} + +func resolveProject( + ctx context.Context, + client cloudservice.CloudServiceClient, + projectID string, +) (*projectv1.Project, error) { + res, err := client.GetProject(ctx, &cloudservice.GetProjectRequest{ProjectId: projectID}) + if err != nil { + return nil, err + } + return res.Project, nil +} + +func getProjectByName(ctx context.Context, client cloudservice.CloudServiceClient, projectName string) (*projectv1.Project, error) { + var match *projectv1.Project + var pageToken string + for { + res, err := client.GetProjects(ctx, &cloudservice.GetProjectsRequest{ + PageSize: projectLookupPageSize, + PageToken: pageToken, + }) + if err != nil { + return nil, err + } + + for _, project := range res.Projects { + if project.GetSpec().GetDisplayName() != projectName { + continue + } + if match != nil { + return nil, fmt.Errorf("multiple projects found with display name %q", projectName) + } + match = project + } + + pageToken = res.GetNextPageToken() + if pageToken == "" { + return match, nil + } + } +} diff --git a/temporalcloudcli/commands.project_test.go b/temporalcloudcli/commands.project_test.go index 1d8ecaa..27747ef 100644 --- a/temporalcloudcli/commands.project_test.go +++ b/temporalcloudcli/commands.project_test.go @@ -205,6 +205,11 @@ func TestProjectApply_Create(t *testing.T) { temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProjects(mock.Anything, &cloudservice.GetProjectsRequest{ + PageSize: 1000, + }, mock.Anything). + Return(&cloudservice.GetProjectsResponse{}, nil) c.EXPECT(). CreateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.CreateProjectRequest) bool { return proto.Equal(req.Spec, wantSpec) @@ -223,27 +228,29 @@ func TestProjectApply_Create(t *testing.T) { }) } -func TestProjectApply_Update(t *testing.T) { +func TestProjectApply_UpdateBySpecDisplayName(t *testing.T) { cmd := &temporalcloudcli.CloudProjectApplyCommand{ - ProjectId: "project-a", - Spec: `{"display_name":"Platform","description":"Platform workloads"}`, - ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + Spec: `{"display_name":"Engineering","description":"Updated workloads"}`, } existing := testProject("project-a") wantSpec := &projectv1.ProjectSpec{ - DisplayName: "Platform", - Description: "Platform workloads", + DisplayName: "Engineering", + Description: "Updated 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) + GetProjects(mock.Anything, &cloudservice.GetProjectsRequest{ + PageSize: 1000, + }, mock.Anything). + Return(&cloudservice.GetProjectsResponse{ + Projects: []*projectv1.Project{existing}, + }, nil) c.EXPECT(). UpdateProject(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateProjectRequest) bool { return req.ProjectId == "project-a" && - req.ResourceVersion == "rv-user" && + req.ResourceVersion == "rv-project-a" && proto.Equal(req.Spec, wantSpec) }), mock.Anything). Return(&cloudservice.UpdateProjectResponse{AsyncOperation: testAsyncOperation("op-update")}, nil) @@ -259,6 +266,28 @@ func TestProjectApply_Update(t *testing.T) { }) } +func TestProjectApply_DuplicateDisplayName(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectApplyCommand{ + Spec: `{"display_name":"Engineering","description":"Updated workloads"}`, + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetProjects(mock.Anything, &cloudservice.GetProjectsRequest{ + PageSize: 1000, + }, mock.Anything). + Return(&cloudservice.GetProjectsResponse{ + Projects: []*projectv1.Project{ + testProject("project-a"), + testProject("project-b"), + }, + }, nil) + }, + ExpectedError: `multiple projects found with display name "Engineering"`, + }) +} + func TestProjectApply_InvalidSpec(t *testing.T) { cmd := &temporalcloudcli.CloudProjectApplyCommand{Spec: `{`} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 13b9b28..8809ba1 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -221,6 +221,11 @@ commands: Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. required: true + - name: project-id + type: string + description: | + The ID of the project to create the namespace in. If omitted, the + namespace is created in the account's default project. - name: async-operation-id type: string description: | @@ -361,6 +366,10 @@ commands: type: string description: | Filter namespaces by the name as defined in the specification of the namespace. + - name: project-id + type: string + description: | + Filter namespaces by project ID. - name: cloud namespace create summary: Create a new Temporal Cloud namespace description: | @@ -426,6 +435,11 @@ commands: type: string[] description: | Private connectivity rule ID. Repeat to specify multiple. + - name: project-id + type: string + description: | + The ID of the project to create the namespace in. If omitted, the + namespace is created in the account's default project. # Namespace retention commands - name: cloud namespace retention @@ -2115,9 +2129,9 @@ commands: - 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. + Apply a project configuration to Temporal Cloud. An existing project with + the specification's display_name is updated if found; if no matching + project exists, a new project is created. The specification can be provided as inline JSON or loaded from a file by prefixing the path with '@'. @@ -2134,10 +2148,6 @@ commands: - 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 diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index c9af2be..48de22e 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -53,6 +53,7 @@ type getNamespacesParams struct { pageSize int32 pageToken string name string // optional, if set, will filter by name + projectID string } func (c *namespaceClient) getNamespaces(ctx context.Context, params getNamespacesParams) ([]*namespace.Namespace, string, error) { @@ -60,6 +61,7 @@ func (c *namespaceClient) getNamespaces(ctx context.Context, params getNamespace PageSize: params.pageSize, PageToken: params.pageToken, Name: params.name, + ProjectId: params.projectID, }) if err != nil { return nil, "", err @@ -98,6 +100,7 @@ type createNamespaceParams struct { spec *namespace.NamespaceSpec asyncOperationID string + projectID string } type createNamespaceResponse struct { @@ -109,6 +112,7 @@ func (c *namespaceClient) createNamespace(ctx context.Context, params createName res, err := c.client.CloudService().CreateNamespace(ctx, &cloudservice.CreateNamespaceRequest{ AsyncOperationId: params.asyncOperationID, Spec: params.spec, + ProjectId: params.projectID, }) if err != nil { return createNamespaceResponse{}, err @@ -157,8 +161,8 @@ func (c *namespaceClient) deleteNamespace(ctx context.Context, params deleteName return res.AsyncOperation, nil } -func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string) (*namespace.Namespace, error) { - namespaces, err := c.listNamespacesWithName(ctx, name, true) +func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string, projectID string) (*namespace.Namespace, error) { + namespaces, err := c.listNamespacesWithName(ctx, name, projectID, true) if err != nil { return nil, err } else if len(namespaces) > 1 { @@ -169,13 +173,14 @@ func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string) ( return namespaces[0], nil } -func (c *namespaceClient) listNamespacesWithName(ctx context.Context, name string, shortCircuit bool) ([]*namespace.Namespace, error) { +func (c *namespaceClient) listNamespacesWithName(ctx context.Context, name string, projectID string, shortCircuit bool) ([]*namespace.Namespace, error) { namespaces := []*namespace.Namespace{} pageToken := "" for { res, err := c.client.CloudService().GetNamespaces(ctx, &cloudservice.GetNamespacesRequest{ Name: name, PageToken: pageToken, + ProjectId: projectID, }) if err != nil { return nil, err