From d82fc4ee29d2ab83c8b8ecbc82437946ab17ccb2 Mon Sep 17 00:00:00 2001 From: Harry Sadoyan Date: Fri, 4 Sep 2026 14:36:42 -0700 Subject: [PATCH 1/2] Take cloud-sdk v0.19.0 Picks up MoveNamespaceToProject, published in cloud-api v0.22.0. Co-Authored-By: Claude Opus 5 --- go.mod | 2 +- go.sum | 2 + internal/cloudservice/mock/mock.go | 83 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d2e9d18..63ecff2 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/temporalio/cli/cliext v0.0.0-20260602200703-8bb57b77ad55 go.temporal.io/api v1.63.6-0.20260811224032-89570b10e9e2 - go.temporal.io/cloud-sdk v0.18.0 + go.temporal.io/cloud-sdk v0.19.0 go.temporal.io/sdk v1.44.1 go.temporal.io/sdk/contrib/envconfig v1.0.0 golang.org/x/oauth2 v0.36.0 diff --git a/go.sum b/go.sum index d7b9ef5..fa4872c 100644 --- a/go.sum +++ b/go.sum @@ -156,6 +156,8 @@ go.temporal.io/api v1.63.6-0.20260811224032-89570b10e9e2 h1:VeNTxlOHETym01zX6kSX go.temporal.io/api v1.63.6-0.20260811224032-89570b10e9e2/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= go.temporal.io/cloud-sdk v0.18.0 h1:7jGf76PwO6teN/wCkFlrf5yEsJcUrtbMw1hxV/l0a9M= go.temporal.io/cloud-sdk v0.18.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= +go.temporal.io/cloud-sdk v0.19.0 h1:Vjr8a75hdxbq6VAiZJz7WboqhGA2NhmXl5A6ZFrVt60= +go.temporal.io/cloud-sdk v0.19.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= go.temporal.io/sdk v1.44.1 h1:Mt2OZLZpqkzDIdg9YyQzO0Rb/HqCDnnqHlIAGAJ5gqM= go.temporal.io/sdk v1.44.1/go.mod h1:vkApR12F9/Y8OR+hkxe7WyXQFuCX6clhzqnAk6rzDAM= go.temporal.io/sdk/contrib/envconfig v1.0.0 h1:1Q/swVgB4EW/p3k7rI9/4hpU4/DC57FSRbU90+UisXw= diff --git a/internal/cloudservice/mock/mock.go b/internal/cloudservice/mock/mock.go index fae37de..19bf16b 100644 --- a/internal/cloudservice/mock/mock.go +++ b/internal/cloudservice/mock/mock.go @@ -5517,6 +5517,89 @@ func (_c *MockCloudServiceClient_GetUsers_Call) RunAndReturn(run func(ctx contex return _c } +// MoveNamespaceToProject provides a mock function for the type MockCloudServiceClient +func (_mock *MockCloudServiceClient) MoveNamespaceToProject(ctx context.Context, in *cloudservice.MoveNamespaceToProjectRequest, opts ...grpc.CallOption) (*cloudservice.MoveNamespaceToProjectResponse, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, in, opts) + } else { + tmpRet = _mock.Called(ctx, in) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for MoveNamespaceToProject") + } + + var r0 *cloudservice.MoveNamespaceToProjectResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *cloudservice.MoveNamespaceToProjectRequest, ...grpc.CallOption) (*cloudservice.MoveNamespaceToProjectResponse, error)); ok { + return returnFunc(ctx, in, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *cloudservice.MoveNamespaceToProjectRequest, ...grpc.CallOption) *cloudservice.MoveNamespaceToProjectResponse); ok { + r0 = returnFunc(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*cloudservice.MoveNamespaceToProjectResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *cloudservice.MoveNamespaceToProjectRequest, ...grpc.CallOption) error); ok { + r1 = returnFunc(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockCloudServiceClient_MoveNamespaceToProject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MoveNamespaceToProject' +type MockCloudServiceClient_MoveNamespaceToProject_Call struct { + *mock.Call +} + +// MoveNamespaceToProject is a helper method to define mock.On call +// - ctx context.Context +// - in *cloudservice.MoveNamespaceToProjectRequest +// - opts ...grpc.CallOption +func (_e *MockCloudServiceClient_Expecter) MoveNamespaceToProject(ctx interface{}, in interface{}, opts ...interface{}) *MockCloudServiceClient_MoveNamespaceToProject_Call { + return &MockCloudServiceClient_MoveNamespaceToProject_Call{Call: _e.mock.On("MoveNamespaceToProject", + append([]interface{}{ctx, in}, opts...)...)} +} + +func (_c *MockCloudServiceClient_MoveNamespaceToProject_Call) Run(run func(ctx context.Context, in *cloudservice.MoveNamespaceToProjectRequest, opts ...grpc.CallOption)) *MockCloudServiceClient_MoveNamespaceToProject_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *cloudservice.MoveNamespaceToProjectRequest + if args[1] != nil { + arg1 = args[1].(*cloudservice.MoveNamespaceToProjectRequest) + } + var arg2 []grpc.CallOption + var variadicArgs []grpc.CallOption + if len(args) > 2 { + variadicArgs = args[2].([]grpc.CallOption) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *MockCloudServiceClient_MoveNamespaceToProject_Call) Return(moveNamespaceToProjectResponse *cloudservice.MoveNamespaceToProjectResponse, err error) *MockCloudServiceClient_MoveNamespaceToProject_Call { + _c.Call.Return(moveNamespaceToProjectResponse, err) + return _c +} + +func (_c *MockCloudServiceClient_MoveNamespaceToProject_Call) RunAndReturn(run func(ctx context.Context, in *cloudservice.MoveNamespaceToProjectRequest, opts ...grpc.CallOption) (*cloudservice.MoveNamespaceToProjectResponse, error)) *MockCloudServiceClient_MoveNamespaceToProject_Call { + _c.Call.Return(run) + return _c +} + // RemoveUserGroupMember provides a mock function for the type MockCloudServiceClient func (_mock *MockCloudServiceClient) RemoveUserGroupMember(ctx context.Context, in *cloudservice.RemoveUserGroupMemberRequest, opts ...grpc.CallOption) (*cloudservice.RemoveUserGroupMemberResponse, error) { var tmpRet mock.Arguments From 2b2fb2135a5c4672e0074ac1dd098abb4aecf972 Mon Sep 17 00:00:00 2001 From: Harry Sadoyan Date: Fri, 4 Sep 2026 14:36:42 -0700 Subject: [PATCH 2/2] Add namespace move-to-project command Moves a namespace to a different project without recreating it, so running workers keep the namespace they are polling. Connectivity rules are project-scoped and must be chosen in the same call as the project. The selection is three-way and a repeated string flag cannot express all of it, so --no-connectivity-rules carries the explicit-none case: named ids attach those rules, --no-connectivity-rules asks for none, and omitting both asserts the namespace has no rules to carry over, which the server rejects when it does. The resource version is read from the namespace unless given, matching the other namespace commands. The expected source project is always explicit, since inferring it from the same read would defeat the concurrency check it exists to perform. Async surfacing goes through a new Poller.HandleOperation. The existing handlers each translate one rejection into an "already in the desired state" message, and the server treats a completed move as a success rather than rejecting it, so none of those translations apply. Co-Authored-By: Claude Opus 5 --- temporalcloudcli/async/mock/mock.go | 63 ++++++ temporalcloudcli/async/poller.go | 14 ++ temporalcloudcli/commands.gen.go | 49 +++++ temporalcloudcli/commands.namespace.move.go | 74 +++++++ .../commands.namespace.move_test.go | 207 ++++++++++++++++++ temporalcloudcli/commands.yml | 70 ++++++ 6 files changed, 477 insertions(+) create mode 100644 temporalcloudcli/commands.namespace.move.go create mode 100644 temporalcloudcli/commands.namespace.move_test.go diff --git a/temporalcloudcli/async/mock/mock.go b/temporalcloudcli/async/mock/mock.go index 0bebd94..3c51860 100644 --- a/temporalcloudcli/async/mock/mock.go +++ b/temporalcloudcli/async/mock/mock.go @@ -221,6 +221,69 @@ func (_c *MockPoller_HandleDeleteOperation_Call) RunAndReturn(run func(ctx conte return _c } +// HandleOperation provides a mock function for the type MockPoller +func (_mock *MockPoller) HandleOperation(ctx context.Context, response async.RespWithAsyncOp, err error) error { + ret := _mock.Called(ctx, response, err) + + if len(ret) == 0 { + panic("no return value specified for HandleOperation") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, async.RespWithAsyncOp, error) error); ok { + r0 = returnFunc(ctx, response, err) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockPoller_HandleOperation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleOperation' +type MockPoller_HandleOperation_Call struct { + *mock.Call +} + +// HandleOperation is a helper method to define mock.On call +// - ctx context.Context +// - response async.RespWithAsyncOp +// - err error +func (_e *MockPoller_Expecter) HandleOperation(ctx interface{}, response interface{}, err interface{}) *MockPoller_HandleOperation_Call { + return &MockPoller_HandleOperation_Call{Call: _e.mock.On("HandleOperation", ctx, response, err)} +} + +func (_c *MockPoller_HandleOperation_Call) Run(run func(ctx context.Context, response async.RespWithAsyncOp, err error)) *MockPoller_HandleOperation_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 async.RespWithAsyncOp + if args[1] != nil { + arg1 = args[1].(async.RespWithAsyncOp) + } + var arg2 error + if args[2] != nil { + arg2 = args[2].(error) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockPoller_HandleOperation_Call) Return(err1 error) *MockPoller_HandleOperation_Call { + _c.Call.Return(err1) + return _c +} + +func (_c *MockPoller_HandleOperation_Call) RunAndReturn(run func(ctx context.Context, response async.RespWithAsyncOp, err error) error) *MockPoller_HandleOperation_Call { + _c.Call.Return(run) + return _c +} + // HandleUpdateOperation provides a mock function for the type MockPoller func (_mock *MockPoller) HandleUpdateOperation(ctx context.Context, response async.RespWithAsyncOp, err error) error { ret := _mock.Called(ctx, response, err) diff --git a/temporalcloudcli/async/poller.go b/temporalcloudcli/async/poller.go index 6e7e9f8..54af408 100644 --- a/temporalcloudcli/async/poller.go +++ b/temporalcloudcli/async/poller.go @@ -21,6 +21,9 @@ type ( HandleCreateAsyncOperationResponse(ctx context.Context, response RespWithAsyncOp, err error) error HandleUpdateOperation(ctx context.Context, response RespWithAsyncOp, err error) error HandleDeleteOperation(ctx context.Context, response RespWithAsyncOp, err error) error + // HandleOperation surfaces an operation the server already treats as idempotent, so + // there is no "already in the desired state" rejection for the client to translate. + HandleOperation(ctx context.Context, response RespWithAsyncOp, err error) error // AwaitAsyncOperation blocks until the operation with the given ID reaches a terminal state, // then prints the final operation details. Use this when you already have an operation ID // and just need to wait for it to complete. @@ -100,6 +103,17 @@ func (p *poller) HandleDeleteOperation( return p.handleAsyncOperation(ctx, response) } +func (p *poller) HandleOperation( + ctx context.Context, + response RespWithAsyncOp, + err error, +) error { + if err != nil { + return fmt.Errorf("operation failed: %w", err) + } + return p.handleAsyncOperation(ctx, response) +} + func (p *poller) AwaitAsyncOperation(ctx context.Context, asyncOpID string) error { finalOp, err := p.waitForAsyncOperation(ctx, asyncOpID) if err != nil { diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 7701ba6..3fd8327 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -1896,6 +1896,7 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud s.Command.AddCommand(&NewCloudNamespaceHaCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceLifecycleCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceMoveToProjectCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceMtlsCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceRetentionCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceSearchAttributeCommand(cctx, &s).Command) @@ -3670,6 +3671,54 @@ func NewCloudNamespaceListCommand(cctx *CommandContext, parent *CloudNamespaceCo return &s } +type CloudNamespaceMoveToProjectCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command + ClientOptions + NamespaceOptions + ResourceVersionOptions + DestinationProjectId string + SourceProjectId string + ConnectivityRuleId []string + NoConnectivityRules bool + AsyncOperationId string + Async bool + PollInterval cliext.FlagDuration +} + +func NewCloudNamespaceMoveToProjectCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceMoveToProjectCommand { + var s CloudNamespaceMoveToProjectCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "move-to-project [flags]" + s.Command.Short = "Move a namespace to a different project" + if hasHighlighting { + s.Command.Long = "Move a namespace to a different project within the same account.\n\nThe namespace keeps its identity, endpoint, and running workflow\nexecutions. Only its project and the access derived from that project\nchange. Principals who reached the namespace only through the source\nproject lose access once the move completes.\n\nConnectivity rules are scoped to a project, so the rules the namespace\nshould have in the destination must be chosen in the same command. Pass\n--connectivity-rule-id once per rule, or --no-connectivity-rules to move\nthe namespace without any. Omit both only when the namespace has no\nconnectivity rules today.\n\nExample:\n\n\x1b[1mtemporal cloud namespace move-to-project \\\n --namespace my-namespace.my-account \\\n --destination-project-id my-destination-project \\\n --source-project-id my-source-project \\\n --connectivity-rule-id \x1b[0m" + } else { + s.Command.Long = "Move a namespace to a different project within the same account.\n\nThe namespace keeps its identity, endpoint, and running workflow\nexecutions. Only its project and the access derived from that project\nchange. Principals who reached the namespace only through the source\nproject lose access once the move completes.\n\nConnectivity rules are scoped to a project, so the rules the namespace\nshould have in the destination must be chosen in the same command. Pass\n--connectivity-rule-id once per rule, or --no-connectivity-rules to move\nthe namespace without any. Omit both only when the namespace has no\nconnectivity rules today.\n\nExample:\n\n```\ntemporal cloud namespace move-to-project \\\n --namespace my-namespace.my-account \\\n --destination-project-id my-destination-project \\\n --source-project-id my-source-project \\\n --connectivity-rule-id \n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.DestinationProjectId, "destination-project-id", "", "The ID of the project to move the namespace to. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "destination-project-id") + s.Command.Flags().StringVar(&s.SourceProjectId, "source-project-id", "", "The ID of the project the namespace is expected to currently belong to. The move fails if the namespace has since moved elsewhere. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "source-project-id") + s.Command.Flags().StringArrayVar(&s.ConnectivityRuleId, "connectivity-rule-id", nil, "The ID of a connectivity rule the namespace should have in the destination project. Repeat to specify multiple. Mutually exclusive with --no-connectivity-rules.") + s.Command.Flags().BoolVar(&s.NoConnectivityRules, "no-connectivity-rules", false, "Move the namespace without any connectivity rules in the destination project. Mutually exclusive with --connectivity-rule-id.") + 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.Async, "async", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") + s.PollInterval = cliext.MustParseFlagDuration("1s") + s.Command.Flags().Var(&s.PollInterval, "poll-interval", "Time to wait between status checks when waiting for operation completion. Cannot be greater than 10 minutes. Supports minutes (m) and seconds (s).") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.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 CloudNamespaceMtlsCommand struct { Parent *CloudNamespaceCommand Command cobra.Command diff --git a/temporalcloudcli/commands.namespace.move.go b/temporalcloudcli/commands.namespace.move.go new file mode 100644 index 0000000..bc5c0ae --- /dev/null +++ b/temporalcloudcli/commands.namespace.move.go @@ -0,0 +1,74 @@ +package temporalcloudcli + +import ( + "errors" + "fmt" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" +) + +func (c *CloudNamespaceMoveToProjectCommand) run(cctx *CommandContext, _ []string) error { + if c.NoConnectivityRules && len(c.ConnectivityRuleId) > 0 { + return errors.New("--connectivity-rule-id and --no-connectivity-rules are mutually exclusive") + } + seen := make(map[string]struct{}, len(c.ConnectivityRuleId)) + for _, id := range c.ConnectivityRuleId { + if _, dup := seen[id]; dup { + return fmt.Errorf("connectivity rule ID %q specified more than once", id) + } + seen[id] = struct{}{} + } + + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetNamespace(cctx, &cloudservice.GetNamespaceRequest{Namespace: c.Namespace}) + if err != nil { + return err + } + ns := res.Namespace + + yes, err := cctx.GetPrompter().PromptYes(fmt.Sprintf( + "Move namespace %q from project %q to project %q", + c.Namespace, ns.GetProjectId(), c.DestinationProjectId, + )) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting move.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + req := &cloudservice.MoveNamespaceToProjectRequest{ + Namespace: c.Namespace, + DestinationProjectId: c.DestinationProjectId, + ExpectedSourceProjectId: c.SourceProjectId, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + } + // Leaving the oneof unset asserts the namespace has no rules to carry over; the server + // rejects that when it does. It is not the same as selecting no rules. + switch { + case c.NoConnectivityRules: + req.DestinationConnectivityRules = &cloudservice.MoveNamespaceToProjectRequest_Unrestricted{ + Unrestricted: &cloudservice.NoConnectivityRules{}, + } + case len(c.ConnectivityRuleId) > 0: + req.DestinationConnectivityRules = &cloudservice.MoveNamespaceToProjectRequest_RuleIds{ + RuleIds: &cloudservice.ConnectivityRuleIDs{ConnectivityRuleIds: c.ConnectivityRuleId}, + } + } + + resp, err := client.MoveNamespaceToProject(cctx, req) + poller := cctx.GetPoller(client, AsyncOperationOptions{ + AsyncOperationId: c.AsyncOperationId, + Async: c.Async, + PollInterval: c.PollInterval, + }) + return poller.HandleOperation(cctx, resp, err) +} diff --git a/temporalcloudcli/commands.namespace.move_test.go b/temporalcloudcli/commands.namespace.move_test.go new file mode 100644 index 0000000..1f0e04e --- /dev/null +++ b/temporalcloudcli/commands.namespace.move_test.go @@ -0,0 +1,207 @@ +package temporalcloudcli_test + +import ( + "errors" + "slices" + "testing" + + "github.com/stretchr/testify/mock" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + + cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +const ( + testMoveNamespace = "my-ns.my-acct" + testMoveRV = "rv-1" + testMoveSource = "proj-source" + testMoveDest = "proj-dest" +) + +func expectGetNamespaceForMove(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: testMoveNamespace}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{ + Namespace: &namespacev1.Namespace{ + Namespace: testMoveNamespace, + ResourceVersion: testMoveRV, + ProjectId: testMoveSource, + Spec: &namespacev1.NamespaceSpec{}, + }, + }, nil) +} + +func expectMove( + c *cloudmock.MockCloudServiceClient, + matches func(*cloudservice.MoveNamespaceToProjectRequest) bool, +) { + c.EXPECT(). + MoveNamespaceToProject(mock.Anything, mock.MatchedBy(matches), mock.Anything). + Return(&cloudservice.MoveNamespaceToProjectResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-move"}, + }, nil) +} + +func TestNamespaceMoveToProjectConnectivitySelection(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceMoveToProjectCommand + wantRequest func(*cloudservice.MoveNamespaceToProjectRequest) bool + }{ + { + name: "NamedRuleIds", + cmd: temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + ConnectivityRuleId: []string{"rule-a", "rule-b"}, + }, + wantRequest: func(req *cloudservice.MoveNamespaceToProjectRequest) bool { + return slices.Equal(req.GetRuleIds().GetConnectivityRuleIds(), []string{"rule-a", "rule-b"}) + }, + }, + { + name: "ExplicitlyUnrestricted", + cmd: temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + NoConnectivityRules: true, + }, + wantRequest: func(req *cloudservice.MoveNamespaceToProjectRequest) bool { + return req.GetUnrestricted() != nil && req.GetRuleIds() == nil + }, + }, + { + name: "SelectionOmitted", + cmd: temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + }, + wantRequest: func(req *cloudservice.MoveNamespaceToProjectRequest) bool { + return req.GetDestinationConnectivityRules() == nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.cmd + cmd.Namespace = testMoveNamespace + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectGetNamespaceForMove(c) + expectMove(c, tt.wantRequest) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-move"}, + }) + }) + } +} + +func TestNamespaceMoveToProjectRequestFields(t *testing.T) { + tests := []struct { + name string + resourceVersion string + wantRV string + }{ + {name: "ResourceVersionFetched", wantRV: testMoveRV}, + {name: "ResourceVersionOverride", resourceVersion: "rv-override", wantRV: "rv-override"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + cmd.Namespace = testMoveNamespace + cmd.ResourceVersion = tt.resourceVersion + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectGetNamespaceForMove(c) + expectMove(c, func(req *cloudservice.MoveNamespaceToProjectRequest) bool { + return req.GetNamespace() == testMoveNamespace && + req.GetDestinationProjectId() == testMoveDest && + req.GetExpectedSourceProjectId() == testMoveSource && + req.GetResourceVersion() == tt.wantRV + }) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-move"}, + }) + }) + } +} + +func TestNamespaceMoveToProjectRejectsInvalidRuleSelection(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceMoveToProjectCommand + expectedErr string + }{ + { + name: "RuleIdsAndNoRulesTogether", + cmd: temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + ConnectivityRuleId: []string{"rule-a"}, + NoConnectivityRules: true, + }, + expectedErr: "mutually exclusive", + }, + { + name: "DuplicateRuleID", + cmd: temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + ConnectivityRuleId: []string{"rule-a", "rule-a"}, + }, + expectedErr: `connectivity rule ID "rule-a" specified more than once`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.cmd + cmd.Namespace = testMoveNamespace + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + ExpectedError: tt.expectedErr, + }) + }) + } +} + +func TestNamespaceMoveToProjectPromptDeclined(t *testing.T) { + cmd := temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + cmd.Namespace = testMoveNamespace + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: expectGetNamespaceForMove, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: `Move namespace "my-ns.my-acct" from project "proj-source" to project "proj-dest"`, + PromptResult: false, + }, + ExpectedError: "Aborting move.", + }) +} + +func TestNamespaceMoveToProjectSurfacesRejection(t *testing.T) { + cmd := temporalcloudcli.CloudNamespaceMoveToProjectCommand{ + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + cmd.Namespace = testMoveNamespace + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectGetNamespaceForMove(c) + c.EXPECT(). + MoveNamespaceToProject(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New(`namespace "my-ns.my-acct" has a migration in progress`)) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + ExpectedError: "has a migration in progress", + }) +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 14bb54a..63e6855 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -446,6 +446,76 @@ commands: The description is a human-readable description of the namespace. Must be at most 255 printable ASCII characters plus whitespace. Optional, default is empty. + - name: cloud namespace move-to-project + summary: Move a namespace to a different project + description: | + Move a namespace to a different project within the same account. + + The namespace keeps its identity, endpoint, and running workflow + executions. Only its project and the access derived from that project + change. Principals who reached the namespace only through the source + project lose access once the move completes. + + Connectivity rules are scoped to a project, so the rules the namespace + should have in the destination must be chosen in the same command. Pass + --connectivity-rule-id once per rule, or --no-connectivity-rules to move + the namespace without any. Omit both only when the namespace has no + connectivity rules today. + + Example: + + ``` + temporal cloud namespace move-to-project \ + --namespace my-namespace.my-account \ + --destination-project-id my-destination-project \ + --source-project-id my-source-project \ + --connectivity-rule-id + ``` + has-init: false + option-sets: + - client + - namespace + - resource-version + options: + - name: destination-project-id + type: string + description: | + The ID of the project to move the namespace to. + required: true + - name: source-project-id + type: string + description: | + The ID of the project the namespace is expected to currently belong + to. The move fails if the namespace has since moved elsewhere. + required: true + - name: connectivity-rule-id + type: string[] + description: | + The ID of a connectivity rule the namespace should have in the + destination project. Repeat to specify multiple. Mutually exclusive + with --no-connectivity-rules. + - name: no-connectivity-rules + type: bool + description: | + Move the namespace without any connectivity rules in the destination + project. Mutually exclusive with --connectivity-rule-id. + - name: async-operation-id + type: string + description: | + Custom identifier for tracking this async operation. If not provided, + a unique ID is generated automatically. + - name: async + type: bool + description: | + Return immediately after initiating the operation instead of waiting + for completion. Use the returned operation ID to check status later. + - name: poll-interval + type: duration + default: 1s + description: | + Time to wait between status checks when waiting for operation completion. + Cannot be greater than 10 minutes. + Supports minutes (m) and seconds (s). # Namespace description commands - name: cloud namespace description