From 8dd25b5de770ade94f7f7b54e5787fe7d9c8547c Mon Sep 17 00:00:00 2001 From: daehan Date: Tue, 15 Sep 2026 03:02:30 -0400 Subject: [PATCH] add eve to cloud-cli --- go.mod | 2 +- go.sum | 4 +- temporalcloudcli/commands.gen.go | 195 ++++++- temporalcloudcli/commands.go | 2 + .../commands.namespace.create_test.go | 58 ++ ...ommands.namespace.encryption_validation.go | 235 ++++++++ ...ds.namespace.encryption_validation_test.go | 528 ++++++++++++++++++ temporalcloudcli/commands.namespace.go | 13 +- temporalcloudcli/commands.yml | 129 +++++ ...eprecated_field_without_suffix.golden.json | 1 + 10 files changed, 1151 insertions(+), 16 deletions(-) create mode 100644 temporalcloudcli/commands.namespace.encryption_validation.go create mode 100644 temporalcloudcli/commands.namespace.encryption_validation_test.go diff --git a/go.mod b/go.mod index d2e9d18..a76b346 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.18.1-0.20260915065746-d48c11f885cb 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..a22af60 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.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.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.18.1-0.20260915065746-d48c11f885cb h1:15TRMXvRKuStKfSfr2LpoG2+mPFYTMpH9M/zl9RKVp8= +go.temporal.io/cloud-sdk v0.18.1-0.20260915065746-d48c11f885cb/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 7701ba6..b2c72b7 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -24,7 +24,7 @@ type ClientOptions struct { func (v *ClientOptions) BuildFlags(f *pflag.FlagSet) { v.FlagSet = f - f.StringVar(&v.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. Env: TEMPORAL_API_KEY.") + f.StringVar(&v.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") f.StringVar(&v.Server, "server", "saas-api.tmprl.cloud:443", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") _ = f.MarkHidden("server") } @@ -1890,6 +1890,7 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud s.Command.AddCommand(&NewCloudNamespaceDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceDescriptionCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceEditCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceExportCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceFairnessCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudNamespaceGetCommand(cctx, &s).Command) @@ -2397,17 +2398,22 @@ type CloudNamespaceCreateCommand struct { CodecServerOptions CaCertificateOptions CertificateFilterOptions - Name string - Region []string - RetentionDays int - ApiKeyAuthEnabled bool - MtlsAuthEnabled bool - EnableDeleteProtection bool - EnableTaskQueueFairness bool - SearchAttribute []string - ConnectionRuleId []string - ProjectId string - Description string + Name string + Region []string + RetentionDays int + ApiKeyAuthEnabled bool + MtlsAuthEnabled bool + EnableDeleteProtection bool + EnableTaskQueueFairness bool + SearchAttribute []string + ConnectionRuleId []string + ProjectId string + Description string + EncryptionValidationMode string + EncryptionValidationMetadataKey string + EncryptionValidationMetadataValue []string + EncryptionValidationInspectHeader bool + EncryptionValidationInspectFailure bool } func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceCreateCommand { @@ -2435,6 +2441,11 @@ func NewCloudNamespaceCreateCommand(cctx *CommandContext, parent *CloudNamespace 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.Command.Flags().StringVar(&s.Description, "description", "", "The description is a human-readable description of the namespace. Must be at most 255 printable ASCII characters plus whitespace. Optional, default is empty.") + s.Command.Flags().StringVar(&s.EncryptionValidationMode, "encryption-validation-mode", "", "Payload encryption validation mode. Valid values: disabled, warn, deny. When any encryption-validation flag is set, this field is required.") + s.Command.Flags().StringVar(&s.EncryptionValidationMetadataKey, "encryption-validation-metadata-key", "", "Payload metadata key used to identify encrypted payloads. If omitted, an empty key is sent.") + s.Command.Flags().StringArrayVar(&s.EncryptionValidationMetadataValue, "encryption-validation-metadata-value", nil, "Payload metadata value used to identify encrypted payloads. Repeat to add additional values. If omitted, no values are sent.") + s.Command.Flags().BoolVar(&s.EncryptionValidationInspectHeader, "encryption-validation-inspect-header", false, "Inspect workflow headers for encryption validation. Default is false.") + s.Command.Flags().BoolVar(&s.EncryptionValidationInspectFailure, "encryption-validation-inspect-failure", false, "Inspect Failure payloads for encryption validation. Default is false.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) s.CodecServerOptions.BuildFlags(s.Command.Flags()) @@ -2608,6 +2619,166 @@ func NewCloudNamespaceEditCommand(cctx *CommandContext, parent *CloudNamespaceCo return &s } +type CloudNamespaceEncryptionValidationCommand struct { + Parent *CloudNamespaceCommand + Command cobra.Command +} + +func NewCloudNamespaceEncryptionValidationCommand(cctx *CommandContext, parent *CloudNamespaceCommand) *CloudNamespaceEncryptionValidationCommand { + var s CloudNamespaceEncryptionValidationCommand + s.Parent = parent + s.Command.Use = "encryption-validation" + s.Command.Short = "Manage namespace payload encryption validation settings" + s.Command.Long = "Commands for managing payload encryption validation configuration of Temporal Cloud namespaces." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationDisableCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationEnableCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationGetCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudNamespaceEncryptionValidationSetCommand(cctx, &s).Command) + return &s +} + +type CloudNamespaceEncryptionValidationDisableCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions +} + +func NewCloudNamespaceEncryptionValidationDisableCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationDisableCommand { + var s CloudNamespaceEncryptionValidationDisableCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "disable [flags]" + s.Command.Short = "Disable payload encryption validation for a namespace" + if hasHighlighting { + s.Command.Long = "Disable payload encryption validation for a Temporal Cloud namespace.\nExisting metadata and inspect settings are kept.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation disable --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Disable payload encryption validation for a Temporal Cloud namespace.\nExisting metadata and inspect settings are kept.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation disable --namespace my-namespace.my-account\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.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 CloudNamespaceEncryptionValidationEnableCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions + Deny bool +} + +func NewCloudNamespaceEncryptionValidationEnableCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationEnableCommand { + var s CloudNamespaceEncryptionValidationEnableCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "enable [flags]" + s.Command.Short = "Enable payload encryption validation for a namespace" + if hasHighlighting { + s.Command.Long = "Enable payload encryption validation for a Temporal Cloud namespace.\nIf validation has not been configured, default values are applied\n(mode=warn, metadata-key=encoding, metadata-values=[binary/encrypted]).\nIf validation is disabled, the existing configuration is re-enabled.\nIf validation is already warn or deny, the existing configuration is kept.\nUse --deny to enable in deny mode.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation enable --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Enable payload encryption validation for a Temporal Cloud namespace.\nIf validation has not been configured, default values are applied\n(mode=warn, metadata-key=encoding, metadata-values=[binary/encrypted]).\nIf validation is disabled, the existing configuration is re-enabled.\nIf validation is already warn or deny, the existing configuration is kept.\nUse --deny to enable in deny mode.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation enable --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().BoolVar(&s.Deny, "deny", false, "Enable validation in deny mode instead of warn.") + 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.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + +type CloudNamespaceEncryptionValidationGetCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions +} + +func NewCloudNamespaceEncryptionValidationGetCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationGetCommand { + var s CloudNamespaceEncryptionValidationGetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "get [flags]" + s.Command.Short = "Get namespace payload encryption validation configuration" + if hasHighlighting { + s.Command.Long = "Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation get --namespace my-namespace.my-account\x1b[0m" + } else { + s.Command.Long = "Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation get --namespace my-namespace.my-account\n```" + } + s.Command.Args = cobra.NoArgs + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.NamespaceOptions.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 CloudNamespaceEncryptionValidationSetCommand struct { + Parent *CloudNamespaceEncryptionValidationCommand + Command cobra.Command + ClientOptions + NamespaceOptions + AsyncOperationOptions + ResourceVersionOptions + Mode string + MetadataKey string + MetadataValue []string + InspectHeader bool + InspectFailure bool +} + +func NewCloudNamespaceEncryptionValidationSetCommand(cctx *CommandContext, parent *CloudNamespaceEncryptionValidationCommand) *CloudNamespaceEncryptionValidationSetCommand { + var s CloudNamespaceEncryptionValidationSetCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set [flags]" + s.Command.Short = "Set namespace payload encryption validation configuration" + if hasHighlighting { + s.Command.Long = "Replace the payload encryption validation configuration for a Temporal Cloud\nnamespace. Omitted optional flags are sent as empty or false and do not\nkeep the existing values.\n\nExample:\n\n\x1b[1mtemporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted\x1b[0m" + } else { + s.Command.Long = "Replace the payload encryption validation configuration for a Temporal Cloud\nnamespace. Omitted optional flags are sent as empty or false and do not\nkeep the existing values.\n\nExample:\n\n```\ntemporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.Mode, "mode", "", "Payload encryption validation mode. Valid values: disabled, warn, deny. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "mode") + s.Command.Flags().StringVar(&s.MetadataKey, "metadata-key", "", "Payload metadata key used to identify encrypted payloads. If omitted, an empty key is sent.") + s.Command.Flags().StringArrayVar(&s.MetadataValue, "metadata-value", nil, "Payload metadata value used to identify encrypted payloads. Repeat to add additional values. If omitted, no values are sent.") + s.Command.Flags().BoolVar(&s.InspectHeader, "inspect-header", false, "Inspect workflow headers for encryption validation. Default is false.") + s.Command.Flags().BoolVar(&s.InspectFailure, "inspect-failure", false, "Inspect Failure payloads for encryption validation. Default is false.") + 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.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudNamespaceExportCommand struct { Parent *CloudNamespaceCommand Command cobra.Command diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go index eccefcc..45739ac 100644 --- a/temporalcloudcli/commands.go +++ b/temporalcloudcli/commands.go @@ -172,6 +172,8 @@ func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudcl } opts := cloudclient.Options{ UserAgent: fmt.Sprintf("temporalio-cloud-cli/%s", VersionString()), + // Temporary: staging Cloud Ops still gates EncryptionValidation behind development. + APIVersion: "development", } if cloudOpts.Server != "" { opts.HostPort = cloudOpts.Server diff --git a/temporalcloudcli/commands.namespace.create_test.go b/temporalcloudcli/commands.namespace.create_test.go index bfd0e72..f8c3933 100644 --- a/temporalcloudcli/commands.namespace.create_test.go +++ b/temporalcloudcli/commands.namespace.create_test.go @@ -435,3 +435,61 @@ func TestCreateNamespace_InvalidInput(t *testing.T) { }) } } + +func TestCreateNamespace_EncryptionValidation(t *testing.T) { + tests := []struct { + name string + spec *namespacev1.EncryptionValidationSpec + expected *namespacev1.EncryptionValidationSpec + }{ + {name: "Unset", spec: nil, expected: nil}, + { + name: "Explicit", + spec: &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + }, + expected: &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedSpec := baseNamespaceSpec() + expectedSpec.EncryptionValidation = tt.expected + + mockCloud := cloudmock.NewMockCloudServiceClient(t) + mockPrompter := cmdmock.NewMockPrompter(t) + mockHandler := cmdmock.NewMockAsyncOperationHandler(t) + + mockPrompter.EXPECT(). + PromptApply(&namespacev1.NamespaceSpec{}, specMatcher(expectedSpec), false). + Return(nil) + mockCloud.EXPECT(). + CreateNamespace(context.Background(), createReqMatcher(expectedSpec)). + 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"}, + Cloud: mockCloud, + Printer: &printer.Printer{Output: &buf, JSON: true}, + Prompter: mockPrompter, + UnmarshalProtoJSON: noopUnmarshalProtoJSON, + OperationHandler: mockHandler, + EncryptionValidation: tt.spec, + }) + require.NoError(t, err) + }) + } +} diff --git a/temporalcloudcli/commands.namespace.encryption_validation.go b/temporalcloudcli/commands.namespace.encryption_validation.go new file mode 100644 index 0000000..1da059a --- /dev/null +++ b/temporalcloudcli/commands.namespace.encryption_validation.go @@ -0,0 +1,235 @@ +package temporalcloudcli + +import ( + "errors" + "fmt" + "strings" + + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + "go.temporal.io/sdk/converter" + "google.golang.org/protobuf/proto" + + "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" +) + +const ( + defaultEncryptionValidationMetadataKey = converter.MetadataEncoding + defaultEncryptionValidationMetadataValue = "binary/encrypted" + + encryptionValidationModeDisabled = "disabled" + encryptionValidationModeWarn = "warn" + encryptionValidationModeDeny = "deny" +) + +func (c *CloudNamespaceEncryptionValidationGetCommand) run(cctx *CommandContext, _ []string) error { + 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 + } + + var spec *namespacev1.EncryptionValidationSpec + if res.Namespace.Spec != nil { + spec = res.Namespace.Spec.EncryptionValidation + } + return cctx.Printer.PrintResource(struct { + Namespace string `json:"namespace"` + Spec *namespacev1.EncryptionValidationSpec `json:"spec"` + }{ + Namespace: res.Namespace.Namespace, + Spec: spec, + }, printer.PrintResourceOptions{}) +} + +func (c *CloudNamespaceEncryptionValidationSetCommand) run(cctx *CommandContext, _ []string) error { + mode, err := parseEncryptionValidationMode(c.Mode) + if err != nil { + return err + } + + 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 + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + newSpec.EncryptionValidation = &namespacev1.EncryptionValidationSpec{ + Mode: mode, + MetadataKey: c.MetadataKey, + MetadataValues: c.MetadataValue, + InspectHeader: c.InspectHeader, + InspectFailure: c.InspectFailure, + } + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting set.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudNamespaceEncryptionValidationEnableCommand) run(cctx *CommandContext, _ []string) error { + 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 + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + newSpec.EncryptionValidation = enableEncryptionValidation(newSpec.EncryptionValidation, c.Deny) + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting enable.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudNamespaceEncryptionValidationDisableCommand) run(cctx *CommandContext, _ []string) error { + 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 + newSpec := proto.Clone(ns.Spec).(*namespacev1.NamespaceSpec) + if newSpec.EncryptionValidation == nil { + newSpec.EncryptionValidation = &namespacev1.EncryptionValidationSpec{} + } else { + newSpec.EncryptionValidation = proto.Clone(newSpec.EncryptionValidation).(*namespacev1.EncryptionValidationSpec) + } + newSpec.EncryptionValidation.Mode = namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED + + yes, err := cctx.GetPrompter().PromptApply(ns.Spec, newSpec, false) + if err != nil { + return err + } + if !yes { + return errors.New("Aborting disable.") + } + + rv := ns.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.UpdateNamespace(cctx, &cloudservice.UpdateNamespaceRequest{ + Namespace: c.Namespace, + Spec: newSpec, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func enableEncryptionValidation(existing *namespacev1.EncryptionValidationSpec, deny bool) *namespacev1.EncryptionValidationSpec { + mode := namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN + if deny { + mode = namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY + } + + if existing == nil { + return &namespacev1.EncryptionValidationSpec{ + Mode: mode, + MetadataKey: defaultEncryptionValidationMetadataKey, + MetadataValues: []string{defaultEncryptionValidationMetadataValue}, + } + } + + spec := proto.Clone(existing).(*namespacev1.EncryptionValidationSpec) + if spec.Mode == namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED { + spec.Mode = mode + } + return spec +} + +func parseEncryptionValidationMode(value string) (namespacev1.EncryptionValidationSpec_EncryptionValidationMode, error) { + switch strings.ToLower(value) { + case encryptionValidationModeDisabled: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, nil + case encryptionValidationModeWarn: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, nil + case encryptionValidationModeDeny: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, nil + default: + return namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED, fmt.Errorf( + "invalid encryption validation mode %q: must be disabled, warn, or deny", + value, + ) + } +} + +func encryptionValidationFromCreateFlags(c *CloudNamespaceCreateCommand) (*namespacev1.EncryptionValidationSpec, error) { + flags := c.Command.Flags() + spec := &namespacev1.EncryptionValidationSpec{} + if flags.Changed("encryption-validation-mode") { + mode, err := parseEncryptionValidationMode(c.EncryptionValidationMode) + if err != nil { + return nil, err + } + spec.Mode = mode + } + if flags.Changed("encryption-validation-metadata-key") { + spec.MetadataKey = c.EncryptionValidationMetadataKey + } + if flags.Changed("encryption-validation-metadata-value") { + spec.MetadataValues = c.EncryptionValidationMetadataValue + } + if flags.Changed("encryption-validation-inspect-header") { + spec.InspectHeader = c.EncryptionValidationInspectHeader + } + if flags.Changed("encryption-validation-inspect-failure") { + spec.InspectFailure = c.EncryptionValidationInspectFailure + } + if proto.Equal(spec, &namespacev1.EncryptionValidationSpec{}) { + return nil, nil + } + if spec.Mode == namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED { + return nil, errors.New("--encryption-validation-mode is required when any encryption-validation flag is set") + } + return spec, nil +} diff --git a/temporalcloudcli/commands.namespace.encryption_validation_test.go b/temporalcloudcli/commands.namespace.encryption_validation_test.go new file mode 100644 index 0000000..cc32360 --- /dev/null +++ b/temporalcloudcli/commands.namespace.encryption_validation_test.go @@ -0,0 +1,528 @@ +package temporalcloudcli_test + +import ( + "errors" + "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" + "google.golang.org/protobuf/proto" + + cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +func namespaceWithEncryptionValidation(spec *namespacev1.EncryptionValidationSpec) *namespacev1.Namespace { + return &namespacev1.Namespace{ + Namespace: "my-ns.my-acct", + ResourceVersion: "rv-fetched", + Spec: &namespacev1.NamespaceSpec{ + Name: "my-ns", + Regions: []string{"aws-us-east-1"}, + RetentionDays: 30, + EncryptionValidation: spec, + }, + } +} + +func TestNamespaceEncryptionValidationGet(t *testing.T) { + configured := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + expectedErr string + expectedJsonOutput any + }{ + { + name: "Configured", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(configured)}, nil) + }, + expectedJsonOutput: map[string]any{ + "namespace": "my-ns.my-acct", + "spec": map[string]any{ + "mode": 2, + "metadata_key": "encoding", + "metadata_values": []any{"binary/encrypted"}, + }, + }, + }, + { + name: "NilSpec", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + expectedJsonOutput: map[string]any{ + "namespace": "my-ns.my-acct", + "spec": nil, + }, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationGetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + JSONOutput: true, + ExpectedError: tt.expectedErr, + ExpectedOutputJson: tt.expectedJsonOutput, + }) + }) + } +} + +func TestNamespaceEncryptionValidationSet(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "ReplaceSpec", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Mode: "warn", + MetadataKey: "encoding", + MetadataValue: []string{"binary/encrypted", "legacy-value"}, + InspectHeader: true, + InspectFailure: true, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.Namespace == "my-ns.my-acct" && + req.ResourceVersion == "rv-fetched" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted", "legacy-value"}, + InspectHeader: true, + InspectFailure: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-set"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-set"}, + }, + { + name: "InvalidMode", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Mode: "oops", + }, + expectedErr: `invalid encryption validation mode "oops": must be disabled, warn, or deny`, + }, + { + name: "ResourceVersionOverride", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-user"}, + Mode: "deny", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.ResourceVersion == "rv-user" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-rv"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-rv"}, + }, + { + name: "AsyncOperationIdOverride", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + AsyncOperationOptions: temporalcloudcli.AsyncOperationOptions{AsyncOperationId: "op-custom"}, + Mode: "warn", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.AsyncOperationId == "op-custom" + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-custom"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-custom"}, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Mode: "warn", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + { + name: "UpdateNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Mode: "warn", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("update failed")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + expectedErr: "update failed", + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationSetCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Mode: "warn", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: false}, + expectedErr: "Aborting set.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} + +func TestNamespaceEncryptionValidationEnable(t *testing.T) { + disabledWithCustomMetadata := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + } + warn := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + deny := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + } + + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "NilToWarnDefaults", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, &cloudservice.GetNamespaceRequest{Namespace: "my-ns.my-acct"}, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return req.Namespace == "my-ns.my-acct" && + req.ResourceVersion == "rv-fetched" && + proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "DisabledKeepsMetadata", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(disabledWithCustomMetadata)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "DenyFlag", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Deny: true, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(disabledWithCustomMetadata)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, + MetadataKey: "custom-key", + MetadataValues: []string{"custom-value"}, + InspectHeader: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "AlreadyWarn", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(warn)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, warn) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "AlreadyWarnWithDeny", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + Deny: true, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(warn)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, warn) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "AlreadyDeny", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(deny)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, deny) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-enable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-enable"}, + }, + { + name: "GetNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("namespace not found")) + }, + expectedErr: "namespace not found", + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationEnableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(nil)}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: false}, + expectedErr: "Aborting enable.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} + +func TestNamespaceEncryptionValidationDisable(t *testing.T) { + existing := &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectFailure: true, + } + tests := []struct { + name string + cmd temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "DisableFromEnabled", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(existing)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.MatchedBy(func(req *cloudservice.UpdateNamespaceRequest) bool { + return proto.Equal(req.Spec.EncryptionValidation, &namespacev1.EncryptionValidationSpec{ + Mode: namespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectFailure: true, + }) + }), mock.Anything). + Return(&cloudservice.UpdateNamespaceResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-disable"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-disable"}, + }, + { + name: "UpdateNamespaceError", + cmd: temporalcloudcli.CloudNamespaceEncryptionValidationDisableCommand{ + NamespaceOptions: temporalcloudcli.NamespaceOptions{Namespace: "my-ns.my-acct"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetNamespaceResponse{Namespace: namespaceWithEncryptionValidation(existing)}, nil) + c.EXPECT(). + UpdateNamespace(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("update failed")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + expectedErr: "update failed", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + JSONOutput: true, + ExpectedError: tt.expectedErr, + }) + }) + } +} diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 805360b..81903ab 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -330,6 +330,7 @@ type ( ConnectionRuleIDs []string ProjectID string Description string + EncryptionValidation *namespacev1.EncryptionValidationSpec Cloud cloudservice.CloudServiceClient Printer *printer.Printer @@ -391,11 +392,11 @@ func CreateNamespace(ctx context.Context, params CreateNamespaceParams) error { Name: params.Name, Regions: params.Regions, RetentionDays: params.RetentionDays, - Description: params.Description, ApiKeyAuth: &namespacev1.ApiKeyAuthSpec{Enabled: params.ApiKeyAuthEnabled}, MtlsAuth: &namespacev1.MtlsAuthSpec{Enabled: params.MtlsAuthEnabled}, Lifecycle: &namespacev1.LifecycleSpec{EnableDeleteProtection: params.EnableDeleteProtection}, ConnectivityRuleIds: params.ConnectionRuleIDs, + Description: params.Description, } // Only set fairness when the flag was explicitly provided; otherwise leave it @@ -420,6 +421,10 @@ func CreateNamespace(ctx context.Context, params CreateNamespaceParams) error { spec.SearchAttributes = searchAttrs + if params.EncryptionValidation != nil { + spec.EncryptionValidation = params.EncryptionValidation + } + if err := params.Prompter.PromptApply(&namespacev1.NamespaceSpec{}, spec, false); err != nil { return err } @@ -450,6 +455,11 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro } projectID := c.ProjectId + encryptionValidation, err := encryptionValidationFromCreateFlags(c) + if err != nil { + return err + } + return CreateNamespace(cctx.Context, CreateNamespaceParams{ Name: c.Name, Regions: c.Region, @@ -473,6 +483,7 @@ func (c *CloudNamespaceCreateCommand) run(cctx *CommandContext, _ []string) erro Prompter: newPrompter(cctx), UnmarshalProtoJSON: cctx.UnmarshalProtoJSON, OperationHandler: NewOperationHandler(cctx, c.AsyncOperationOptions, c.ClientOptions), + EncryptionValidation: encryptionValidation, }) } diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 14bb54a..755a8d3 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -446,6 +446,29 @@ 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: encryption-validation-mode + type: string + description: | + Payload encryption validation mode. Valid values: disabled, warn, deny. + When any encryption-validation flag is set, this field is required. + - name: encryption-validation-metadata-key + type: string + description: | + Payload metadata key used to identify encrypted payloads. If omitted, + an empty key is sent. + - name: encryption-validation-metadata-value + type: string[] + description: | + Payload metadata value used to identify encrypted payloads. Repeat to + add additional values. If omitted, no values are sent. + - name: encryption-validation-inspect-header + type: bool + description: | + Inspect workflow headers for encryption validation. Default is false. + - name: encryption-validation-inspect-failure + type: bool + description: | + Inspect Failure payloads for encryption validation. Default is false. # Namespace description commands - name: cloud namespace description @@ -671,6 +694,112 @@ commands: description: | Enable or disable task queue fairness for the namespace. + # Namespace encryption validation commands + - name: cloud namespace encryption-validation + summary: Manage namespace payload encryption validation settings + description: | + Commands for managing payload encryption validation configuration of Temporal Cloud namespaces. + has-init: false + - name: cloud namespace encryption-validation get + summary: Get namespace payload encryption validation configuration + description: | + Retrieve the current payload encryption validation configuration for a Temporal Cloud namespace. + + Example: + + ``` + temporal cloud namespace encryption-validation get --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - name: cloud namespace encryption-validation set + summary: Set namespace payload encryption validation configuration + description: | + Replace the payload encryption validation configuration for a Temporal Cloud + namespace. Omitted optional flags are sent as empty or false and do not + keep the existing values. + + Example: + + ``` + temporal cloud namespace encryption-validation set --namespace my-namespace.my-account --mode warn --metadata-key encoding --metadata-value binary/encrypted + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + options: + - name: mode + type: string + required: true + description: | + Payload encryption validation mode. Valid values: disabled, warn, deny. + - name: metadata-key + type: string + description: | + Payload metadata key used to identify encrypted payloads. If omitted, + an empty key is sent. + - name: metadata-value + type: string[] + description: | + Payload metadata value used to identify encrypted payloads. Repeat to + add additional values. If omitted, no values are sent. + - name: inspect-header + type: bool + description: | + Inspect workflow headers for encryption validation. Default is false. + - name: inspect-failure + type: bool + description: | + Inspect Failure payloads for encryption validation. Default is false. + - name: cloud namespace encryption-validation enable + summary: Enable payload encryption validation for a namespace + description: | + Enable payload encryption validation for a Temporal Cloud namespace. + If validation has not been configured, default values are applied + (mode=warn, metadata-key=encoding, metadata-values=[binary/encrypted]). + If validation is disabled, the existing configuration is re-enabled. + If validation is already warn or deny, the existing configuration is kept. + Use --deny to enable in deny mode. + + Example: + + ``` + temporal cloud namespace encryption-validation enable --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + options: + - name: deny + type: bool + description: | + Enable validation in deny mode instead of warn. + - name: cloud namespace encryption-validation disable + summary: Disable payload encryption validation for a namespace + description: | + Disable payload encryption validation for a Temporal Cloud namespace. + Existing metadata and inspect settings are kept. + + Example: + + ``` + temporal cloud namespace encryption-validation disable --namespace my-namespace.my-account + ``` + has-init: false + option-sets: + - client + - namespace + - async-operation + - resource-version + # Namespace mtls commands - name: cloud namespace mtls summary: Manage namespace mTLS authentication settings 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,