diff --git a/api/cloud/common/resource_status.go b/api/cloud/common/resource_status.go index f16855b..7a4637f 100644 --- a/api/cloud/common/resource_status.go +++ b/api/cloud/common/resource_status.go @@ -1,6 +1,7 @@ package common import ( + tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -46,6 +47,66 @@ type ResourceStatus struct { // +kubebuilder:validation:Enum=Activating;ActivationFailed;Active;Updating;UpdateFailed;Deleting;DeleteFailed;Deleted;Suspended;Expired type ResourceState string +// FromProto sets the state from a Temporal Cloud resource state. Unspecified +// and unknown states map to "": the CR field is enum-validated, so an +// unrecognized value would fail the status patch. +func (s *ResourceState) FromProto(in tcloudresourcev1.ResourceState) { + switch in { + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING: + *s = ResourceStateActivating + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED: + *s = ResourceStateActivationFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE: + *s = ResourceStateActive + case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING: + *s = ResourceStateUpdating + case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED: + *s = ResourceStateUpdateFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING: + *s = ResourceStateDeleting + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED: + *s = ResourceStateDeleteFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED: + *s = ResourceStateDeleted + case tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED: + *s = ResourceStateSuspended + case tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED: + *s = ResourceStateExpired + default: + *s = "" + } +} + +// ToProto returns the Temporal Cloud resource state for the state. The CR +// field is enum-validated, so unknown states cannot occur; they map to +// unspecified. +func (s ResourceState) ToProto() tcloudresourcev1.ResourceState { + switch s { + case ResourceStateActivating: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING + case ResourceStateActivationFailed: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED + case ResourceStateActive: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE + case ResourceStateUpdating: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING + case ResourceStateUpdateFailed: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED + case ResourceStateDeleting: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING + case ResourceStateDeleteFailed: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED + case ResourceStateDeleted: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED + case ResourceStateSuspended: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED + case ResourceStateExpired: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED + default: + return tcloudresourcev1.ResourceState_RESOURCE_STATE_UNSPECIFIED + } +} + const ( // ResourceStateActivating means the resource is being activated. ResourceStateActivating ResourceState = "Activating" diff --git a/api/cloud/common/resource_status_test.go b/api/cloud/common/resource_status_test.go new file mode 100644 index 0000000..1500086 --- /dev/null +++ b/api/cloud/common/resource_status_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/require" + tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" +) + +func TestResourceStateFromProto(t *testing.T) { + cases := []struct { + in tcloudresourcev1.ResourceState + exp ResourceState + }{ + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UNSPECIFIED, ""}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING, ResourceStateActivating}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED, ResourceStateActivationFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE, ResourceStateActive}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING, ResourceStateUpdating}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED, ResourceStateUpdateFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING, ResourceStateDeleting}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED, ResourceStateDeleteFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED, ResourceStateDeleted}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED, ResourceStateSuspended}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED, ResourceStateExpired}, + // Unknown states must map to "" so the enum-validated status patch + // is not rejected. + {tcloudresourcev1.ResourceState(99), ""}, + } + for _, tc := range cases { + t.Run(tc.in.String(), func(tt *testing.T) { + // Start from a non-empty state to check that FromProto always + // overwrites it, including with "". + got := ResourceStateActive + got.FromProto(tc.in) + require.Equal(tt, tc.exp, got) + }) + } +} diff --git a/api/cloud/v1alpha1/namespace.go b/api/cloud/v1alpha1/namespace.go index 6cabb3f..5161f25 100644 --- a/api/cloud/v1alpha1/namespace.go +++ b/api/cloud/v1alpha1/namespace.go @@ -1,9 +1,14 @@ package v1alpha1 import ( + "maps" + "slices" + + tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" tmprlcorev1 "github.com/temporalio/kube-temporal/api/core/v1" + "github.com/temporalio/kube-temporal/pkg/kube/transform" cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" ) @@ -25,6 +30,40 @@ type Namespace struct { Status NamespaceStatus `json:"status,omitempty"` } +// FromProto sets the spec and status from the supplied Temporal Cloud +// Namespace. Project, tags and the status fields live on the outer Namespace +// message rather than its spec, so they are set here. Object metadata is left +// unchanged. +func (n *Namespace) FromProto(in *tcloudnamespacev1.Namespace) { + n.Spec.FromProto(in.GetSpec()) + n.Spec.Project = in.GetProjectId() + n.Spec.Tags = maps.Clone(in.GetTags()) + + n.Status.ID = in.GetNamespace() + n.Status.ResourceVersion = in.GetResourceVersion() + n.Status.State.FromProto(in.GetState()) + n.Status.AsyncOperationID = in.GetAsyncOperationId() + n.Status.CreatedTime = transform.TimeFromProto(in.GetCreatedTime()) + n.Status.LastModifiedTime = transform.TimeFromProto(in.GetLastModifiedTime()) +} + +// ToProto returns the Temporal Cloud Namespace that represents the spec. It +// sets only the fields we send to Temporal Cloud: the spec, project and tags. +// Requests take these as separate fields, so callers pick out the ones they +// need. Status is never sent, so it is left unset. +// +// The namespace name comes from the CR's metadata.name, because the CR has no +// spec.name of its own. +func (n Namespace) ToProto() *tcloudnamespacev1.Namespace { + spec := n.Spec.toProto() + spec.Name = n.Name + return &tcloudnamespacev1.Namespace{ + Spec: spec, + ProjectId: n.Spec.Project, + Tags: maps.Clone(n.Spec.Tags), + } +} + // NamespaceSpec describes the desired state of a Temporal Cloud Namespace. // // Ref: https://github.com/temporalio/cloud-api/blob/main/temporal/api/cloud/namespace/v1/message.proto @@ -102,6 +141,47 @@ type NamespaceSpec struct { Description string `json:"description,omitempty"` } +// FromProto sets the spec from the supplied Temporal Cloud NamespaceSpec. +// Project and tags are not part of the Temporal Cloud NamespaceSpec, so they +// are left unchanged; [Namespace.FromProto] sets them. +func (s *NamespaceSpec) FromProto(in *tcloudnamespacev1.NamespaceSpec) { + s.Replicas = replicasFromProto(in.GetReplicas()) + s.RetentionDays = in.GetRetentionDays() + s.MTLSAuth = mtlsAuthFromProto(in.GetMtlsAuth()) + s.APIKeyAuth = apiKeyAuthFromProto(in.GetApiKeyAuth()) + s.SearchAttributes = searchAttributesFromProto(in.GetSearchAttributes()) + s.CodecServer = codecServerFromProto(in.GetCodecServer()) + s.Lifecycle = lifecycleFromProto(in.GetLifecycle()) + s.HighAvailability = highAvailabilityFromProto(in.GetHighAvailability()) + s.ConnectivityRuleIDs = slices.Clone(in.GetConnectivityRuleIds()) + s.Capacity = capacityFromProto(in.GetCapacitySpec()) + s.Fairness = fairnessFromProto(in.GetFairness()) + s.EncryptionValidation = encryptionValidationFromProto(in.GetEncryptionValidation()) + s.Description = in.GetDescription() +} + +// toProto returns the Temporal Cloud NamespaceSpec that represents the spec, +// without the name. Project and tags are not part of the Temporal Cloud +// NamespaceSpec and are sent separately. Use [Namespace.ToProto], which also +// sets the name. +func (s NamespaceSpec) toProto() *tcloudnamespacev1.NamespaceSpec { + return &tcloudnamespacev1.NamespaceSpec{ + Replicas: replicasToProto(s.Replicas), + RetentionDays: s.RetentionDays, + MtlsAuth: mtlsAuthToProto(s.MTLSAuth), + ApiKeyAuth: apiKeyAuthToProto(s.APIKeyAuth), + SearchAttributes: searchAttributesToProto(s.SearchAttributes), + CodecServer: codecServerToProto(s.CodecServer), + Lifecycle: lifecycleToProto(s.Lifecycle), + HighAvailability: highAvailabilityToProto(s.HighAvailability), + ConnectivityRuleIds: slices.Clone(s.ConnectivityRuleIDs), + CapacitySpec: capacityToProto(s.Capacity), + Fairness: fairnessToProto(s.Fairness), + EncryptionValidation: encryptionValidationToProto(s.EncryptionValidation), + Description: s.Description, + } +} + // ReplicaSpec places a replica of the Namespace in a region. type ReplicaSpec struct { // Region is the ID of the region to place the replica in. @@ -300,6 +380,8 @@ const ( ) // NamespaceStatus describes the current status of a Temporal Cloud namespace. +// This struct does not contain a FromProto or ToProto function because there is +// no corresponding NamespaceStatus struct in the Temporal Cloud API. // +k8s:openapi-gen=true type NamespaceStatus struct { tmprlcorev1.StatusBase `json:",inline"` @@ -316,3 +398,392 @@ type NamespaceList struct { Items []Namespace `json:"items"` } + +func replicasFromProto( + in []*tcloudnamespacev1.ReplicaSpec, +) []ReplicaSpec { + if len(in) == 0 { + return nil + } + out := make([]ReplicaSpec, 0, len(in)) + for _, r := range in { + out = append(out, ReplicaSpec{Region: r.GetRegion()}) + } + return out +} + +func mtlsAuthFromProto( + in *tcloudnamespacev1.MtlsAuthSpec, +) *MTLSAuthSpec { + if in == nil { + return nil + } + out := &MTLSAuthSpec{ + Enabled: in.GetEnabled(), + // TODO: confirm Temporal Cloud returns raw PEM here, not base64. + AcceptedClientCA: string(in.GetAcceptedClientCa()), + } + for _, f := range in.GetCertificateFilters() { + out.CertificateFilters = append( + out.CertificateFilters, + CertificateFilterSpec{ + CommonName: f.GetCommonName(), + Organization: f.GetOrganization(), + OrganizationalUnit: f.GetOrganizationalUnit(), + SubjectAlternativeName: f.GetSubjectAlternativeName(), + }, + ) + } + return out +} + +func apiKeyAuthFromProto( + in *tcloudnamespacev1.ApiKeyAuthSpec, +) *APIKeyAuthSpec { + if in == nil { + return nil + } + return &APIKeyAuthSpec{Enabled: in.GetEnabled()} +} + +func codecServerFromProto( + in *tcloudnamespacev1.CodecServerSpec, +) *CodecServerSpec { + if in == nil { + return nil + } + out := &CodecServerSpec{ + Endpoint: in.GetEndpoint(), + PassAccessToken: in.GetPassAccessToken(), + IncludeCrossOriginCredentials: in.GetIncludeCrossOriginCredentials(), + } + if cem := in.GetCustomErrorMessage(); cem != nil { + out.CustomErrorMessage = &CodecServerCustomErrorMessage{} + if def := cem.GetDefault(); def != nil { + out.CustomErrorMessage.Default = &CodecServerErrorMessage{ + Message: def.GetMessage(), + Link: def.GetLink(), + } + } + } + return out +} + +func lifecycleFromProto( + in *tcloudnamespacev1.LifecycleSpec, +) *cloudcommon.LifecycleSpec { + if in == nil { + return nil + } + return &cloudcommon.LifecycleSpec{ + EnableDeleteProtection: in.GetEnableDeleteProtection(), + } +} + +func highAvailabilityFromProto( + in *tcloudnamespacev1.HighAvailabilitySpec, +) *HighAvailabilitySpec { + if in == nil { + return nil + } + return &HighAvailabilitySpec{ + DisableManagedFailover: in.GetDisableManagedFailover(), + DisablePassivePollerForwarding: in.GetDisablePassivePollerForwarding(), + } +} + +func capacityFromProto( + in *tcloudnamespacev1.CapacitySpec, +) *CapacitySpec { + switch { + case in.GetOnDemand() != nil: + return &CapacitySpec{ + OnDemand: &OnDemandCapacity{}, + } + case in.GetProvisioned() != nil: + return &CapacitySpec{ + Provisioned: &ProvisionedCapacity{ + // The proto carries TRUs as a float64; the CR uses whole TRUs. + Value: int32(in.GetProvisioned().GetValue()), + }, + } + default: + return nil + } +} + +func fairnessFromProto( + in *tcloudnamespacev1.FairnessSpec, +) *FairnessSpec { + if in == nil { + return nil + } + return &FairnessSpec{ + TaskQueueFairnessEnabled: in.GetTaskQueueFairnessEnabled(), + } +} + +func encryptionValidationFromProto( + in *tcloudnamespacev1.EncryptionValidationSpec, +) *EncryptionValidationSpec { + if in == nil { + return nil + } + return &EncryptionValidationSpec{ + Mode: encryptionValidationModeFromProto(in.GetMode()), + MetadataKey: in.GetMetadataKey(), + MetadataValues: slices.Clone(in.GetMetadataValues()), + InspectHeader: in.GetInspectHeader(), + InspectFailure: in.GetInspectFailure(), + } +} + +// searchAttributesFromProto converts search attribute types to their CR +// names. Unspecified types map to "". Types this operator does not know about +// keep their proto enum name, so they surface as a spec difference instead of +// being silently dropped. +func searchAttributesFromProto( + in map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType, +) map[string]SearchAttributeType { + if len(in) == 0 { + return nil + } + out := make(map[string]SearchAttributeType, len(in)) + for name, t := range in { + switch t { + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED: + out[name] = "" + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT: + out[name] = SearchAttributeTypeText + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD: + out[name] = SearchAttributeTypeKeyword + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT: + out[name] = SearchAttributeTypeInt + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE: + out[name] = SearchAttributeTypeDouble + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL: + out[name] = SearchAttributeTypeBool + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME: + out[name] = SearchAttributeTypeDatetime + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST: + out[name] = SearchAttributeTypeKeywordList + default: + out[name] = SearchAttributeType(t.String()) + } + } + return out +} + +// encryptionValidationModeFromProto converts an encryption validation mode to +// its CR name. Unknown modes keep their proto enum name. +func encryptionValidationModeFromProto( + in tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode, +) EncryptionValidationMode { + switch in { + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED: + return "" + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED: + return EncryptionValidationModeDisabled + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN: + return EncryptionValidationModeWarn + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY: + return EncryptionValidationModeDeny + default: + return EncryptionValidationMode(in.String()) + } +} + +func replicasToProto( + in []ReplicaSpec, +) []*tcloudnamespacev1.ReplicaSpec { + if len(in) == 0 { + return nil + } + out := make([]*tcloudnamespacev1.ReplicaSpec, 0, len(in)) + for _, r := range in { + out = append(out, &tcloudnamespacev1.ReplicaSpec{Region: r.Region}) + } + return out +} + +func mtlsAuthToProto( + in *MTLSAuthSpec, +) *tcloudnamespacev1.MtlsAuthSpec { + if in == nil { + return nil + } + out := &tcloudnamespacev1.MtlsAuthSpec{ + Enabled: in.Enabled, + // TODO: confirm Temporal Cloud expects raw PEM here, not base64. + AcceptedClientCa: []byte(in.AcceptedClientCA), + } + for _, f := range in.CertificateFilters { + out.CertificateFilters = append( + out.CertificateFilters, + &tcloudnamespacev1.CertificateFilterSpec{ + CommonName: f.CommonName, + Organization: f.Organization, + OrganizationalUnit: f.OrganizationalUnit, + SubjectAlternativeName: f.SubjectAlternativeName, + }, + ) + } + return out +} + +func apiKeyAuthToProto( + in *APIKeyAuthSpec, +) *tcloudnamespacev1.ApiKeyAuthSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.ApiKeyAuthSpec{Enabled: in.Enabled} +} + +func codecServerToProto( + in *CodecServerSpec, +) *tcloudnamespacev1.CodecServerSpec { + if in == nil { + return nil + } + out := &tcloudnamespacev1.CodecServerSpec{ + Endpoint: in.Endpoint, + PassAccessToken: in.PassAccessToken, + IncludeCrossOriginCredentials: in.IncludeCrossOriginCredentials, + } + if cem := in.CustomErrorMessage; cem != nil { + out.CustomErrorMessage = &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage{} + if def := cem.Default; def != nil { + out.CustomErrorMessage.Default = &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage_ErrorMessage{ + Message: def.Message, + Link: def.Link, + } + } + } + return out +} + +func lifecycleToProto( + in *cloudcommon.LifecycleSpec, +) *tcloudnamespacev1.LifecycleSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.LifecycleSpec{ + EnableDeleteProtection: in.EnableDeleteProtection, + } +} + +func highAvailabilityToProto( + in *HighAvailabilitySpec, +) *tcloudnamespacev1.HighAvailabilitySpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.HighAvailabilitySpec{ + DisableManagedFailover: in.DisableManagedFailover, + DisablePassivePollerForwarding: in.DisablePassivePollerForwarding, + } +} + +func capacityToProto( + in *CapacitySpec, +) *tcloudnamespacev1.CapacitySpec { + switch { + case in == nil: + return nil + case in.OnDemand != nil: + return &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_OnDemand_{ + OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, + }, + } + case in.Provisioned != nil: + return &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ + Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{ + Value: float64(in.Provisioned.Value), + }, + }, + } + default: + return nil + } +} + +func fairnessToProto( + in *FairnessSpec, +) *tcloudnamespacev1.FairnessSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.FairnessSpec{ + TaskQueueFairnessEnabled: in.TaskQueueFairnessEnabled, + } +} + +func encryptionValidationToProto( + in *EncryptionValidationSpec, +) *tcloudnamespacev1.EncryptionValidationSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.EncryptionValidationSpec{ + Mode: encryptionValidationModeToProto(in.Mode), + MetadataKey: in.MetadataKey, + MetadataValues: slices.Clone(in.MetadataValues), + InspectHeader: in.InspectHeader, + InspectFailure: in.InspectFailure, + } +} + +// searchAttributesToProto converts search attribute types to their proto +// values. The CR field is enum-validated, so unknown types cannot occur; they +// map to unspecified. +func searchAttributesToProto( + in map[string]SearchAttributeType, +) map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType { + if len(in) == 0 { + return nil + } + out := make(map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType, len(in)) + for name, t := range in { + switch t { + case SearchAttributeTypeText: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT + case SearchAttributeTypeKeyword: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD + case SearchAttributeTypeInt: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT + case SearchAttributeTypeDouble: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE + case SearchAttributeTypeBool: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL + case SearchAttributeTypeDatetime: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME + case SearchAttributeTypeKeywordList: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST + default: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED + } + } + return out +} + +// encryptionValidationModeToProto converts an encryption validation mode to +// its proto value. The CR field is enum-validated, so unknown modes cannot +// occur; they map to unspecified. +func encryptionValidationModeToProto( + in EncryptionValidationMode, +) tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode { + switch in { + case EncryptionValidationModeDisabled: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED + case EncryptionValidationModeWarn: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN + case EncryptionValidationModeDeny: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY + default: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED + } +} diff --git a/api/cloud/v1alpha1/namespace_test.go b/api/cloud/v1alpha1/namespace_test.go new file mode 100644 index 0000000..a0453ed --- /dev/null +++ b/api/cloud/v1alpha1/namespace_test.go @@ -0,0 +1,544 @@ +package v1alpha1 + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" +) + +// fullSpec and fullProtoNamespace describe the same namespace with every +// field set. Pairs of booleans are given different values so that a swapped +// field mapping is caught even if both directions swap it. +func fullSpec() NamespaceSpec { + return NamespaceSpec{ + Project: "proj-1", + Tags: map[string]string{"team": "core", "env": "prod"}, + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}, {Region: "aws-us-west-2"}}, + RetentionDays: 30, + MTLSAuth: &MTLSAuthSpec{ + Enabled: true, + AcceptedClientCA: "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n", + CertificateFilters: []CertificateFilterSpec{ + {CommonName: "cn", Organization: "org", OrganizationalUnit: "ou", SubjectAlternativeName: "san"}, + {CommonName: "cn2"}, + }, + }, + APIKeyAuth: &APIKeyAuthSpec{Enabled: true}, + SearchAttributes: map[string]SearchAttributeType{ + "CustomText": SearchAttributeTypeText, + "CustomKeyword": SearchAttributeTypeKeyword, + }, + CodecServer: &CodecServerSpec{ + Endpoint: "https://codec.example.com", + PassAccessToken: true, + IncludeCrossOriginCredentials: false, + CustomErrorMessage: &CodecServerCustomErrorMessage{ + Default: &CodecServerErrorMessage{ + Message: "codec unavailable", + Link: "https://docs.example.com", + }, + }, + }, + Lifecycle: &cloudcommon.LifecycleSpec{EnableDeleteProtection: true}, + HighAvailability: &HighAvailabilitySpec{ + DisableManagedFailover: true, + DisablePassivePollerForwarding: false, + }, + ConnectivityRuleIDs: []string{"rule-1", "rule-2"}, + Capacity: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 4}, + }, + Fairness: &FairnessSpec{TaskQueueFairnessEnabled: true}, + EncryptionValidation: &EncryptionValidationSpec{ + Mode: EncryptionValidationModeWarn, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + InspectFailure: false, + }, + Description: "the full namespace", + } +} + +func fullProtoNamespace() *tcloudnamespacev1.Namespace { + return &tcloudnamespacev1.Namespace{ + Namespace: "my-ns.acct1", + ProjectId: "proj-1", + Tags: map[string]string{"team": "core", "env": "prod"}, + Spec: &tcloudnamespacev1.NamespaceSpec{ + Name: "my-ns", + Replicas: []*tcloudnamespacev1.ReplicaSpec{ + {Region: "aws-us-east-1"}, + {Region: "aws-us-west-2"}, + }, + RetentionDays: 30, + MtlsAuth: &tcloudnamespacev1.MtlsAuthSpec{ + Enabled: true, + AcceptedClientCa: []byte("-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n"), + CertificateFilters: []*tcloudnamespacev1.CertificateFilterSpec{ + {CommonName: "cn", Organization: "org", OrganizationalUnit: "ou", SubjectAlternativeName: "san"}, + {CommonName: "cn2"}, + }, + }, + ApiKeyAuth: &tcloudnamespacev1.ApiKeyAuthSpec{Enabled: true}, + SearchAttributes: map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{ + "CustomText": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT, + "CustomKeyword": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, + }, + CodecServer: &tcloudnamespacev1.CodecServerSpec{ + Endpoint: "https://codec.example.com", + PassAccessToken: true, + IncludeCrossOriginCredentials: false, + CustomErrorMessage: &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage{ + Default: &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage_ErrorMessage{ + Message: "codec unavailable", + Link: "https://docs.example.com", + }, + }, + }, + Lifecycle: &tcloudnamespacev1.LifecycleSpec{EnableDeleteProtection: true}, + HighAvailability: &tcloudnamespacev1.HighAvailabilitySpec{ + DisableManagedFailover: true, + DisablePassivePollerForwarding: false, + }, + ConnectivityRuleIds: []string{"rule-1", "rule-2"}, + CapacitySpec: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ + Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{Value: 4}, + }, + }, + Fairness: &tcloudnamespacev1.FairnessSpec{TaskQueueFairnessEnabled: true}, + EncryptionValidation: &tcloudnamespacev1.EncryptionValidationSpec{ + Mode: tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, + MetadataKey: "encoding", + MetadataValues: []string{"binary/encrypted"}, + InspectHeader: true, + InspectFailure: false, + }, + Description: "the full namespace", + }, + } +} + +func namespaceWithSpec(spec NamespaceSpec) *Namespace { + ns := &Namespace{Spec: spec} + ns.Name = "my-ns" + return ns +} + +func TestNamespaceFromProto_Spec(t *testing.T) { + minimal := NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + } + cases := []struct { + name string + in *tcloudnamespacev1.Namespace + exp NamespaceSpec + }{ + { + name: "nil namespace", + in: nil, + exp: NamespaceSpec{}, + }, + { + name: "nil spec keeps outer fields", + in: &tcloudnamespacev1.Namespace{ + ProjectId: "proj-1", + Tags: map[string]string{"a": "1"}, + }, + exp: NamespaceSpec{ + Project: "proj-1", + Tags: map[string]string{"a": "1"}, + }, + }, + { + name: "unset optional blocks stay nil", + in: &tcloudnamespacev1.Namespace{ + Spec: &tcloudnamespacev1.NamespaceSpec{ + Name: "my-ns", + Replicas: []*tcloudnamespacev1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }, + }, + exp: minimal, + }, + { + name: "every field", + in: fullProtoNamespace(), + exp: fullSpec(), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + var got Namespace + got.FromProto(tc.in) + require.Equal(tt, tc.exp, got.Spec) + }) + } +} + +func TestNamespaceFromProto_Status(t *testing.T) { + created := time.Date(2026, 1, 2, 3, 4, 5, 6, time.UTC) + modified := time.Date(2026, 2, 3, 4, 5, 6, 7, time.UTC) + + cases := []struct { + name string + in *tcloudnamespacev1.Namespace + exp cloudcommon.ResourceStatus + }{ + { + name: "nil namespace", + }, + { + name: "every field", + in: &tcloudnamespacev1.Namespace{ + Namespace: "my-ns.acct1", + ResourceVersion: "rv-1", + State: tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE, + AsyncOperationId: "op-1", + CreatedTime: timestamppb.New(created), + LastModifiedTime: timestamppb.New(modified), + }, + exp: cloudcommon.ResourceStatus{ + ID: "my-ns.acct1", + ResourceVersion: "rv-1", + State: cloudcommon.ResourceStateActive, + AsyncOperationID: "op-1", + CreatedTime: &metav1.Time{Time: created}, + LastModifiedTime: &metav1.Time{Time: modified}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + require := require.New(tt) + + got := namespaceWithSpec(NamespaceSpec{}) + got.FromProto(tc.in) + + require.Equal(tc.exp, got.Status.ResourceStatus) + // The qualified ID goes to status, never to metadata.name. + require.Equal("my-ns", got.Name) + }) + } +} + +func TestNamespaceFromProto_CopiesCollections(t *testing.T) { + require := require.New(t) + + in := fullProtoNamespace() + var got Namespace + got.FromProto(in) + + in.Tags["team"] = "changed" + in.Spec.ConnectivityRuleIds[0] = "changed" + in.Spec.EncryptionValidation.MetadataValues[0] = "changed" + + require.Equal("core", got.Spec.Tags["team"]) + require.Equal("rule-1", got.Spec.ConnectivityRuleIDs[0]) + require.Equal("binary/encrypted", got.Spec.EncryptionValidation.MetadataValues[0]) +} + +func TestNamespaceToProto(t *testing.T) { + // ToProto sets only the fields that are sent to Temporal Cloud, so the + // qualified ID and status fields of the full proto are left unset. + full := fullProtoNamespace() + full.Namespace = "" + + cases := []struct { + name string + in *Namespace + exp *tcloudnamespacev1.Namespace + }{ + { + name: "unset optional blocks stay nil", + in: namespaceWithSpec(NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }), + exp: &tcloudnamespacev1.Namespace{ + Spec: &tcloudnamespacev1.NamespaceSpec{ + Name: "my-ns", + Replicas: []*tcloudnamespacev1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }, + }, + }, + { + name: "every field", + in: namespaceWithSpec(fullSpec()), + exp: full, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + got := tc.in.ToProto() + require.True(tt, proto.Equal(tc.exp, got), "expected %v, got %v", tc.exp, got) + }) + } +} + +func TestNamespace_RoundTrip(t *testing.T) { + cases := []struct { + name string + spec NamespaceSpec + }{ + { + name: "minimal", + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }, + }, + { + name: "every field", + spec: fullSpec(), + }, + { + name: "on-demand capacity", + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + Capacity: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, + }, + }, + { + name: "explicitly empty blocks stay non-nil", + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + MTLSAuth: &MTLSAuthSpec{}, + APIKeyAuth: &APIKeyAuthSpec{}, + CodecServer: &CodecServerSpec{CustomErrorMessage: &CodecServerCustomErrorMessage{}}, + Lifecycle: &cloudcommon.LifecycleSpec{}, + HighAvailability: &HighAvailabilitySpec{}, + Fairness: &FairnessSpec{}, + EncryptionValidation: &EncryptionValidationSpec{Mode: EncryptionValidationModeDisabled}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + var got Namespace + got.FromProto(namespaceWithSpec(tc.spec).ToProto()) + require.Equal(tt, tc.spec, got.Spec) + }) + } +} + +func TestCapacityFromProto(t *testing.T) { + cases := []struct { + name string + in *tcloudnamespacev1.CapacitySpec + exp *CapacitySpec + }{ + { + name: "nil", + }, + { + name: "no mode set", + in: &tcloudnamespacev1.CapacitySpec{}, + }, + { + name: "on demand", + in: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_OnDemand_{ + OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, + }, + }, + exp: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, + }, + { + name: "provisioned truncates fractional TRUs", + in: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ + Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{Value: 2.9}, + }, + }, + exp: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 2}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + require.Equal(tt, tc.exp, capacityFromProto(tc.in)) + }) + } +} + +func TestCapacityToProto(t *testing.T) { + cases := []struct { + name string + in *CapacitySpec + exp *tcloudnamespacev1.CapacitySpec + }{ + { + name: "nil", + }, + { + name: "no mode set", + in: &CapacitySpec{}, + }, + { + name: "on demand", + in: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, + exp: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_OnDemand_{ + OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, + }, + }, + }, + { + name: "provisioned", + in: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 8}, + }, + exp: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ + Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{Value: 8}, + }, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + got := capacityToProto(tc.in) + require.True(tt, proto.Equal(tc.exp, got), "expected %v, got %v", tc.exp, got) + }) + } +} + +func TestCodecServerFromProto_CustomErrorMessageWithoutDefault(t *testing.T) { + got := codecServerFromProto(&tcloudnamespacev1.CodecServerSpec{ + Endpoint: "https://codec.example.com", + CustomErrorMessage: &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage{}, + }) + require.Equal(t, &CodecServerSpec{ + Endpoint: "https://codec.example.com", + CustomErrorMessage: &CodecServerCustomErrorMessage{}, + }, got) +} + +func TestSubMessageConverters_Nil(t *testing.T) { + require := require.New(t) + + require.Nil(replicasFromProto(nil)) + require.Nil(replicasFromProto([]*tcloudnamespacev1.ReplicaSpec{})) + require.Nil(mtlsAuthFromProto(nil)) + require.Nil(apiKeyAuthFromProto(nil)) + require.Nil(codecServerFromProto(nil)) + require.Nil(lifecycleFromProto(nil)) + require.Nil(highAvailabilityFromProto(nil)) + require.Nil(fairnessFromProto(nil)) + require.Nil(encryptionValidationFromProto(nil)) + require.Nil(searchAttributesFromProto(nil)) + + require.Nil(replicasToProto(nil)) + require.Nil(replicasToProto([]ReplicaSpec{})) + require.Nil(mtlsAuthToProto(nil)) + require.Nil(apiKeyAuthToProto(nil)) + require.Nil(codecServerToProto(nil)) + require.Nil(lifecycleToProto(nil)) + require.Nil(highAvailabilityToProto(nil)) + require.Nil(fairnessToProto(nil)) + require.Nil(encryptionValidationToProto(nil)) + require.Nil(searchAttributesToProto(nil)) +} + +func TestSearchAttributeTypeConversion(t *testing.T) { + pairs := []struct { + proto tcloudnamespacev1.NamespaceSpec_SearchAttributeType + cr SearchAttributeType + }{ + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT, SearchAttributeTypeText}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, SearchAttributeTypeKeyword}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT, SearchAttributeTypeInt}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE, SearchAttributeTypeDouble}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL, SearchAttributeTypeBool}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME, SearchAttributeTypeDatetime}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST, SearchAttributeTypeKeywordList}, + } + for _, p := range pairs { + t.Run(string(p.cr), func(tt *testing.T) { + require := require.New(tt) + require.Equal( + map[string]SearchAttributeType{"a": p.cr}, + searchAttributesFromProto(map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{"a": p.proto}), + ) + require.Equal( + map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{"a": p.proto}, + searchAttributesToProto(map[string]SearchAttributeType{"a": p.cr}), + ) + }) + } + + t.Run("unspecified and unknown from proto", func(tt *testing.T) { + got := searchAttributesFromProto(map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{ + "unspecified": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED, + "unknown": tcloudnamespacev1.NamespaceSpec_SearchAttributeType(99), + }) + require.Equal(tt, map[string]SearchAttributeType{ + "unspecified": "", + "unknown": "99", + }, got) + }) + + t.Run("unknown to proto", func(tt *testing.T) { + got := searchAttributesToProto(map[string]SearchAttributeType{"a": "Bogus"}) + require.Equal(tt, map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{ + "a": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED, + }, got) + }) +} + +func TestEncryptionValidationModeConversion(t *testing.T) { + pairs := []struct { + proto tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode + cr EncryptionValidationMode + }{ + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, EncryptionValidationModeDisabled}, + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, EncryptionValidationModeWarn}, + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, EncryptionValidationModeDeny}, + } + for _, p := range pairs { + t.Run(string(p.cr), func(tt *testing.T) { + require := require.New(tt) + require.Equal(p.cr, encryptionValidationModeFromProto(p.proto)) + require.Equal(p.proto, encryptionValidationModeToProto(p.cr)) + }) + } + + t.Run("unspecified and unknown from proto", func(tt *testing.T) { + require := require.New(tt) + require.Equal( + EncryptionValidationMode(""), + encryptionValidationModeFromProto(tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED), + ) + require.Equal( + EncryptionValidationMode("99"), + encryptionValidationModeFromProto(tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode(99)), + ) + }) + + t.Run("empty and unknown to proto", func(tt *testing.T) { + require := require.New(tt) + require.Equal( + tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED, + encryptionValidationModeToProto(""), + ) + require.Equal( + tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED, + encryptionValidationModeToProto("Bogus"), + ) + }) +} diff --git a/controller/namespace/reconcile.go b/controller/namespace/reconcile.go index 49b3fe6..f6cdc27 100644 --- a/controller/namespace/reconcile.go +++ b/controller/namespace/reconcile.go @@ -3,14 +3,22 @@ package namespace import ( "context" "fmt" + "slices" + ctrlerrors "github.com/temporalio/kube-temporal/pkg/controller/errors" ctrlreqlog "github.com/temporalio/kube-temporal/pkg/controller/request/log" + "github.com/temporalio/kube-temporal/pkg/controller/requeue" "github.com/temporalio/kube-temporal/pkg/types" tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" + tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" tcclient "go.temporal.io/cloud-sdk/cloudclient" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/api/equality" ctrlrt "sigs.k8s.io/controller-runtime" kclient "sigs.k8s.io/controller-runtime/pkg/client" + cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" "github.com/temporalio/cloud-operator/pkg/reconciler/base" ) @@ -101,26 +109,63 @@ func (r *Reconciler) ResourceSetLatest( if err != nil { return fmt.Errorf("failed getting tcloud client: %w", err) } - _ = tcc // "pop" the underlying *tcloudv1alpha1.Namespace from the [types.Resource] // wrapper. This is a pointer so by modifying it, you mutate the supplied // `latest` parameter. cr := latest.(*res).cr - _ = cr - // TODO: call the Temporal Cloud API to fetch Namespace spec and status and - // mutate the supplied resource to set the spec and status fields - // accordingly. - req := tcloudservicev1.GetNamespaceRequest{ - Namespace: cr.Name, + var tns *tcloudnamespacev1.Namespace + if cr.Status.ID == "" { + // The qualified namespace name is only known once a reconcile after + // the create has saved it to status, and status can be lost (e.g. the + // CR was recreated). Look the namespace up by name so that a duplicate + // is never created. + tns, err = findNamespaceByName(ctx, tcc, cr.Name) + if err != nil { + return fmt.Errorf("failed listing tcloud namespaces: %w", err) + } + } else { + var resp *tcloudservicev1.GetNamespaceResponse + resp, err = tcc.GetNamespace(ctx, &tcloudservicev1.GetNamespaceRequest{ + Namespace: cr.Status.ID, + }) + if status.Code(err) == codes.NotFound { + // The namespace was deleted outside of the operator. Treat it + // the same as one that was never created so ResourceSync + // recreates it. + err = nil + } else if err != nil { + return fmt.Errorf("failed getting tcloud namespace: %w", err) + } + tns = resp.GetNamespace() } - resp, err := tcc.GetNamespace(ctx, &req) - if err != nil { - return fmt.Errorf("failed getting tcloud namespace: %w", err) + + if tns == nil { + // The namespace does not exist in Temporal Cloud, so mutate latest + // spec and status to be empty. + cr.Spec = tcloudv1alpha1.NamespaceSpec{} + cr.Status.ResourceStatus = cloudcommon.ResourceStatus{} + return nil } - ns := resp.GetNamespace() - cr.Spec.Project = ns.GetProjectId() + + cr.FromProto(tns) + + // cr := latest.(*res).cr + // _ = cr + + // // TODO: call the Temporal Cloud API to fetch Namespace spec and status and + // // mutate the supplied resource to set the spec and status fields + // // accordingly. + // req := tcloudservicev1.GetNamespaceRequest{ + // Namespace: cr.Name, + // } + // resp, err := tcc.GetNamespace(ctx, &req) + // if err != nil { + // return fmt.Errorf("failed getting tcloud namespace: %w", err) + // } + // ns := resp.GetNamespace() + // cr.Spec.Project = ns.GetProjectId() return err } @@ -152,23 +197,218 @@ func (r *Reconciler) ResourceSync( if err != nil { return fmt.Errorf("failed getting tcloud client: %w", err) } - _ = tcc - - // TODO: call the Temporal Cloud API to update Namespace desired - // configuration to match the desired resource. + err = syncNamespace(ctx, tcc, desired.(*res).cr, latest.(*res).cr) return err } +// syncNamespace makes at most one Temporal Cloud call to move the namespace +// toward the desired state. Create, update and tag changes each start an +// async operation, and a namespace with an operation in flight rejects new +// ones, so after each call we requeue and let the next reconcile observe the +// result and take the next step. +func syncNamespace( + ctx context.Context, + tcc tcloudservicev1.CloudServiceClient, + desired *tcloudv1alpha1.Namespace, + latest *tcloudv1alpha1.Namespace, +) error { + if latest.Status.ID == "" { + tns := desired.ToProto() + resp, err := tcc.CreateNamespace(ctx, &tcloudservicev1.CreateNamespaceRequest{ + Spec: tns.GetSpec(), + Tags: tns.GetTags(), + ProjectId: tns.GetProjectId(), + }) + if err != nil { + return cloudCallError("creating tcloud namespace", err) + } + return requeue.NeededAfter( + fmt.Errorf("created tcloud namespace %q, waiting for activation", resp.GetNamespace()), + requeue.DefaultRequeueAfterDuration, + ) + } + + // Only an Active namespace accepts new operations. Every other state + // either resolves on its own, so we wait, or needs someone to step in, so + // we stop. Unknown states also wait rather than fall through to an update. + id := latest.Status.ID + switch state := latest.Status.State; state { + case cloudcommon.ResourceStateActive: + case cloudcommon.ResourceStateActivating, cloudcommon.ResourceStateUpdating: + return requeue.NeededAfter( + fmt.Errorf("waiting for tcloud namespace %q to leave state %s", id, state), + requeue.DefaultRequeueAfterDuration, + ) + case cloudcommon.ResourceStateDeleting, cloudcommon.ResourceStateDeleted: + // The namespace was deleted outside of the operator. Once Temporal + // Cloud reports it as not found, ResourceSetLatest clears the status + // and the next sync recreates it. + return requeue.NeededAfter( + fmt.Errorf("waiting for tcloud namespace %q to finish deleting, state %s", id, state), + requeue.DefaultRequeueAfterDuration, + ) + case cloudcommon.ResourceStateActivationFailed, + cloudcommon.ResourceStateUpdateFailed, + cloudcommon.ResourceStateDeleteFailed, + cloudcommon.ResourceStateSuspended, + cloudcommon.ResourceStateExpired: + return ctrlerrors.NewTerminalError(fmt.Errorf( + "%w: tcloud namespace %q is in state %s, contact Temporal Cloud support", + ctrlerrors.ErrResourceSync, id, state, + )) + default: + return requeue.NeededAfter( + fmt.Errorf("waiting for tcloud namespace %q to leave unknown state %q", id, state), + requeue.DefaultRequeueAfterDuration, + ) + } + + // spec.project is immutable on the CR, so a mismatch means the namespace + // was moved outside of the operator. An empty spec.project means the + // account's default project, whose ID Temporal Cloud reports back. + if desired.Spec.Project != "" && desired.Spec.Project != latest.Spec.Project { + return ctrlerrors.NewTerminalError(fmt.Errorf( + "%w: tcloud namespace %q is in project %q but spec.project is %q", + ctrlerrors.ErrResourceSync, id, latest.Spec.Project, desired.Spec.Project, + )) + } + + // The controller's DeepEqual always reports a difference + // once Status.Conditions is populated, so compare the fields each API + // owns here to avoid sending no-op updates on every reconcile. Project + // and tags are not part of UpdateNamespace. + if !equality.Semantic.DeepEqual( + specWithoutProjectAndTags(&desired.Spec), + specWithoutProjectAndTags(&latest.Spec), + ) { + _, err := tcc.UpdateNamespace(ctx, &tcloudservicev1.UpdateNamespaceRequest{ + Namespace: id, + Spec: desired.ToProto().GetSpec(), + ResourceVersion: latest.Status.ResourceVersion, + }) + if err != nil { + return cloudCallError("updating tcloud namespace", err) + } + return requeue.NeededAfter( + fmt.Errorf("updated tcloud namespace %q, waiting for update to complete", id), + requeue.DefaultRequeueAfterDuration, + ) + } + + upsert, remove := tagsDiff(desired.Spec.Tags, latest.Spec.Tags) + if len(upsert) > 0 || len(remove) > 0 { + _, err := tcc.UpdateNamespaceTags(ctx, &tcloudservicev1.UpdateNamespaceTagsRequest{ + Namespace: id, + TagsToUpsert: upsert, + TagsToRemove: remove, + }) + if err != nil { + return cloudCallError("updating tcloud namespace tags", err) + } + return requeue.NeededAfter( + fmt.Errorf("updated tcloud namespace %q tags, waiting for update to complete", id), + requeue.DefaultRequeueAfterDuration, + ) + } + + return nil +} + +// specWithoutProjectAndTags returns a copy of the supplied spec with the +// fields that UpdateNamespace does not manage cleared. +func specWithoutProjectAndTags( + spec *tcloudv1alpha1.NamespaceSpec, +) *tcloudv1alpha1.NamespaceSpec { + out := spec.DeepCopy() + out.Project = "" + out.Tags = nil + return out +} + +// cloudCallError wraps an error returned from a Temporal Cloud call in +// [ctrlerrors.ErrResourceSync] and classifies it for the controller. +func cloudCallError(action string, err error) error { + wrapped := fmt.Errorf("%w: failed %s: %w", ctrlerrors.ErrResourceSync, action, err) + switch status.Code(err) { + case codes.InvalidArgument: + // The request itself is bad; retrying will not help until the spec + // changes. + return ctrlerrors.NewTerminalError(wrapped) + case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted: + // Our view of the namespace is stale (e.g. an outdated resource + // version, or a create that ResourceSetLatest has not observed yet). + // The next reconcile re-reads it. + return requeue.NeededAfter(wrapped, requeue.DefaultRequeueAfterDuration) + } + return wrapped +} + +// findNamespaceByName returns the Temporal Cloud Namespace with the supplied +// (unqualified) name, or nil if there is none. Namespace names are unique +// within an account. +func findNamespaceByName( + ctx context.Context, + tcc tcloudservicev1.CloudServiceClient, + name string, +) (*tcloudnamespacev1.Namespace, error) { + req := &tcloudservicev1.GetNamespacesRequest{Name: name} + for { + resp, err := tcc.GetNamespaces(ctx, req) + if err != nil { + return nil, err + } + // Compare names ourselves rather than relying on the filter being an + // exact match. + for _, tns := range resp.GetNamespaces() { + if tns.GetSpec().GetName() == name { + return tns, nil + } + } + if resp.GetNextPageToken() == "" { + return nil, nil + } + req.PageToken = resp.GetNextPageToken() + } +} + +// tagsDiff returns the tags that must be upserted and the tag keys that must +// be removed to turn the latest tags into the desired tags. +func tagsDiff( + desired map[string]string, + latest map[string]string, +) (map[string]string, []string) { + var upsert map[string]string + for k, v := range desired { + if lv, ok := latest[k]; !ok || lv != v { + if upsert == nil { + upsert = map[string]string{} + } + upsert[k] = v + } + } + var remove []string + for k := range latest { + if _, ok := desired[k]; !ok { + remove = append(remove, k) + } + } + slices.Sort(remove) + return upsert, remove +} + // ResourceDelete deletes any managed external resources for the supplied // [types.Resource]. Note that the Reconciler does NOT need to remove the // finalizer on the associated [client.Object]. The Controller handles that. // All the Reconciler needs to do is delete any owned resources or // externally-managed resources associated with the supplied [types.Resource]. +// +// It returns a copy of the supplied [types.Resource] with any status +// mutations applied to the copy. `original` is not mutated. func (r *Reconciler) ResourceDelete( ctx context.Context, - desired types.Resource, -) error { + original types.Resource, +) (types.Resource, error) { var err error log := ctrlreqlog.FromContext(ctx) exit := log.Trace("resource.delete") @@ -176,15 +416,17 @@ func (r *Reconciler) ResourceDelete( exit(err) }() - tcc, err := r.CloudClientFor(ctx, desired) + latest := original.DeepCopy() + + tcc, err := r.CloudClientFor(ctx, original) if err != nil { - return fmt.Errorf("failed getting tcloud client: %w", err) + return latest, fmt.Errorf("failed getting tcloud client: %w", err) } _ = tcc // TODO: call the Temporal Cloud API to delete the Namespace. - return err + return latest, err } // compile-time check that Reconciler satisfies the [types.Reconciler] diff --git a/controller/namespace/reconcile_internal_test.go b/controller/namespace/reconcile_internal_test.go new file mode 100644 index 0000000..3a21ab8 --- /dev/null +++ b/controller/namespace/reconcile_internal_test.go @@ -0,0 +1,202 @@ +package namespace + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" + tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// fakeCloudService implements GetNamespaces by returning the supplied pages +// in order. Calling any other method panics via the nil embedded interface. +type fakeCloudService struct { + tcloudservicev1.CloudServiceClient + + pages []*tcloudservicev1.GetNamespacesResponse + err error + // reqs records a copy of every request, since findNamespaceByName + // reuses and mutates one request across pages. + reqs []*tcloudservicev1.GetNamespacesRequest +} + +func (f *fakeCloudService) GetNamespaces( + _ context.Context, + req *tcloudservicev1.GetNamespacesRequest, + _ ...grpc.CallOption, +) (*tcloudservicev1.GetNamespacesResponse, error) { + f.reqs = append(f.reqs, proto.Clone(req).(*tcloudservicev1.GetNamespacesRequest)) + if f.err != nil { + return nil, f.err + } + return f.pages[len(f.reqs)-1], nil +} + +func namespaceNamed(name string) *tcloudnamespacev1.Namespace { + return &tcloudnamespacev1.Namespace{ + Namespace: name + ".acct1", + Spec: &tcloudnamespacev1.NamespaceSpec{Name: name}, + } +} + +func TestFindNamespaceByName(t *testing.T) { + cases := []struct { + name string + fake *fakeCloudService + expID string + expErr string + expPageTokens []string + }{ + { + name: "found on first page", + fake: &fakeCloudService{ + pages: []*tcloudservicev1.GetNamespacesResponse{ + {Namespaces: []*tcloudnamespacev1.Namespace{namespaceNamed("my-ns")}}, + }, + }, + expID: "my-ns.acct1", + expPageTokens: []string{""}, + }, + { + name: "found on a later page", + fake: &fakeCloudService{ + pages: []*tcloudservicev1.GetNamespacesResponse{ + {NextPageToken: "p2"}, + { + Namespaces: []*tcloudnamespacev1.Namespace{namespaceNamed("my-ns")}, + NextPageToken: "p3", + }, + }, + }, + expID: "my-ns.acct1", + expPageTokens: []string{"", "p2"}, + }, + { + name: "ignores names that are not an exact match", + fake: &fakeCloudService{ + pages: []*tcloudservicev1.GetNamespacesResponse{ + {Namespaces: []*tcloudnamespacev1.Namespace{ + namespaceNamed("my-ns-2"), + namespaceNamed("my-ns"), + }}, + }, + }, + expID: "my-ns.acct1", + expPageTokens: []string{""}, + }, + { + name: "nil when no page matches", + fake: &fakeCloudService{ + pages: []*tcloudservicev1.GetNamespacesResponse{ + { + Namespaces: []*tcloudnamespacev1.Namespace{namespaceNamed("other")}, + NextPageToken: "p2", + }, + {Namespaces: []*tcloudnamespacev1.Namespace{namespaceNamed("my-ns-2")}}, + }, + }, + expPageTokens: []string{"", "p2"}, + }, + { + name: "nil when there are no namespaces", + fake: &fakeCloudService{ + pages: []*tcloudservicev1.GetNamespacesResponse{{}}, + }, + expPageTokens: []string{""}, + }, + { + name: "returns client errors", + fake: &fakeCloudService{err: errors.New("boom")}, + expErr: "boom", + expPageTokens: []string{""}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + require := require.New(tt) + + got, err := findNamespaceByName(context.TODO(), tc.fake, "my-ns") + + var tokens []string + for _, req := range tc.fake.reqs { + require.Equal("my-ns", req.GetName()) + tokens = append(tokens, req.GetPageToken()) + } + require.Equal(tc.expPageTokens, tokens) + + if tc.expErr != "" { + require.ErrorContains(err, tc.expErr) + return + } + require.Nil(err) + if tc.expID == "" { + require.Nil(got) + return + } + require.Equal(tc.expID, got.GetNamespace()) + }) + } +} + +func TestTagsDiff(t *testing.T) { + cases := []struct { + name string + desired map[string]string + latest map[string]string + expUpsert map[string]string + expRemove []string + }{ + { + name: "both empty", + }, + { + name: "equal", + desired: map[string]string{"a": "1", "b": "2"}, + latest: map[string]string{"a": "1", "b": "2"}, + }, + { + name: "adds new tags", + desired: map[string]string{"a": "1", "b": "2"}, + latest: map[string]string{"a": "1"}, + expUpsert: map[string]string{"b": "2"}, + }, + { + name: "updates changed values", + desired: map[string]string{"a": "new"}, + latest: map[string]string{"a": "old"}, + expUpsert: map[string]string{"a": "new"}, + }, + { + name: "removes missing tags in sorted order", + desired: map[string]string{"a": "1"}, + latest: map[string]string{"a": "1", "c": "3", "b": "2"}, + expRemove: []string{"b", "c"}, + }, + { + name: "removes everything when desired is nil", + latest: map[string]string{"a": "1"}, + expRemove: []string{"a"}, + }, + { + name: "upserts and removes together", + desired: map[string]string{"a": "new", "b": "2"}, + latest: map[string]string{"a": "old", "c": "3"}, + expUpsert: map[string]string{"a": "new", "b": "2"}, + expRemove: []string{"c"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + require := require.New(tt) + + upsert, remove := tagsDiff(tc.desired, tc.latest) + + require.Equal(tc.expUpsert, upsert) + require.Equal(tc.expRemove, remove) + }) + } +} diff --git a/controller/namespace/reconcile_test.go b/controller/namespace/reconcile_test.go index 863a49d..7abe685 100644 --- a/controller/namespace/reconcile_test.go +++ b/controller/namespace/reconcile_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" "github.com/temporalio/cloud-operator/controller/namespace" "github.com/temporalio/cloud-operator/pkg/testutil/kube" @@ -16,6 +17,9 @@ import ( "github.com/temporalio/kube-temporal/pkg/types" tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ctrlrt "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -88,21 +92,82 @@ func TestReconciler_ResourceFromRequest(t *testing.T) { func TestReconciler_ResourceSetLatest(t *testing.T) { tnsName := "tmprl-ns" + tnsID := tnsName + ".acct1" project := "tmprl-project" projectOther := "tmprl-project-other" + + // tns has never been synced, so it has no Status.ID and + // ResourceSetLatest looks the namespace up by name. tns := &tcloudv1alpha1.Namespace{} tns.Name = tnsName tns.Namespace = kubeNamespace tns.Spec.Project = project + tns.Spec.RetentionDays = 7 + + // tnsSynced has been synced before, so ResourceSetLatest gets the + // namespace by its Status.ID. + tnsSynced := tns.DeepCopy() + tnsSynced.Status.ID = tnsID + tnsSynced.Status.ResourceVersion = "rv-0" + + // cloudNS is the namespace as Temporal Cloud reports it. Its project + // differs from the CR's so we can see that latest is mutated to match + // Temporal Cloud. + cloudNS := &tcloudnamespacev1.Namespace{ + Namespace: tnsID, + ProjectId: projectOther, + Tags: map[string]string{"team": "core"}, + Spec: &tcloudnamespacev1.NamespaceSpec{ + Name: tnsName, + Replicas: []*tcloudnamespacev1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 30, + }, + ResourceVersion: "rv-1", + State: tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE, + AsyncOperationId: "op-1", + } + + // withLatest returns a copy of the supplied CR as a types.Resource after + // applying mutate, for building the expected latest resource. + withLatest := func( + cr *tcloudv1alpha1.Namespace, + mutate func(*tcloudv1alpha1.Namespace), + ) types.Resource { + ns := cr.DeepCopy() + mutate(ns) + return namespace.NewResource(ns) + } + // mappedFromCloud sets the spec and status that cloudNS maps to. + mappedFromCloud := func(ns *tcloudv1alpha1.Namespace) { + ns.Spec = tcloudv1alpha1.NamespaceSpec{ + Project: projectOther, + Tags: map[string]string{"team": "core"}, + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 30, + } + ns.Status.ResourceStatus = cloudcommon.ResourceStatus{ + ID: tnsID, + ResourceVersion: "rv-1", + State: cloudcommon.ResourceStateActive, + AsyncOperationID: "op-1", + } + } + // cleared empties the spec and status, which is what ResourceSetLatest + // does when the namespace does not exist in Temporal Cloud. + cleared := func(ns *tcloudv1alpha1.Namespace) { + ns.Spec = tcloudv1alpha1.NamespaceSpec{} + ns.Status.ResourceStatus = cloudcommon.ResourceStatus{} + } cases := []struct { - name string - r *namespace.Reconciler - serverOpts []fakeserver.NewOption - asserter func(*testing.T) fakeserver.GetNamespaceAssertFunc - res types.Resource - latest types.Resource - expErr string + name string + r *namespace.Reconciler + serverOpts []fakeserver.NewOption + asserter func(*testing.T) fakeserver.GetNamespaceAssertFunc + listAsserter func(*testing.T) fakeserver.GetNamespacesAssertFunc + res types.Resource + latest types.Resource + expErr string }{ { name: "fail nil cloud connection manager or default client", @@ -117,47 +182,88 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { nil, fmt.Errorf("Indy! Bad dates!"), ), }, - res: namespace.NewResource(tns), + res: namespace.NewResource(tnsSynced), expErr: "failed getting tcloud namespace: rpc error: code = Unknown desc = Indy! Bad dates!", }, { - name: "fail if get namespace request empty", + name: "get namespace request uses status id", asserter: func(tt *testing.T) fakeserver.GetNamespaceAssertFunc { assert := assert.New(tt) return func(req *tcloudservicev1.GetNamespaceRequest) { - assert.NotEmpty(req.GetNamespace()) + assert.Equal(tnsID, req.GetNamespace()) } }, - res: namespace.NewResource(tns), + res: namespace.NewResource(tnsSynced), + }, + { + name: "latest is mutated from get namespace", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespaceReturn( + &tcloudservicev1.GetNamespaceResponse{Namespace: cloudNS}, nil, + ), + }, + res: namespace.NewResource(tnsSynced), + latest: withLatest(tnsSynced, mappedFromCloud), }, { - name: "latest spec is mutated", + // The namespace was deleted outside of the operator, so latest is + // cleared and ResourceSync recreates it. + name: "latest is cleared if get namespace is not found", serverOpts: []fakeserver.NewOption{ fakeserver.OnGetNamespaceReturn( - &tcloudservicev1.GetNamespaceResponse{ - Namespace: &tcloudnamespacev1.Namespace{ - Spec: &tcloudnamespacev1.NamespaceSpec{ - Name: tnsName, + nil, status.Error(codes.NotFound, "namespace not found"), + ), + }, + res: namespace.NewResource(tnsSynced), + latest: withLatest(tnsSynced, cleared), + }, + { + name: "fail if tcloud list namespaces errors", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespacesReturn( + nil, fmt.Errorf("Indy! Bad dates!"), + ), + }, + res: namespace.NewResource(tns), + expErr: "failed listing tcloud namespaces: rpc error: code = Unknown desc = Indy! Bad dates!", + }, + { + name: "list namespaces request uses cr name", + listAsserter: func(tt *testing.T) fakeserver.GetNamespacesAssertFunc { + assert := assert.New(tt) + return func(req *tcloudservicev1.GetNamespacesRequest) { + assert.Equal(tnsName, req.GetName()) + } + }, + res: namespace.NewResource(tns), + }, + { + name: "latest is mutated from namespace found by name", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespacesReturn( + &tcloudservicev1.GetNamespacesResponse{ + Namespaces: []*tcloudnamespacev1.Namespace{ + { + Namespace: "other-ns.acct1", + Spec: &tcloudnamespacev1.NamespaceSpec{Name: "other-ns"}, }, - ProjectId: projectOther, + cloudNS, }, }, nil, ), }, - res: namespace.NewResource(tns), - latest: func() types.Resource { - // We verify that if the Temporal Cloud client returns a - // GetNamespaceResponse containing a different ProjectId than - // the one on the supplied types.Resource's Spec.Project field, - // that the supplied types.Resource's Spec.Project field is - // mutated to match the response from the Temporal Cloud - // server. - ns := &tcloudv1alpha1.Namespace{} - ns.Name = tnsName - ns.Namespace = kubeNamespace - ns.Spec.Project = projectOther - return namespace.NewResource(ns) - }(), + res: namespace.NewResource(tns), + latest: withLatest(tns, mappedFromCloud), + }, + { + name: "latest is cleared if not found by name", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespacesReturn( + &tcloudservicev1.GetNamespacesResponse{}, nil, + ), + }, + res: namespace.NewResource(tns), + latest: withLatest(tns, cleared), }, } for _, tc := range cases { @@ -172,6 +278,12 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { fakeserver.OnGetNamespaceAssert(tc.asserter(tt)), ) } + if tc.listAsserter != nil { + sopts = append( + sopts, + fakeserver.OnGetNamespacesAssert(tc.listAsserter(tt)), + ) + } srv, err := fakeserver.New(sopts...) require.Nil(err) diff --git a/go.mod b/go.mod index eebb2e0..c99afb5 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/samber/lo v1.53.0 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 - github.com/temporalio/kube-temporal v0.5.0 + github.com/temporalio/kube-temporal v0.6.0 go.temporal.io/cloud-sdk v0.21.0 google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 k8s.io/api v0.37.1 k8s.io/apimachinery v0.37.1 k8s.io/client-go v0.37.1 @@ -86,7 +87,6 @@ require ( gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260918162117-cecb64721679 // indirect - google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.37.0 // indirect diff --git a/go.sum b/go.sum index 37cee79..cae5a44 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/temporalio/kube-temporal v0.5.0 h1:nOlbpYfK0AafxadyggFZ7ukpoMhJxj/sH/ZZ6VWVmxk= -github.com/temporalio/kube-temporal v0.5.0/go.mod h1:O0KWamVVgkckW+mrNrzl2Fs9kORYZlYMdITKQx2jbrs= +github.com/temporalio/kube-temporal v0.6.0 h1:Zp4qbIqOE+u3j040EZitjX4MRSb/IZbEG1xzlsC8lDI= +github.com/temporalio/kube-temporal v0.6.0/go.mod h1:1rjIEYZPbs6ahnxptA7Z5p549s2oZYeU6P337Bz50gY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/pkg/testutil/temporal/cloud/fake/server/namespace.go b/pkg/testutil/temporal/cloud/fake/server/namespace.go index 95e126a..f18689b 100644 --- a/pkg/testutil/temporal/cloud/fake/server/namespace.go +++ b/pkg/testutil/temporal/cloud/fake/server/namespace.go @@ -61,3 +61,59 @@ func (s fakeServer) GetNamespace( } return s.UnimplementedCloudServiceServer.GetNamespace(ctx, req) } + +type GetNamespacesFunc func( + context.Context, + *tcloudservicev1.GetNamespacesRequest, +) (*tcloudservicev1.GetNamespacesResponse, error) + +type GetNamespacesAssertFunc func(*tcloudservicev1.GetNamespacesRequest) + +// WithGetNamespaces sets the fake server's GetNamespaces function override. +func WithGetNamespaces(fn GetNamespacesFunc) NewOption { + return func(s *fakeServer) { + s.getNamespacesFunc = fn + } +} + +// OnGetNamespacesAssert allows the test author to supply a function that +// checks arguments passed to the GetNamespaces method are expected. +func OnGetNamespacesAssert( + asserter GetNamespacesAssertFunc, +) NewOption { + return func(s *fakeServer) { + fn := func( + ctx context.Context, req *tcloudservicev1.GetNamespacesRequest, + ) (*tcloudservicev1.GetNamespacesResponse, error) { + asserter(req) + return nil, nil + } + s.getNamespacesFunc = fn + } +} + +// OnGetNamespacesReturn allows the test author to set the returned value from +// the GetNamespaces method. +func OnGetNamespacesReturn( + resp *tcloudservicev1.GetNamespacesResponse, + err error, +) NewOption { + return func(s *fakeServer) { + fn := func( + context.Context, *tcloudservicev1.GetNamespacesRequest, + ) (*tcloudservicev1.GetNamespacesResponse, error) { + return resp, err + } + s.getNamespacesFunc = fn + } +} + +func (s fakeServer) GetNamespaces( + ctx context.Context, + req *tcloudservicev1.GetNamespacesRequest, +) (*tcloudservicev1.GetNamespacesResponse, error) { + if s.getNamespacesFunc != nil { + return s.getNamespacesFunc(ctx, req) + } + return s.UnimplementedCloudServiceServer.GetNamespaces(ctx, req) +} diff --git a/pkg/testutil/temporal/cloud/fake/server/server.go b/pkg/testutil/temporal/cloud/fake/server/server.go index cc57d0f..d485185 100644 --- a/pkg/testutil/temporal/cloud/fake/server/server.go +++ b/pkg/testutil/temporal/cloud/fake/server/server.go @@ -14,7 +14,8 @@ type fakeServer struct { lis net.Listener tcloudservicev1.UnimplementedCloudServiceServer - getNamespaceFunc GetNamespaceFunc + getNamespaceFunc GetNamespaceFunc + getNamespacesFunc GetNamespacesFunc } // HostPort returns the address of the TCP localhost port that the fake server