diff --git a/go.mod b/go.mod index 7ecd41e..69215ff 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.62.13 - go.temporal.io/cloud-sdk v0.15.0 + go.temporal.io/cloud-sdk v0.16.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 2116e89..1169c40 100644 --- a/go.sum +++ b/go.sum @@ -154,8 +154,8 @@ 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.62.13 h1:xMa8Nt5oAMX+LvlCJA44wjTCc1H09i2rG9poB1/xvH4= go.temporal.io/api v1.62.13/go.mod h1:0k75tRljEuELWGeXjEZZO7zYqBln4+1FrG6+IMOMy7Q= -go.temporal.io/cloud-sdk v0.15.0 h1:TwlGhTVnF7d9uIP5wjV/vr7TBYsPEpJw/MNuiylX3DQ= -go.temporal.io/cloud-sdk v0.15.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= +go.temporal.io/cloud-sdk v0.16.0 h1:8EgFMyc3M1XV2+OIOOuKeyhUI8Kjy7YCCbmGYlRw1No= +go.temporal.io/cloud-sdk v0.16.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/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index b98c493..86f5a14 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -222,6 +222,40 @@ func (v *ExportGcsRegionOptions) BuildFlags(f *pflag.FlagSet) { _ = cobra.MarkFlagRequired(f, "region") } +type ExportAzureBlobOptions struct { + TenantId string + SubscriptionId string + ResourceGroup string + StorageAccount string + ContainerName string + FlagSet *pflag.FlagSet +} + +func (v *ExportAzureBlobOptions) BuildFlags(f *pflag.FlagSet) { + v.FlagSet = f + f.StringVar(&v.TenantId, "tenant-id", "", "The Azure tenant ID where the storage account exists and where Temporal's app registration is consented/granted access. Required.") + _ = cobra.MarkFlagRequired(f, "tenant-id") + f.StringVar(&v.SubscriptionId, "subscription-id", "", "The Azure subscription ID that contains the storage account. Required.") + _ = cobra.MarkFlagRequired(f, "subscription-id") + f.StringVar(&v.ResourceGroup, "resource-group", "", "The Azure resource group that contains the storage account. Required.") + _ = cobra.MarkFlagRequired(f, "resource-group") + f.StringVar(&v.StorageAccount, "storage-account", "", "The name of the destination Azure storage account where Temporal will send data. Required.") + _ = cobra.MarkFlagRequired(f, "storage-account") + f.StringVar(&v.ContainerName, "container-name", "", "The name of the destination Azure Blob container where Temporal will send data. Required.") + _ = cobra.MarkFlagRequired(f, "container-name") +} + +type ExportAzureBlobRegionOptions struct { + Region string + FlagSet *pflag.FlagSet +} + +func (v *ExportAzureBlobRegionOptions) BuildFlags(f *pflag.FlagSet) { + v.FlagSet = f + f.StringVar(&v.Region, "region", "", "The region where the Azure storage account is located. Required.") + _ = cobra.MarkFlagRequired(f, "region") +} + type CloudCommand struct { Command cobra.Command ClientOptions @@ -2489,8 +2523,9 @@ func NewCloudNamespaceExportCommand(cctx *CommandContext, parent *CloudNamespace s.Parent = parent s.Command.Use = "export" s.Command.Short = "Manage workflow history export sinks for namespaces" - s.Command.Long = "Commands for managing workflow history export sinks for Temporal Cloud namespaces.\n\nExport sinks define destinations (S3 or GCS) to which workflow history is exported." + s.Command.Long = "Commands for managing workflow history export sinks for Temporal Cloud namespaces.\n\nExport sinks define destinations (S3, GCS, or Azure Blob) to which workflow history is exported." s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceExportAzureBlobCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceExportDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceExportDisableCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceExportEnableCommand(cctx, &s).Command) @@ -2501,6 +2536,141 @@ func NewCloudNamespaceExportCommand(cctx *CommandContext, parent *CloudNamespace return &s } +type CloudNamespaceExportAzureBlobCommand struct { + Parent *CloudNamespaceExportCommand + Command cobra.Command +} + +func NewCloudNamespaceExportAzureBlobCommand(cctx *CommandContext, parent *CloudNamespaceExportCommand) *CloudNamespaceExportAzureBlobCommand { + var s CloudNamespaceExportAzureBlobCommand + s.Parent = parent + s.Command.Use = "azure-blob" + s.Command.Short = "Manage Azure Blob workflow history export sinks" + s.Command.Long = "Commands for managing Azure Blob workflow history export sinks for Temporal Cloud namespaces." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceExportAzureBlobCreateCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceExportAzureBlobUpdateCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceExportAzureBlobValidateCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceExportAzureBlobCreateCommand struct { + Parent *CloudNamespaceExportAzureBlobCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ExportSinkOptions + ExportAzureBlobOptions + ExportAzureBlobRegionOptions +} + +func NewCloudNamespaceExportAzureBlobCreateCommand(cctx *CommandContext, parent *CloudNamespaceExportAzureBlobCommand) *CloudNamespaceExportAzureBlobCreateCommand { + var s CloudNamespaceExportAzureBlobCreateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "create [flags]" + s.Command.Short = "Create an Azure Blob workflow history export sink" + if hasHighlighting { + s.Command.Long = "Create a new Azure Blob workflow history export sink for a Temporal Cloud namespace.\nThe sink is created in the enabled state.\n\nExample:\n\n\x1b[1mtemporal cloud namespace export azure-blob create --namespace my-namespace.my-account --sink-name my-sink \\\n --tenant-id 11111111-1111-1111-1111-111111111111 \\\n --subscription-id 22222222-2222-2222-2222-222222222222 \\\n --resource-group my-resource-group --storage-account my-storage-account \\\n --container-name my-container --region eastus\x1b[0m" + } else { + s.Command.Long = "Create a new Azure Blob workflow history export sink for a Temporal Cloud namespace.\nThe sink is created in the enabled state.\n\nExample:\n\n```\ntemporal cloud namespace export azure-blob create --namespace my-namespace.my-account --sink-name my-sink \\\n --tenant-id 11111111-1111-1111-1111-111111111111 \\\n --subscription-id 22222222-2222-2222-2222-222222222222 \\\n --resource-group my-resource-group --storage-account my-storage-account \\\n --container-name my-container --region eastus\n```" + } + s.Command.Args = cobra.NoArgs + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ExportSinkOptions.BuildFlags(s.Command.Flags()) + s.ExportAzureBlobOptions.BuildFlags(s.Command.Flags()) + s.ExportAzureBlobRegionOptions.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 CloudNamespaceExportAzureBlobUpdateCommand struct { + Parent *CloudNamespaceExportAzureBlobCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions + ExportSinkOptions + TenantId string + SubscriptionId string + ResourceGroup string + StorageAccount string + ContainerName string +} + +func NewCloudNamespaceExportAzureBlobUpdateCommand(cctx *CommandContext, parent *CloudNamespaceExportAzureBlobCommand) *CloudNamespaceExportAzureBlobUpdateCommand { + var s CloudNamespaceExportAzureBlobUpdateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "update [flags]" + s.Command.Short = "Update an Azure Blob workflow history export sink" + if hasHighlighting { + s.Command.Long = "Update the configuration of an existing Azure Blob workflow history export sink.\nOnly the flags you provide are changed; omitted flags keep their current\nvalues. The enabled/disabled state and region are also preserved.\n\nExample (rotate storage account only):\n\n\x1b[1mtemporal cloud namespace export azure-blob update --namespace my-namespace.my-account --sink-name my-sink \\\n --storage-account my-new-storage-account\x1b[0m" + } else { + s.Command.Long = "Update the configuration of an existing Azure Blob workflow history export sink.\nOnly the flags you provide are changed; omitted flags keep their current\nvalues. The enabled/disabled state and region are also preserved.\n\nExample (rotate storage account only):\n\n```\ntemporal cloud namespace export azure-blob update --namespace my-namespace.my-account --sink-name my-sink \\\n --storage-account my-new-storage-account\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.TenantId, "tenant-id", "", "The Azure tenant ID where the storage account exists and where Temporal's app registration is consented/granted access. If omitted, the current value is kept.") + s.Command.Flags().StringVar(&s.SubscriptionId, "subscription-id", "", "The Azure subscription ID that contains the storage account. If omitted, the current value is kept.") + s.Command.Flags().StringVar(&s.ResourceGroup, "resource-group", "", "The Azure resource group that contains the storage account. If omitted, the current value is kept.") + s.Command.Flags().StringVar(&s.StorageAccount, "storage-account", "", "The name of the destination Azure storage account where Temporal will send data. If omitted, the current value is kept.") + s.Command.Flags().StringVar(&s.ContainerName, "container-name", "", "The name of the destination Azure Blob container where Temporal will send data. If omitted, the current value is kept.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.ExportSinkOptions.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 CloudNamespaceExportAzureBlobValidateCommand struct { + Parent *CloudNamespaceExportAzureBlobCommand + Command cobra.Command + ClientOptions + NamespaceOptions + ExportSinkOptions + ExportAzureBlobOptions + ExportAzureBlobRegionOptions +} + +func NewCloudNamespaceExportAzureBlobValidateCommand(cctx *CommandContext, parent *CloudNamespaceExportAzureBlobCommand) *CloudNamespaceExportAzureBlobValidateCommand { + var s CloudNamespaceExportAzureBlobValidateCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "validate [flags]" + s.Command.Short = "Validate an Azure Blob workflow history export sink configuration" + if hasHighlighting { + s.Command.Long = "Validate an Azure Blob workflow history export sink configuration without creating or updating it.\nA successful response means the configuration is valid.\n\nExample:\n\n\x1b[1mtemporal cloud namespace export azure-blob validate --namespace my-namespace.my-account --sink-name my-sink \\\n --tenant-id 11111111-1111-1111-1111-111111111111 \\\n --subscription-id 22222222-2222-2222-2222-222222222222 \\\n --resource-group my-resource-group --storage-account my-storage-account \\\n --container-name my-container --region eastus\x1b[0m" + } else { + s.Command.Long = "Validate an Azure Blob workflow history export sink configuration without creating or updating it.\nA successful response means the configuration is valid.\n\nExample:\n\n```\ntemporal cloud namespace export azure-blob validate --namespace my-namespace.my-account --sink-name my-sink \\\n --tenant-id 11111111-1111-1111-1111-111111111111 \\\n --subscription-id 22222222-2222-2222-2222-222222222222 \\\n --resource-group my-resource-group --storage-account my-storage-account \\\n --container-name my-container --region eastus\n```" + } + s.Command.Args = cobra.NoArgs + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.BuildFlags(s.Command.Flags()) + s.ExportSinkOptions.BuildFlags(s.Command.Flags()) + s.ExportAzureBlobOptions.BuildFlags(s.Command.Flags()) + s.ExportAzureBlobRegionOptions.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 CloudNamespaceExportDeleteCommand struct { Parent *CloudNamespaceExportCommand Command cobra.Command diff --git a/temporalcloudcli/commands.namespace.export.go b/temporalcloudcli/commands.namespace.export.go index 4307c5e..38ee15c 100644 --- a/temporalcloudcli/commands.namespace.export.go +++ b/temporalcloudcli/commands.namespace.export.go @@ -143,6 +143,52 @@ type ( Cloud cloudservice.CloudServiceClient Printer *printer.Printer } + + CreateAzureBlobExportSinkParams struct { + Namespace string + SinkName string + TenantID string + SubscriptionID string + ResourceGroup string + StorageAccount string + ContainerName string + Region string + AsyncOperationID string + + Cloud cloudservice.CloudServiceClient + Prompter Prompter + OperationHandler AsyncOperationHandler + } + + UpdateAzureBlobExportSinkParams struct { + Namespace string + SinkName string + TenantID string + SubscriptionID string + ResourceGroup string + StorageAccount string + ContainerName string + ResourceVersion string + AsyncOperationID string + + Cloud cloudservice.CloudServiceClient + Prompter Prompter + OperationHandler AsyncOperationHandler + } + + ValidateAzureBlobExportSinkParams struct { + Namespace string + SinkName string + TenantID string + SubscriptionID string + ResourceGroup string + StorageAccount string + ContainerName string + Region string + + Cloud cloudservice.CloudServiceClient + Printer *printer.Printer + } ) func GetExportSink(ctx context.Context, params GetExportSinkParams) error { @@ -476,6 +522,110 @@ func ValidateGCSExportSink(ctx context.Context, params ValidateGCSExportSinkPara ) } +func CreateAzureBlobExportSink(ctx context.Context, params CreateAzureBlobExportSinkParams) error { + spec := &namespacev1.ExportSinkSpec{ + Name: params.SinkName, + Enabled: true, + AzureBlob: &sinkv1.AzureBlobSpec{ + TenantId: params.TenantID, + SubscriptionId: params.SubscriptionID, + ResourceGroup: params.ResourceGroup, + StorageAccount: params.StorageAccount, + ContainerName: params.ContainerName, + Region: params.Region, + }, + } + if err := params.Prompter.PromptApply(&namespacev1.ExportSinkSpec{}, spec, false); err != nil { + return err + } + createSink := wrapCreateOperation( + params.Cloud.CreateNamespaceExportSink, + params.OperationHandler, + func(_ *cloudservice.CreateNamespaceExportSinkResponse) string { return params.SinkName }, + ) + return createSink(ctx, &cloudservice.CreateNamespaceExportSinkRequest{ + Namespace: params.Namespace, + Spec: spec, + AsyncOperationId: params.AsyncOperationID, + }) +} + +func UpdateAzureBlobExportSink(ctx context.Context, params UpdateAzureBlobExportSinkParams) error { + sinkRes, err := params.Cloud.GetNamespaceExportSink(ctx, &cloudservice.GetNamespaceExportSinkRequest{ + Namespace: params.Namespace, + Name: params.SinkName, + }) + if err != nil { + return err + } + sink := sinkRes.Sink + + rv := sink.ResourceVersion + if params.ResourceVersion != "" { + rv = params.ResourceVersion + } + + oldSpec := sink.Spec + newAzureBlob := proto.Clone(oldSpec.GetAzureBlob()).(*sinkv1.AzureBlobSpec) + if params.TenantID != "" { + newAzureBlob.TenantId = params.TenantID + } + if params.SubscriptionID != "" { + newAzureBlob.SubscriptionId = params.SubscriptionID + } + if params.ResourceGroup != "" { + newAzureBlob.ResourceGroup = params.ResourceGroup + } + if params.StorageAccount != "" { + newAzureBlob.StorageAccount = params.StorageAccount + } + if params.ContainerName != "" { + newAzureBlob.ContainerName = params.ContainerName + } + newSpec := &namespacev1.ExportSinkSpec{ + Name: params.SinkName, + Enabled: oldSpec.GetEnabled(), + AzureBlob: newAzureBlob, + } + + if err := params.Prompter.PromptApply(oldSpec, newSpec, false); err != nil { + return err + } + + updateSink := wrapUpdateOperation(params.Cloud.UpdateNamespaceExportSink, params.OperationHandler, params.SinkName) + return updateSink(ctx, &cloudservice.UpdateNamespaceExportSinkRequest{ + Namespace: params.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: params.AsyncOperationID, + }) +} + +func ValidateAzureBlobExportSink(ctx context.Context, params ValidateAzureBlobExportSinkParams) error { + spec := &namespacev1.ExportSinkSpec{ + Name: params.SinkName, + AzureBlob: &sinkv1.AzureBlobSpec{ + TenantId: params.TenantID, + SubscriptionId: params.SubscriptionID, + ResourceGroup: params.ResourceGroup, + StorageAccount: params.StorageAccount, + ContainerName: params.ContainerName, + Region: params.Region, + }, + } + _, err := params.Cloud.ValidateNamespaceExportSink(ctx, &cloudservice.ValidateNamespaceExportSinkRequest{ + Namespace: params.Namespace, + Spec: spec, + }) + if err != nil { + return err + } + return params.Printer.PrintStructured( + struct{ Status string }{Status: fmt.Sprintf("Export sink %q configuration is valid.", params.SinkName)}, + printer.StructuredOptions{}, + ) +} + func (c *CloudNamespaceExportGetCommand) run(cctx *CommandContext, _ []string) error { cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) if err != nil { @@ -693,3 +843,64 @@ func (c *CloudNamespaceExportGcsValidateCommand) run(cctx *CommandContext, _ []s Printer: cctx.Printer, }) } + +func (c *CloudNamespaceExportAzureBlobCreateCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + return CreateAzureBlobExportSink(cctx.Context, CreateAzureBlobExportSinkParams{ + Namespace: c.Namespace, + SinkName: c.SinkName, + TenantID: c.TenantId, + SubscriptionID: c.SubscriptionId, + ResourceGroup: c.ResourceGroup, + StorageAccount: c.StorageAccount, + ContainerName: c.ContainerName, + Region: c.Region, + AsyncOperationID: c.AsyncOperationId, + Cloud: cloudClient.CloudService(), + Prompter: newPrompter(cctx), + OperationHandler: NewOperationHandler(cctx, c.AsyncOperationOptions, c.ClientOptions), + }) +} + +func (c *CloudNamespaceExportAzureBlobUpdateCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + return UpdateAzureBlobExportSink(cctx.Context, UpdateAzureBlobExportSinkParams{ + Namespace: c.Namespace, + SinkName: c.SinkName, + TenantID: c.TenantId, + SubscriptionID: c.SubscriptionId, + ResourceGroup: c.ResourceGroup, + StorageAccount: c.StorageAccount, + ContainerName: c.ContainerName, + ResourceVersion: c.ResourceVersion, + AsyncOperationID: c.AsyncOperationId, + Cloud: cloudClient.CloudService(), + Prompter: newPrompter(cctx), + OperationHandler: NewOperationHandler(cctx, c.AsyncOperationOptions, c.ClientOptions), + }) +} + +func (c *CloudNamespaceExportAzureBlobValidateCommand) run(cctx *CommandContext, _ []string) error { + cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + if err != nil { + return err + } + return ValidateAzureBlobExportSink(cctx.Context, ValidateAzureBlobExportSinkParams{ + Namespace: c.Namespace, + SinkName: c.SinkName, + TenantID: c.TenantId, + SubscriptionID: c.SubscriptionId, + ResourceGroup: c.ResourceGroup, + StorageAccount: c.StorageAccount, + ContainerName: c.ContainerName, + Region: c.Region, + Cloud: cloudClient.CloudService(), + Printer: cctx.Printer, + }) +} diff --git a/temporalcloudcli/commands.namespace.export_test.go b/temporalcloudcli/commands.namespace.export_test.go index cb4d806..59bea21 100644 --- a/temporalcloudcli/commands.namespace.export_test.go +++ b/temporalcloudcli/commands.namespace.export_test.go @@ -38,6 +38,18 @@ func testGCSSpec() *sinkv1.GCSSpec { } } +// testAzureBlobSpec returns a sample Azure Blob spec for use in tests. +func testAzureBlobSpec() *sinkv1.AzureBlobSpec { + return &sinkv1.AzureBlobSpec{ + TenantId: "my-tenant", + SubscriptionId: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + } +} + // testExportSink returns a sample ExportSink with an S3 spec for use in tests. func testExportSink(enabled bool) *namespacev1.ExportSink { return &namespacev1.ExportSink{ @@ -520,14 +532,14 @@ func TestCreateS3ExportSink_Success(t *testing.T) { Return(nil) err := temporalcloudcli.CreateS3ExportSink(context.Background(), temporalcloudcli.CreateS3ExportSinkParams{ - Namespace: "my-namespace", - SinkName: "my-sink", - RoleName: "my-role", - BucketName: "my-bucket", - Region: "us-east-1", - AwsAccountID: "123456789012", - Cloud: mockCloud, - Prompter: mockPrompter, + Namespace: "my-namespace", + SinkName: "my-sink", + RoleName: "my-role", + BucketName: "my-bucket", + Region: "us-east-1", + AwsAccountID: "123456789012", + Cloud: mockCloud, + Prompter: mockPrompter, OperationHandler: mockHandler, }) require.NoError(t, err) @@ -1209,3 +1221,378 @@ func TestValidateGCSExportSink_Error(t *testing.T) { }) require.ErrorIs(t, err, apiErr) } + +// --- CreateAzureBlobExportSink --- + +func TestCreateAzureBlobExportSink_Success(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + expectedSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + } + mockPrompter.EXPECT(). + PromptApply(&namespacev1.ExportSinkSpec{}, expectedSpec, false). + Return(nil) + + op := &operation.AsyncOperation{Id: "op-123"} + mockCloud.EXPECT(). + CreateNamespaceExportSink(context.Background(), &cloudservice.CreateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: expectedSpec, + }). + Return(&cloudservice.CreateNamespaceExportSinkResponse{AsyncOperation: op}, nil) + + mockHandler.EXPECT(). + HandleOperation(op, "my-sink"). + Return(nil) + + err := temporalcloudcli.CreateAzureBlobExportSink(context.Background(), temporalcloudcli.CreateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + SubscriptionID: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.NoError(t, err) +} + +func TestCreateAzureBlobExportSink_PromptDeclined(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + promptErr := errors.New("Aborting apply.") + + expectedSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + } + mockPrompter.EXPECT(). + PromptApply(&namespacev1.ExportSinkSpec{}, expectedSpec, false). + Return(promptErr) + + err := temporalcloudcli.CreateAzureBlobExportSink(context.Background(), temporalcloudcli.CreateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + SubscriptionID: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.ErrorIs(t, err, promptErr) +} + +func TestCreateAzureBlobExportSink_CreateError(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + createErr := errors.New("create error") + + expectedSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + } + mockPrompter.EXPECT(). + PromptApply(&namespacev1.ExportSinkSpec{}, expectedSpec, false). + Return(nil) + + mockCloud.EXPECT(). + CreateNamespaceExportSink(context.Background(), &cloudservice.CreateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: expectedSpec, + }). + Return(nil, createErr) + + mockHandler.EXPECT(). + HandleCreateErr(createErr). + Return(createErr) + + err := temporalcloudcli.CreateAzureBlobExportSink(context.Background(), temporalcloudcli.CreateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + SubscriptionID: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.ErrorIs(t, err, createErr) +} + +// --- UpdateAzureBlobExportSink --- + +func TestUpdateAzureBlobExportSink_Success(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + existingSink := &namespacev1.ExportSink{ + ResourceVersion: "rv-1", + Spec: &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + }, + } + mockCloud.EXPECT(). + GetNamespaceExportSink(context.Background(), &cloudservice.GetNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Name: "my-sink", + }). + Return(&cloudservice.GetNamespaceExportSinkResponse{Sink: existingSink}, nil) + + newSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: &sinkv1.AzureBlobSpec{ + TenantId: "new-tenant", + SubscriptionId: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + }, + } + mockPrompter.EXPECT(). + PromptApply(existingSink.Spec, newSpec, false). + Return(nil) + + op := &operation.AsyncOperation{Id: "op-123"} + mockCloud.EXPECT(). + UpdateNamespaceExportSink(context.Background(), &cloudservice.UpdateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: newSpec, + ResourceVersion: "rv-1", + }). + Return(&cloudservice.UpdateNamespaceExportSinkResponse{AsyncOperation: op}, nil) + + mockHandler.EXPECT(). + HandleOperation(op, "my-sink"). + Return(nil) + + err := temporalcloudcli.UpdateAzureBlobExportSink(context.Background(), temporalcloudcli.UpdateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "new-tenant", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.NoError(t, err) +} + +func TestUpdateAzureBlobExportSink_PromptDeclined(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + promptErr := errors.New("Aborting apply.") + + existingSink := &namespacev1.ExportSink{ + ResourceVersion: "rv-1", + Spec: &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + }, + } + mockCloud.EXPECT(). + GetNamespaceExportSink(context.Background(), &cloudservice.GetNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Name: "my-sink", + }). + Return(&cloudservice.GetNamespaceExportSinkResponse{Sink: existingSink}, nil) + + newSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: &sinkv1.AzureBlobSpec{ + TenantId: "new-tenant", + SubscriptionId: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + }, + } + mockPrompter.EXPECT(). + PromptApply(existingSink.Spec, newSpec, false). + Return(promptErr) + + err := temporalcloudcli.UpdateAzureBlobExportSink(context.Background(), temporalcloudcli.UpdateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "new-tenant", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.ErrorIs(t, err, promptErr) +} + +func TestUpdateAzureBlobExportSink_GetSinkError(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + apiErr := errors.New("api error") + + mockCloud.EXPECT(). + GetNamespaceExportSink(context.Background(), &cloudservice.GetNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Name: "my-sink", + }). + Return(nil, apiErr) + + err := temporalcloudcli.UpdateAzureBlobExportSink(context.Background(), temporalcloudcli.UpdateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.ErrorIs(t, err, apiErr) +} + +func TestUpdateAzureBlobExportSink_PartialUpdate(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + existingSink := &namespacev1.ExportSink{ + ResourceVersion: "rv-1", + Spec: &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: testAzureBlobSpec(), + }, + } + mockCloud.EXPECT(). + GetNamespaceExportSink(context.Background(), &cloudservice.GetNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Name: "my-sink", + }). + Return(&cloudservice.GetNamespaceExportSinkResponse{Sink: existingSink}, nil) + + // Only the storage account and container name change; the rest must be preserved. + expectedNewSpec := &namespacev1.ExportSinkSpec{ + Name: "my-sink", + Enabled: true, + AzureBlob: &sinkv1.AzureBlobSpec{ + TenantId: "my-tenant", + SubscriptionId: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "new-storage-account", + ContainerName: "new-container", + Region: "eastus", + }, + } + mockPrompter.EXPECT(). + PromptApply(existingSink.Spec, expectedNewSpec, false). + Return(nil) + + op := &operation.AsyncOperation{Id: "op-partial-azure"} + mockCloud.EXPECT(). + UpdateNamespaceExportSink(context.Background(), &cloudservice.UpdateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: expectedNewSpec, + ResourceVersion: "rv-1", + }). + Return(&cloudservice.UpdateNamespaceExportSinkResponse{AsyncOperation: op}, nil) + + mockHandler.EXPECT(). + HandleOperation(op, "my-sink"). + Return(nil) + + err := temporalcloudcli.UpdateAzureBlobExportSink(context.Background(), temporalcloudcli.UpdateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + StorageAccount: "new-storage-account", + ContainerName: "new-container", + Cloud: mockCloud, + Prompter: mockPrompter, + OperationHandler: mockHandler, + }) + require.NoError(t, err) +} + +// --- ValidateAzureBlobExportSink --- + +func TestValidateAzureBlobExportSink_Success(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + + mockCloud.EXPECT(). + ValidateNamespaceExportSink(context.Background(), &cloudservice.ValidateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: &namespacev1.ExportSinkSpec{ + Name: "my-sink", + AzureBlob: testAzureBlobSpec(), + }, + }). + Return(&cloudservice.ValidateNamespaceExportSinkResponse{}, nil) + + var buf bytes.Buffer + err := temporalcloudcli.ValidateAzureBlobExportSink(context.Background(), temporalcloudcli.ValidateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + SubscriptionID: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + Cloud: mockCloud, + Printer: newTestPrinter(&buf), + }) + require.NoError(t, err) + assert.Contains(t, buf.String(), "my-sink") +} + +func TestValidateAzureBlobExportSink_Error(t *testing.T) { + mockCloud := cloudmock.NewMockCloudServiceClient(t) + apiErr := errors.New("invalid config") + + mockCloud.EXPECT(). + ValidateNamespaceExportSink(context.Background(), &cloudservice.ValidateNamespaceExportSinkRequest{ + Namespace: "my-namespace", + Spec: &namespacev1.ExportSinkSpec{ + Name: "my-sink", + AzureBlob: testAzureBlobSpec(), + }, + }). + Return(nil, apiErr) + + var buf bytes.Buffer + err := temporalcloudcli.ValidateAzureBlobExportSink(context.Background(), temporalcloudcli.ValidateAzureBlobExportSinkParams{ + Namespace: "my-namespace", + SinkName: "my-sink", + TenantID: "my-tenant", + SubscriptionID: "my-subscription", + ResourceGroup: "my-resource-group", + StorageAccount: "my-storage-account", + ContainerName: "my-container", + Region: "eastus", + Cloud: mockCloud, + Printer: newTestPrinter(&buf), + }) + require.ErrorIs(t, err, apiErr) +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 13b9b28..02c75f2 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -1009,7 +1009,7 @@ commands: description: | Commands for managing workflow history export sinks for Temporal Cloud namespaces. - Export sinks define destinations (S3 or GCS) to which workflow history is exported. + Export sinks define destinations (S3, GCS, or Azure Blob) to which workflow history is exported. has-init: false - name: cloud namespace export get summary: Get a workflow history export sink @@ -1256,6 +1256,103 @@ commands: - export-sink - export-gcs - export-gcs-region + - name: cloud namespace export azure-blob + summary: Manage Azure Blob workflow history export sinks + description: | + Commands for managing Azure Blob workflow history export sinks for Temporal Cloud namespaces. + has-init: false + - name: cloud namespace export azure-blob create + summary: Create an Azure Blob workflow history export sink + description: | + Create a new Azure Blob workflow history export sink for a Temporal Cloud namespace. + The sink is created in the enabled state. + + Example: + + ``` + temporal cloud namespace export azure-blob create --namespace my-namespace.my-account --sink-name my-sink \ + --tenant-id 11111111-1111-1111-1111-111111111111 \ + --subscription-id 22222222-2222-2222-2222-222222222222 \ + --resource-group my-resource-group --storage-account my-storage-account \ + --container-name my-container --region eastus + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - export-sink + - export-azure-blob + - export-azure-blob-region + - name: cloud namespace export azure-blob update + summary: Update an Azure Blob workflow history export sink + description: | + Update the configuration of an existing Azure Blob workflow history export sink. + Only the flags you provide are changed; omitted flags keep their current + values. The enabled/disabled state and region are also preserved. + + Example (rotate storage account only): + + ``` + temporal cloud namespace export azure-blob update --namespace my-namespace.my-account --sink-name my-sink \ + --storage-account my-new-storage-account + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + - export-sink + options: + - name: tenant-id + type: string + description: | + The Azure tenant ID where the storage account exists and where Temporal's + app registration is consented/granted access. + If omitted, the current value is kept. + - name: subscription-id + type: string + description: | + The Azure subscription ID that contains the storage account. + If omitted, the current value is kept. + - name: resource-group + type: string + description: | + The Azure resource group that contains the storage account. + If omitted, the current value is kept. + - name: storage-account + type: string + description: | + The name of the destination Azure storage account where Temporal will send data. + If omitted, the current value is kept. + - name: container-name + type: string + description: | + The name of the destination Azure Blob container where Temporal will send data. + If omitted, the current value is kept. + - name: cloud namespace export azure-blob validate + summary: Validate an Azure Blob workflow history export sink configuration + description: | + Validate an Azure Blob workflow history export sink configuration without creating or updating it. + A successful response means the configuration is valid. + + Example: + + ``` + temporal cloud namespace export azure-blob validate --namespace my-namespace.my-account --sink-name my-sink \ + --tenant-id 11111111-1111-1111-1111-111111111111 \ + --subscription-id 22222222-2222-2222-2222-222222222222 \ + --resource-group my-resource-group --storage-account my-storage-account \ + --container-name my-container --region eastus + ``` + has-init: false + option-sets: + - client + - namespace + - export-sink + - export-azure-blob + - export-azure-blob-region # Namespace codec commands - name: cloud namespace codec @@ -4584,3 +4681,38 @@ option-sets: required: true description: | The GCS bucket region. + - name: export-azure-blob + options: + - name: tenant-id + type: string + required: true + description: | + The Azure tenant ID where the storage account exists and where Temporal's + app registration is consented/granted access. + - name: subscription-id + type: string + required: true + description: | + The Azure subscription ID that contains the storage account. + - name: resource-group + type: string + required: true + description: | + The Azure resource group that contains the storage account. + - name: storage-account + type: string + required: true + description: | + The name of the destination Azure storage account where Temporal will send data. + - name: container-name + type: string + required: true + description: | + The name of the destination Azure Blob container where Temporal will send data. + - name: export-azure-blob-region + options: + - name: region + type: string + required: true + description: | + The region where the Azure storage account is located.