diff --git a/go.mod b/go.mod index 7fe6c50..e6e7b38 100644 --- a/go.mod +++ b/go.mod @@ -84,3 +84,5 @@ require ( ) tool github.com/vektra/mockery/v3 + +replace go.temporal.io/cloud-sdk => /Users/harry/work/cloud-sdk-go diff --git a/go.sum b/go.sum index 091d97a..d94b080 100644 --- a/go.sum +++ b/go.sum @@ -154,8 +154,6 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.temporal.io/api v1.63.6-0.20260811224032-89570b10e9e2 h1:VeNTxlOHETym01zX6kSXHIVw4nc2RrX/KVgepQ/dr/8= go.temporal.io/api v1.63.6-0.20260811224032-89570b10e9e2/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= -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 19bf16b..c3c6189 100644 --- a/internal/cloudservice/mock/mock.go +++ b/internal/cloudservice/mock/mock.go @@ -5600,6 +5600,89 @@ func (_c *MockCloudServiceClient_MoveNamespaceToProject_Call) RunAndReturn(run f return _c } +// MoveNexusEndpointToProject provides a mock function for the type MockCloudServiceClient +func (_mock *MockCloudServiceClient) MoveNexusEndpointToProject(ctx context.Context, in *cloudservice.MoveNexusEndpointToProjectRequest, opts ...grpc.CallOption) (*cloudservice.MoveNexusEndpointToProjectResponse, 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 MoveNexusEndpointToProject") + } + + var r0 *cloudservice.MoveNexusEndpointToProjectResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *cloudservice.MoveNexusEndpointToProjectRequest, ...grpc.CallOption) (*cloudservice.MoveNexusEndpointToProjectResponse, error)); ok { + return returnFunc(ctx, in, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *cloudservice.MoveNexusEndpointToProjectRequest, ...grpc.CallOption) *cloudservice.MoveNexusEndpointToProjectResponse); ok { + r0 = returnFunc(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*cloudservice.MoveNexusEndpointToProjectResponse) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *cloudservice.MoveNexusEndpointToProjectRequest, ...grpc.CallOption) error); ok { + r1 = returnFunc(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockCloudServiceClient_MoveNexusEndpointToProject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MoveNexusEndpointToProject' +type MockCloudServiceClient_MoveNexusEndpointToProject_Call struct { + *mock.Call +} + +// MoveNexusEndpointToProject is a helper method to define mock.On call +// - ctx context.Context +// - in *cloudservice.MoveNexusEndpointToProjectRequest +// - opts ...grpc.CallOption +func (_e *MockCloudServiceClient_Expecter) MoveNexusEndpointToProject(ctx interface{}, in interface{}, opts ...interface{}) *MockCloudServiceClient_MoveNexusEndpointToProject_Call { + return &MockCloudServiceClient_MoveNexusEndpointToProject_Call{Call: _e.mock.On("MoveNexusEndpointToProject", + append([]interface{}{ctx, in}, opts...)...)} +} + +func (_c *MockCloudServiceClient_MoveNexusEndpointToProject_Call) Run(run func(ctx context.Context, in *cloudservice.MoveNexusEndpointToProjectRequest, opts ...grpc.CallOption)) *MockCloudServiceClient_MoveNexusEndpointToProject_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *cloudservice.MoveNexusEndpointToProjectRequest + if args[1] != nil { + arg1 = args[1].(*cloudservice.MoveNexusEndpointToProjectRequest) + } + 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_MoveNexusEndpointToProject_Call) Return(moveNexusEndpointToProjectResponse *cloudservice.MoveNexusEndpointToProjectResponse, err error) *MockCloudServiceClient_MoveNexusEndpointToProject_Call { + _c.Call.Return(moveNexusEndpointToProjectResponse, err) + return _c +} + +func (_c *MockCloudServiceClient_MoveNexusEndpointToProject_Call) RunAndReturn(run func(ctx context.Context, in *cloudservice.MoveNexusEndpointToProjectRequest, opts ...grpc.CallOption) (*cloudservice.MoveNexusEndpointToProjectResponse, error)) *MockCloudServiceClient_MoveNexusEndpointToProject_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 diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 3fd8327..6e318a4 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -4611,6 +4611,7 @@ func NewCloudNexusEndpointCommand(cctx *CommandContext, parent *CloudNexusComman s.Command.AddCommand(&NewCloudNexusEndpointDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNexusEndpointGetCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNexusEndpointListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNexusEndpointMoveToProjectCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNexusEndpointUpdateCommand(cctx, &s).Command) return &s } @@ -4900,6 +4901,52 @@ func NewCloudNexusEndpointListCommand(cctx *CommandContext, parent *CloudNexusEn return &s } +type CloudNexusEndpointMoveToProjectCommand struct { + Parent *CloudNexusEndpointCommand + Command cobra.Command + ClientOptions + ResourceVersionOptions + Name string + Id string + DestinationProjectId string + SourceProjectId string + AsyncOperationId string + Async bool + PollInterval cliext.FlagDuration +} + +func NewCloudNexusEndpointMoveToProjectCommand(cctx *CommandContext, parent *CloudNexusEndpointCommand) *CloudNexusEndpointMoveToProjectCommand { + var s CloudNexusEndpointMoveToProjectCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "move-to-project [flags]" + s.Command.Short = "Move a Nexus Endpoint to a different project" + if hasHighlighting { + s.Command.Long = "Move a Nexus Endpoint to a different project within the same account.\nSpecify either \x1b[1m--name\x1b[0m or \x1b[1m--id\x1b[0m (exactly one is required).\n\nThe endpoint keeps its ID, name, target, allowed caller namespaces, and\nrouting, so Nexus calls through it are unaffected. Only the project that\ngoverns access to it changes: access granted through the source project\nis lost, and access granted through the destination project applies.\n\nExample:\n\n\x1b[1mtemporal cloud nexus endpoint move-to-project \\\n --name my-endpoint \\\n --destination-project-id my-destination-project \\\n --source-project-id my-source-project\x1b[0m" + } else { + s.Command.Long = "Move a Nexus Endpoint to a different project within the same account.\nSpecify either `--name` or `--id` (exactly one is required).\n\nThe endpoint keeps its ID, name, target, allowed caller namespaces, and\nrouting, so Nexus calls through it are unaffected. Only the project that\ngoverns access to it changes: access granted through the source project\nis lost, and access granted through the destination project applies.\n\nExample:\n\n```\ntemporal cloud nexus endpoint move-to-project \\\n --name my-endpoint \\\n --destination-project-id my-destination-project \\\n --source-project-id my-source-project\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Name, "name", "", "The name of the Nexus Endpoint to move.") + s.Command.Flags().StringVar(&s.Id, "id", "", "The ID of the Nexus Endpoint to move.") + s.Command.Flags().StringVar(&s.DestinationProjectId, "destination-project-id", "", "The ID of the project to move the Nexus Endpoint 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 Nexus Endpoint is expected to currently belong to. The move fails if the endpoint has since moved elsewhere. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "source-project-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.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 CloudNexusEndpointUpdateCommand struct { Parent *CloudNexusEndpointCommand Command cobra.Command diff --git a/temporalcloudcli/commands.nexus.move.go b/temporalcloudcli/commands.nexus.move.go new file mode 100644 index 0000000..74366ba --- /dev/null +++ b/temporalcloudcli/commands.nexus.move.go @@ -0,0 +1,59 @@ +package temporalcloudcli + +import ( + "errors" + "fmt" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" +) + +func (c *CloudNexusEndpointMoveToProjectCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + + endpoint, err := resolveNexusEndpoint(cctx, client, c.Name, c.Id) + if err != nil { + return err + } + + names, err := projectDisplayNames(cctx, client, endpoint.GetProjectId(), c.DestinationProjectId) + if err != nil { + return err + } + if _, ok := names[c.DestinationProjectId]; !ok { + return fmt.Errorf("destination project %q not found", c.DestinationProjectId) + } + + yes, err := cctx.GetPrompter().PromptYes(fmt.Sprintf( + "Move Nexus Endpoint %q from project %s to project %s", + endpoint.GetSpec().GetName(), + projectLabel(names, endpoint.GetProjectId()), + projectLabel(names, c.DestinationProjectId), + )) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting move.") + } + + rv := endpoint.GetResourceVersion() + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.MoveNexusEndpointToProject(cctx, &cloudservice.MoveNexusEndpointToProjectRequest{ + EndpointId: endpoint.GetId(), + DestinationProjectId: c.DestinationProjectId, + ExpectedSourceProjectId: c.SourceProjectId, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + poller := cctx.GetPoller(client, AsyncOperationOptions{ + AsyncOperationId: c.AsyncOperationId, + Async: c.Async, + PollInterval: c.PollInterval, + }) + return poller.HandleIdempotentOperation(cctx, resp, err) +} diff --git a/temporalcloudcli/commands.nexus.move_test.go b/temporalcloudcli/commands.nexus.move_test.go new file mode 100644 index 0000000..711ef6d --- /dev/null +++ b/temporalcloudcli/commands.nexus.move_test.go @@ -0,0 +1,151 @@ +package temporalcloudcli_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + nexusv1 "go.temporal.io/cloud-sdk/api/nexus/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" +) + +var testMoveEndpoint = &nexusv1.Endpoint{ + Id: "ep-123", + ResourceVersion: "v1", + ProjectId: testMoveSource, + Spec: &nexusv1.EndpointSpec{Name: "my-endpoint"}, +} + +func expectEndpointByName(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNexusEndpoints(mock.Anything, &cloudservice.GetNexusEndpointsRequest{Name: "my-endpoint"}, mock.Anything). + Return(&cloudservice.GetNexusEndpointsResponse{Endpoints: []*nexusv1.Endpoint{testMoveEndpoint}}, nil) +} + +func expectEndpointByID(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNexusEndpoint(mock.Anything, &cloudservice.GetNexusEndpointRequest{EndpointId: "ep-123"}, mock.Anything). + Return(&cloudservice.GetNexusEndpointResponse{Endpoint: testMoveEndpoint}, nil) +} + +func expectBothProjectsFound(c *cloudmock.MockCloudServiceClient) { + expectProjectLookup(c, testMoveProject(testMoveSource, "source"), testMoveProject(testMoveDest, "destination")) +} + +func expectEndpointMove(c *cloudmock.MockCloudServiceClient, want *cloudservice.MoveNexusEndpointToProjectRequest) { + c.EXPECT(). + MoveNexusEndpointToProject(mock.Anything, want, mock.Anything). + Return(&cloudservice.MoveNexusEndpointToProjectResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-move"}, + }, nil) +} + +func TestNexusEndpointMoveToProject(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudNexusEndpointMoveToProjectCommand + resourceVersion string + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + }{ + { + name: "ByNameWithFetchedResourceVersion", + cmd: temporalcloudcli.CloudNexusEndpointMoveToProjectCommand{Name: "my-endpoint"}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectEndpointByName(c) + expectBothProjectsFound(c) + expectEndpointMove(c, &cloudservice.MoveNexusEndpointToProjectRequest{ + EndpointId: "ep-123", + DestinationProjectId: testMoveDest, + ExpectedSourceProjectId: testMoveSource, + ResourceVersion: "v1", + }) + }, + }, + { + name: "ByIDWithResourceVersionOverride", + cmd: temporalcloudcli.CloudNexusEndpointMoveToProjectCommand{Id: "ep-123"}, + resourceVersion: "v-override", + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectEndpointByID(c) + expectBothProjectsFound(c) + expectEndpointMove(c, &cloudservice.MoveNexusEndpointToProjectRequest{ + EndpointId: "ep-123", + DestinationProjectId: testMoveDest, + ExpectedSourceProjectId: testMoveSource, + ResourceVersion: "v-override", + }) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.cmd + cmd.DestinationProjectId = testMoveDest + cmd.SourceProjectId = testMoveSource + cmd.ResourceVersion = tt.resourceVersion + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: temporalcloudcli.TestPromptOptions{ + ExpectPromptYes: true, + ExpectPromptYesMessage: `Move Nexus Endpoint "my-endpoint" from project "source" (proj-source) to project "destination" (proj-dest)`, + PromptResult: true, + }, + AsyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-move"}, + }) + }) + } +} + +func TestNexusEndpointMoveToProjectUnknownDestination(t *testing.T) { + cmd := temporalcloudcli.CloudNexusEndpointMoveToProjectCommand{ + Name: "my-endpoint", + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectEndpointByName(c) + expectProjectLookup(c, testMoveProject(testMoveSource, "source")) + }, + ExpectedError: `destination project "proj-dest" not found`, + }) +} + +func TestNexusEndpointMoveToProjectPromptDeclined(t *testing.T) { + cmd := temporalcloudcli.CloudNexusEndpointMoveToProjectCommand{ + Name: "my-endpoint", + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectEndpointByName(c) + expectBothProjectsFound(c) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: false}, + ExpectedError: "Aborting move.", + }) +} + +func TestNexusEndpointMoveToProjectSurfacesRejection(t *testing.T) { + cmd := temporalcloudcli.CloudNexusEndpointMoveToProjectCommand{ + Name: "my-endpoint", + DestinationProjectId: testMoveDest, + SourceProjectId: testMoveSource, + } + temporalcloudcli.TestCommand(t, &cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + expectEndpointByName(c) + expectBothProjectsFound(c) + c.EXPECT(). + MoveNexusEndpointToProject(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("resource limit reached: 5")) + }, + PromptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + ExpectedError: "resource limit reached: 5", + }) +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 63e6855..f7973d7 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -2097,6 +2097,67 @@ commands: description: | The ID of the Nexus Endpoint to delete. + - name: cloud nexus endpoint move-to-project + summary: Move a Nexus Endpoint to a different project + description: | + Move a Nexus Endpoint to a different project within the same account. + Specify either `--name` or `--id` (exactly one is required). + + The endpoint keeps its ID, name, target, allowed caller namespaces, and + routing, so Nexus calls through it are unaffected. Only the project that + governs access to it changes: access granted through the source project + is lost, and access granted through the destination project applies. + + Example: + + ``` + temporal cloud nexus endpoint move-to-project \ + --name my-endpoint \ + --destination-project-id my-destination-project \ + --source-project-id my-source-project + ``` + has-init: false + option-sets: + - client + - resource-version + options: + - name: name + type: string + description: | + The name of the Nexus Endpoint to move. + - name: id + type: string + description: | + The ID of the Nexus Endpoint to move. + - name: destination-project-id + type: string + description: | + The ID of the project to move the Nexus Endpoint to. + required: true + - name: source-project-id + type: string + description: | + The ID of the project the Nexus Endpoint is expected to currently + belong to. The move fails if the endpoint has since moved elsewhere. + required: true + - 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). + - name: cloud nexus endpoint update summary: Update an existing Nexus Endpoint by name or ID description: | diff --git a/temporalcloudcli/internal/protoutils/testdata/strip_deprecated/removes_option_deprecated_field_without_suffix.golden.json b/temporalcloudcli/internal/protoutils/testdata/strip_deprecated/removes_option_deprecated_field_without_suffix.golden.json index b376d63..a47a6af 100644 --- a/temporalcloudcli/internal/protoutils/testdata/strip_deprecated/removes_option_deprecated_field_without_suffix.golden.json +++ b/temporalcloudcli/internal/protoutils/testdata/strip_deprecated/removes_option_deprecated_field_without_suffix.golden.json @@ -4,6 +4,7 @@ "codecServer": null, "connectivityRuleIds": [], "description": "", + "encryptionValidation": null, "fairness": null, "highAvailability": null, "lifecycle": null,