From 6c4075859d595bf344b1754102871e3a6f3adf57 Mon Sep 17 00:00:00 2001 From: zainawaisn Date: Sun, 25 Oct 2026 23:20:14 -0700 Subject: [PATCH 1/2] Add implementation and helpers for ResourceSetLatest and ResourceSync --- controller/namespace/convert.go | 512 ++++++++++++++++ controller/namespace/convert_test.go | 570 ++++++++++++++++++ controller/namespace/reconcile.go | 237 +++++++- .../namespace/reconcile_internal_test.go | 202 +++++++ go.mod | 6 +- go.sum | 4 +- 6 files changed, 1509 insertions(+), 22 deletions(-) create mode 100644 controller/namespace/convert.go create mode 100644 controller/namespace/convert_test.go create mode 100644 controller/namespace/reconcile_internal_test.go diff --git a/controller/namespace/convert.go b/controller/namespace/convert.go new file mode 100644 index 0000000..25e2f8a --- /dev/null +++ b/controller/namespace/convert.go @@ -0,0 +1,512 @@ +package namespace + +import ( + "maps" + "slices" + + tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" + tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" + "google.golang.org/protobuf/types/known/timestamppb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" + tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" +) + +// namespaceSpecFromProto returns the [tcloudv1alpha1.NamespaceSpec] that +// represents the supplied Temporal Cloud Namespace. +// +// The whole Namespace is taken, rather than only its spec, because some CR +// spec fields (Project, Tags) live on the outer Namespace message. +func namespaceSpecFromProto( + tns *tcloudnamespacev1.Namespace, +) tcloudv1alpha1.NamespaceSpec { + spec := tns.GetSpec() + return tcloudv1alpha1.NamespaceSpec{ + Project: tns.GetProjectId(), + Tags: maps.Clone(tns.GetTags()), + Replicas: replicasFromProto(spec.GetReplicas()), + RetentionDays: spec.GetRetentionDays(), + MTLSAuth: mtlsAuthFromProto(spec.GetMtlsAuth()), + APIKeyAuth: apiKeyAuthFromProto(spec.GetApiKeyAuth()), + SearchAttributes: searchAttributesFromProto(spec.GetSearchAttributes()), + CodecServer: codecServerFromProto(spec.GetCodecServer()), + Lifecycle: lifecycleFromProto(spec.GetLifecycle()), + HighAvailability: highAvailabilityFromProto(spec.GetHighAvailability()), + ConnectivityRuleIDs: slices.Clone(spec.GetConnectivityRuleIds()), + Capacity: capacityFromProto(spec.GetCapacitySpec()), + Fairness: fairnessFromProto(spec.GetFairness()), + EncryptionValidation: encryptionValidationFromProto(spec.GetEncryptionValidation()), + Description: spec.GetDescription(), + } +} + +// resourceStatusFromProto returns the [cloudcommon.ResourceStatus] that +// represents the supplied Temporal Cloud Namespace. +func resourceStatusFromProto( + tns *tcloudnamespacev1.Namespace, +) cloudcommon.ResourceStatus { + return cloudcommon.ResourceStatus{ + ID: tns.GetNamespace(), + ResourceVersion: tns.GetResourceVersion(), + State: resourceStateFromProto(tns.GetState()), + AsyncOperationID: tns.GetAsyncOperationId(), + CreatedTime: timeFromProto(tns.GetCreatedTime()), + LastModifiedTime: timeFromProto(tns.GetLastModifiedTime()), + } +} + +func replicasFromProto( + in []*tcloudnamespacev1.ReplicaSpec, +) []tcloudv1alpha1.ReplicaSpec { + if len(in) == 0 { + return nil + } + out := make([]tcloudv1alpha1.ReplicaSpec, 0, len(in)) + for _, r := range in { + out = append(out, tcloudv1alpha1.ReplicaSpec{Region: r.GetRegion()}) + } + return out +} + +func mtlsAuthFromProto( + in *tcloudnamespacev1.MtlsAuthSpec, +) *tcloudv1alpha1.MTLSAuthSpec { + if in == nil { + return nil + } + out := &tcloudv1alpha1.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, + tcloudv1alpha1.CertificateFilterSpec{ + CommonName: f.GetCommonName(), + Organization: f.GetOrganization(), + OrganizationalUnit: f.GetOrganizationalUnit(), + SubjectAlternativeName: f.GetSubjectAlternativeName(), + }, + ) + } + return out +} + +func apiKeyAuthFromProto( + in *tcloudnamespacev1.ApiKeyAuthSpec, +) *tcloudv1alpha1.APIKeyAuthSpec { + if in == nil { + return nil + } + return &tcloudv1alpha1.APIKeyAuthSpec{Enabled: in.GetEnabled()} +} + +func codecServerFromProto( + in *tcloudnamespacev1.CodecServerSpec, +) *tcloudv1alpha1.CodecServerSpec { + if in == nil { + return nil + } + out := &tcloudv1alpha1.CodecServerSpec{ + Endpoint: in.GetEndpoint(), + PassAccessToken: in.GetPassAccessToken(), + IncludeCrossOriginCredentials: in.GetIncludeCrossOriginCredentials(), + } + if cem := in.GetCustomErrorMessage(); cem != nil { + out.CustomErrorMessage = &tcloudv1alpha1.CodecServerCustomErrorMessage{} + if def := cem.GetDefault(); def != nil { + out.CustomErrorMessage.Default = &tcloudv1alpha1.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, +) *tcloudv1alpha1.HighAvailabilitySpec { + if in == nil { + return nil + } + return &tcloudv1alpha1.HighAvailabilitySpec{ + DisableManagedFailover: in.GetDisableManagedFailover(), + DisablePassivePollerForwarding: in.GetDisablePassivePollerForwarding(), + } +} + +func capacityFromProto( + in *tcloudnamespacev1.CapacitySpec, +) *tcloudv1alpha1.CapacitySpec { + switch { + case in.GetOnDemand() != nil: + return &tcloudv1alpha1.CapacitySpec{ + OnDemand: &tcloudv1alpha1.OnDemandCapacity{}, + } + case in.GetProvisioned() != nil: + return &tcloudv1alpha1.CapacitySpec{ + Provisioned: &tcloudv1alpha1.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, +) *tcloudv1alpha1.FairnessSpec { + if in == nil { + return nil + } + return &tcloudv1alpha1.FairnessSpec{ + TaskQueueFairnessEnabled: in.GetTaskQueueFairnessEnabled(), + } +} + +func encryptionValidationFromProto( + in *tcloudnamespacev1.EncryptionValidationSpec, +) *tcloudv1alpha1.EncryptionValidationSpec { + if in == nil { + return nil + } + return &tcloudv1alpha1.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]tcloudv1alpha1.SearchAttributeType { + if len(in) == 0 { + return nil + } + out := make(map[string]tcloudv1alpha1.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] = tcloudv1alpha1.SearchAttributeTypeText + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD: + out[name] = tcloudv1alpha1.SearchAttributeTypeKeyword + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT: + out[name] = tcloudv1alpha1.SearchAttributeTypeInt + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE: + out[name] = tcloudv1alpha1.SearchAttributeTypeDouble + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL: + out[name] = tcloudv1alpha1.SearchAttributeTypeBool + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME: + out[name] = tcloudv1alpha1.SearchAttributeTypeDatetime + case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST: + out[name] = tcloudv1alpha1.SearchAttributeTypeKeywordList + default: + out[name] = tcloudv1alpha1.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, +) tcloudv1alpha1.EncryptionValidationMode { + switch in { + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED: + return "" + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED: + return tcloudv1alpha1.EncryptionValidationModeDisabled + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN: + return tcloudv1alpha1.EncryptionValidationModeWarn + case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY: + return tcloudv1alpha1.EncryptionValidationModeDeny + default: + return tcloudv1alpha1.EncryptionValidationMode(in.String()) + } +} + +// resourceStateFromProto converts a Temporal Cloud resource state to its CR +// name. Unspecified and unknown states map to "": the CR field is +// enum-validated, so an unrecognized value would fail the status patch. +func resourceStateFromProto( + in tcloudresourcev1.ResourceState, +) cloudcommon.ResourceState { + switch in { + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING: + return cloudcommon.ResourceStateActivating + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED: + return cloudcommon.ResourceStateActivationFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE: + return cloudcommon.ResourceStateActive + case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING: + return cloudcommon.ResourceStateUpdating + case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED: + return cloudcommon.ResourceStateUpdateFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING: + return cloudcommon.ResourceStateDeleting + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED: + return cloudcommon.ResourceStateDeleteFailed + case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED: + return cloudcommon.ResourceStateDeleted + case tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED: + return cloudcommon.ResourceStateSuspended + case tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED: + return cloudcommon.ResourceStateExpired + default: + return "" + } +} + +func timeFromProto(ts *timestamppb.Timestamp) *metav1.Time { + if ts == nil { + return nil + } + t := metav1.NewTime(ts.AsTime()) + return &t +} + +// namespaceSpecToProto returns the Temporal Cloud NamespaceSpec that +// represents the supplied Namespace CR. The namespace name comes from the CR's +// metadata.name. Project and tags are not part of the Temporal Cloud +// NamespaceSpec and are sent separately. +func namespaceSpecToProto( + cr *tcloudv1alpha1.Namespace, +) *tcloudnamespacev1.NamespaceSpec { + spec := &cr.Spec + return &tcloudnamespacev1.NamespaceSpec{ + Name: cr.Name, + Replicas: replicasToProto(spec.Replicas), + RetentionDays: spec.RetentionDays, + MtlsAuth: mtlsAuthToProto(spec.MTLSAuth), + ApiKeyAuth: apiKeyAuthToProto(spec.APIKeyAuth), + SearchAttributes: searchAttributesToProto(spec.SearchAttributes), + CodecServer: codecServerToProto(spec.CodecServer), + Lifecycle: lifecycleToProto(spec.Lifecycle), + HighAvailability: highAvailabilityToProto(spec.HighAvailability), + ConnectivityRuleIds: slices.Clone(spec.ConnectivityRuleIDs), + CapacitySpec: capacityToProto(spec.Capacity), + Fairness: fairnessToProto(spec.Fairness), + EncryptionValidation: encryptionValidationToProto(spec.EncryptionValidation), + Description: spec.Description, + } +} + +func replicasToProto( + in []tcloudv1alpha1.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 *tcloudv1alpha1.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 *tcloudv1alpha1.APIKeyAuthSpec, +) *tcloudnamespacev1.ApiKeyAuthSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.ApiKeyAuthSpec{Enabled: in.Enabled} +} + +func codecServerToProto( + in *tcloudv1alpha1.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 *tcloudv1alpha1.HighAvailabilitySpec, +) *tcloudnamespacev1.HighAvailabilitySpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.HighAvailabilitySpec{ + DisableManagedFailover: in.DisableManagedFailover, + DisablePassivePollerForwarding: in.DisablePassivePollerForwarding, + } +} + +func capacityToProto( + in *tcloudv1alpha1.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 *tcloudv1alpha1.FairnessSpec, +) *tcloudnamespacev1.FairnessSpec { + if in == nil { + return nil + } + return &tcloudnamespacev1.FairnessSpec{ + TaskQueueFairnessEnabled: in.TaskQueueFairnessEnabled, + } +} + +func encryptionValidationToProto( + in *tcloudv1alpha1.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]tcloudv1alpha1.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 tcloudv1alpha1.SearchAttributeTypeText: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT + case tcloudv1alpha1.SearchAttributeTypeKeyword: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD + case tcloudv1alpha1.SearchAttributeTypeInt: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT + case tcloudv1alpha1.SearchAttributeTypeDouble: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE + case tcloudv1alpha1.SearchAttributeTypeBool: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL + case tcloudv1alpha1.SearchAttributeTypeDatetime: + out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME + case tcloudv1alpha1.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 tcloudv1alpha1.EncryptionValidationMode, +) tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode { + switch in { + case tcloudv1alpha1.EncryptionValidationModeDisabled: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED + case tcloudv1alpha1.EncryptionValidationModeWarn: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN + case tcloudv1alpha1.EncryptionValidationModeDeny: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY + default: + return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED + } +} diff --git a/controller/namespace/convert_test.go b/controller/namespace/convert_test.go new file mode 100644 index 0000000..2936937 --- /dev/null +++ b/controller/namespace/convert_test.go @@ -0,0 +1,570 @@ +package namespace + +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" + tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" +) + +// 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() tcloudv1alpha1.NamespaceSpec { + return tcloudv1alpha1.NamespaceSpec{ + Project: "proj-1", + Tags: map[string]string{"team": "core", "env": "prod"}, + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}, {Region: "aws-us-west-2"}}, + RetentionDays: 30, + MTLSAuth: &tcloudv1alpha1.MTLSAuthSpec{ + Enabled: true, + AcceptedClientCA: "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n", + CertificateFilters: []tcloudv1alpha1.CertificateFilterSpec{ + {CommonName: "cn", Organization: "org", OrganizationalUnit: "ou", SubjectAlternativeName: "san"}, + {CommonName: "cn2"}, + }, + }, + APIKeyAuth: &tcloudv1alpha1.APIKeyAuthSpec{Enabled: true}, + SearchAttributes: map[string]tcloudv1alpha1.SearchAttributeType{ + "CustomText": tcloudv1alpha1.SearchAttributeTypeText, + "CustomKeyword": tcloudv1alpha1.SearchAttributeTypeKeyword, + }, + CodecServer: &tcloudv1alpha1.CodecServerSpec{ + Endpoint: "https://codec.example.com", + PassAccessToken: true, + IncludeCrossOriginCredentials: false, + CustomErrorMessage: &tcloudv1alpha1.CodecServerCustomErrorMessage{ + Default: &tcloudv1alpha1.CodecServerErrorMessage{ + Message: "codec unavailable", + Link: "https://docs.example.com", + }, + }, + }, + Lifecycle: &cloudcommon.LifecycleSpec{EnableDeleteProtection: true}, + HighAvailability: &tcloudv1alpha1.HighAvailabilitySpec{ + DisableManagedFailover: true, + DisablePassivePollerForwarding: false, + }, + ConnectivityRuleIDs: []string{"rule-1", "rule-2"}, + Capacity: &tcloudv1alpha1.CapacitySpec{ + Provisioned: &tcloudv1alpha1.ProvisionedCapacity{Value: 4}, + }, + Fairness: &tcloudv1alpha1.FairnessSpec{TaskQueueFairnessEnabled: true}, + EncryptionValidation: &tcloudv1alpha1.EncryptionValidationSpec{ + Mode: tcloudv1alpha1.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 crWithSpec(spec tcloudv1alpha1.NamespaceSpec) *tcloudv1alpha1.Namespace { + cr := &tcloudv1alpha1.Namespace{Spec: spec} + cr.Name = "my-ns" + return cr +} + +func TestNamespaceSpecFromProto(t *testing.T) { + minimal := tcloudv1alpha1.NamespaceSpec{ + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + } + cases := []struct { + name string + in *tcloudnamespacev1.Namespace + exp tcloudv1alpha1.NamespaceSpec + }{ + { + name: "nil namespace", + in: nil, + exp: tcloudv1alpha1.NamespaceSpec{}, + }, + { + name: "nil spec keeps outer fields", + in: &tcloudnamespacev1.Namespace{ + ProjectId: "proj-1", + Tags: map[string]string{"a": "1"}, + }, + exp: tcloudv1alpha1.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) { + require.Equal(tt, tc.exp, namespaceSpecFromProto(tc.in)) + }) + } +} + +func TestNamespaceSpecFromProto_CopiesCollections(t *testing.T) { + require := require.New(t) + + in := fullProtoNamespace() + got := namespaceSpecFromProto(in) + + in.Tags["team"] = "changed" + in.Spec.ConnectivityRuleIds[0] = "changed" + in.Spec.EncryptionValidation.MetadataValues[0] = "changed" + + require.Equal("core", got.Tags["team"]) + require.Equal("rule-1", got.ConnectivityRuleIDs[0]) + require.Equal("binary/encrypted", got.EncryptionValidation.MetadataValues[0]) +} + +func TestNamespaceSpecToProto(t *testing.T) { + cases := []struct { + name string + in *tcloudv1alpha1.Namespace + exp *tcloudnamespacev1.NamespaceSpec + }{ + { + name: "unset optional blocks stay nil", + in: crWithSpec(tcloudv1alpha1.NamespaceSpec{ + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }), + exp: &tcloudnamespacev1.NamespaceSpec{ + Name: "my-ns", + Replicas: []*tcloudnamespacev1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }, + }, + { + // Project and tags have no place in the proto NamespaceSpec and + // are sent at the request level instead. + name: "every field", + in: crWithSpec(fullSpec()), + exp: fullProtoNamespace().GetSpec(), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + got := namespaceSpecToProto(tc.in) + require.True(tt, proto.Equal(tc.exp, got), "expected %v, got %v", tc.exp, got) + }) + } +} + +func TestNamespaceSpec_RoundTrip(t *testing.T) { + cases := []struct { + name string + spec tcloudv1alpha1.NamespaceSpec + }{ + { + name: "minimal", + spec: tcloudv1alpha1.NamespaceSpec{ + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + }, + }, + { + name: "every field", + spec: fullSpec(), + }, + { + name: "on-demand capacity", + spec: tcloudv1alpha1.NamespaceSpec{ + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + Capacity: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + }, + }, + { + name: "explicitly empty blocks stay non-nil", + spec: tcloudv1alpha1.NamespaceSpec{ + Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + RetentionDays: 7, + MTLSAuth: &tcloudv1alpha1.MTLSAuthSpec{}, + APIKeyAuth: &tcloudv1alpha1.APIKeyAuthSpec{}, + CodecServer: &tcloudv1alpha1.CodecServerSpec{CustomErrorMessage: &tcloudv1alpha1.CodecServerCustomErrorMessage{}}, + Lifecycle: &cloudcommon.LifecycleSpec{}, + HighAvailability: &tcloudv1alpha1.HighAvailabilitySpec{}, + Fairness: &tcloudv1alpha1.FairnessSpec{}, + EncryptionValidation: &tcloudv1alpha1.EncryptionValidationSpec{Mode: tcloudv1alpha1.EncryptionValidationModeDisabled}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(tt *testing.T) { + cr := crWithSpec(tc.spec) + tns := &tcloudnamespacev1.Namespace{ + ProjectId: cr.Spec.Project, + Tags: cr.Spec.Tags, + Spec: namespaceSpecToProto(cr), + } + require.Equal(tt, tc.spec, namespaceSpecFromProto(tns)) + }) + } +} + +func TestCapacityFromProto(t *testing.T) { + cases := []struct { + name string + in *tcloudnamespacev1.CapacitySpec + exp *tcloudv1alpha1.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: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + }, + { + name: "provisioned truncates fractional TRUs", + in: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ + Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{Value: 2.9}, + }, + }, + exp: &tcloudv1alpha1.CapacitySpec{ + Provisioned: &tcloudv1alpha1.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 *tcloudv1alpha1.CapacitySpec + exp *tcloudnamespacev1.CapacitySpec + }{ + { + name: "nil", + }, + { + name: "no mode set", + in: &tcloudv1alpha1.CapacitySpec{}, + }, + { + name: "on demand", + in: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + exp: &tcloudnamespacev1.CapacitySpec{ + Spec: &tcloudnamespacev1.CapacitySpec_OnDemand_{ + OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, + }, + }, + }, + { + name: "provisioned", + in: &tcloudv1alpha1.CapacitySpec{ + Provisioned: &tcloudv1alpha1.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, &tcloudv1alpha1.CodecServerSpec{ + Endpoint: "https://codec.example.com", + CustomErrorMessage: &tcloudv1alpha1.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([]tcloudv1alpha1.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 tcloudv1alpha1.SearchAttributeType + }{ + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT, tcloudv1alpha1.SearchAttributeTypeText}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, tcloudv1alpha1.SearchAttributeTypeKeyword}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT, tcloudv1alpha1.SearchAttributeTypeInt}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE, tcloudv1alpha1.SearchAttributeTypeDouble}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL, tcloudv1alpha1.SearchAttributeTypeBool}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME, tcloudv1alpha1.SearchAttributeTypeDatetime}, + {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST, tcloudv1alpha1.SearchAttributeTypeKeywordList}, + } + for _, p := range pairs { + t.Run(string(p.cr), func(tt *testing.T) { + require := require.New(tt) + require.Equal( + map[string]tcloudv1alpha1.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]tcloudv1alpha1.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]tcloudv1alpha1.SearchAttributeType{ + "unspecified": "", + "unknown": "99", + }, got) + }) + + t.Run("unknown to proto", func(tt *testing.T) { + got := searchAttributesToProto(map[string]tcloudv1alpha1.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 tcloudv1alpha1.EncryptionValidationMode + }{ + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, tcloudv1alpha1.EncryptionValidationModeDisabled}, + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, tcloudv1alpha1.EncryptionValidationModeWarn}, + {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, tcloudv1alpha1.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( + tcloudv1alpha1.EncryptionValidationMode(""), + encryptionValidationModeFromProto(tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED), + ) + require.Equal( + tcloudv1alpha1.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"), + ) + }) +} + +func TestResourceStateFromProto(t *testing.T) { + cases := []struct { + in tcloudresourcev1.ResourceState + exp cloudcommon.ResourceState + }{ + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UNSPECIFIED, ""}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING, cloudcommon.ResourceStateActivating}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED, cloudcommon.ResourceStateActivationFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE, cloudcommon.ResourceStateActive}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING, cloudcommon.ResourceStateUpdating}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED, cloudcommon.ResourceStateUpdateFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING, cloudcommon.ResourceStateDeleting}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED, cloudcommon.ResourceStateDeleteFailed}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED, cloudcommon.ResourceStateDeleted}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED, cloudcommon.ResourceStateSuspended}, + {tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED, cloudcommon.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) { + require.Equal(tt, tc.exp, resourceStateFromProto(tc.in)) + }) + } +} + +func TestResourceStatusFromProto(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.Equal(tt, tc.exp, resourceStatusFromProto(tc.in)) + }) + } +} + +func TestTimeFromProto(t *testing.T) { + require := require.New(t) + + require.Nil(timeFromProto(nil)) + + ts := time.Date(2026, 1, 2, 3, 4, 5, 6, time.UTC) + require.Equal(&metav1.Time{Time: ts}, timeFromProto(timestamppb.New(ts))) +} diff --git a/controller/namespace/reconcile.go b/controller/namespace/reconcile.go index f3d6a6b..f16025d 100644 --- a/controller/namespace/reconcile.go +++ b/controller/namespace/reconcile.go @@ -3,13 +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" ) @@ -100,24 +109,48 @@ 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. ns := latest.(*res).cr - _ = ns - - // 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. - // - // latestObserved, err := tcc.CloudService().GetNamespace(...) - // if err != nil { - // return fmt.Errorf("failed getting tcloud namespace: %w", err) - // } - // ns.Spec.ProjectID = latestObserved.ProjectId - // ... + + var tns *tcloudnamespacev1.Namespace + if ns.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, ns.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: ns.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() + } + + if tns == nil { + // The namespace does not exist in Temporal Cloud, so mutate latest + // spec and status to be empty. + ns.Spec = tcloudv1alpha1.NamespaceSpec{} + ns.Status.ResourceStatus = cloudcommon.ResourceStatus{} + return nil + } + + ns.Spec = namespaceSpecFromProto(tns) + ns.Status.ResourceStatus = resourceStatusFromProto(tns) return err } @@ -149,14 +182,184 @@ 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 == "" { + resp, err := tcc.CreateNamespace(ctx, &tcloudservicev1.CreateNamespaceRequest{ + Spec: namespaceSpecToProto(desired), + Tags: desired.Spec.Tags, + ProjectId: desired.Spec.Project, + }) + 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, + ) + } + + id := latest.Status.ID + switch state := latest.Status.State; state { + 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.ResourceStateActivationFailed, cloudcommon.ResourceStateUpdateFailed: + return ctrlerrors.NewTerminalError(fmt.Errorf( + "%w: tcloud namespace %q is in state %s, contact Temporal Cloud support", + ctrlerrors.ErrResourceSync, id, state, + )) + } + + // 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: namespaceSpecToProto(desired), + 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. 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/go.mod b/go.mod index c09ef66..8c47cd6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 github.com/temporalio/kube-temporal v0.4.0 - go.temporal.io/cloud-sdk v0.7.1 + go.temporal.io/cloud-sdk v0.20.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 @@ -89,8 +91,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/grpc v1.84.0 // 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 aa236a3..37f237b 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6Tb go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.temporal.io/api v1.63.5 h1:c11+kPYHkXXL3UiShPdbMD+xtvqGsbTibUA9ypmiCa4= go.temporal.io/api v1.63.5/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= -go.temporal.io/cloud-sdk v0.7.1 h1:XZY81McyMuHSvTH7vkfjU5X/eN4eDYMVtBDvCVuIXP8= -go.temporal.io/cloud-sdk v0.7.1/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= +go.temporal.io/cloud-sdk v0.20.0 h1:H66BX7e9acBLn1g9zHhXu9FB1fI9t4zIEb6to08iA6c= +go.temporal.io/cloud-sdk v0.20.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= From 6fee49ae354de545ff90b7be6b2b0d9e34479bd2 Mon Sep 17 00:00:00 2001 From: zainawaisn Date: Wed, 28 Oct 2026 00:13:27 -0700 Subject: [PATCH 2/2] Address PR feedback, move convert.go helpers into v1alpha1/namespace.go, update ResourceSetLatest test --- api/cloud/common/resource_status.go | 61 +++ api/cloud/common/resource_status_test.go | 39 ++ api/cloud/v1alpha1/namespace.go | 471 ++++++++++++++++ .../cloud/v1alpha1/namespace_test.go | 328 ++++++----- controller/namespace/convert.go | 512 ------------------ controller/namespace/reconcile.go | 91 ++-- controller/namespace/reconcile_test.go | 174 ++++-- go.mod | 8 +- go.sum | 6 +- .../temporal/cloud/fake/server/namespace.go | 56 ++ .../temporal/cloud/fake/server/server.go | 3 +- 11 files changed, 986 insertions(+), 763 deletions(-) create mode 100644 api/cloud/common/resource_status_test.go rename controller/namespace/convert_test.go => api/cloud/v1alpha1/namespace_test.go (62%) delete mode 100644 controller/namespace/convert.go 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/controller/namespace/convert_test.go b/api/cloud/v1alpha1/namespace_test.go similarity index 62% rename from controller/namespace/convert_test.go rename to api/cloud/v1alpha1/namespace_test.go index 2936937..a0453ed 100644 --- a/controller/namespace/convert_test.go +++ b/api/cloud/v1alpha1/namespace_test.go @@ -1,4 +1,4 @@ -package namespace +package v1alpha1 import ( "testing" @@ -12,54 +12,53 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" - tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" ) // 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() tcloudv1alpha1.NamespaceSpec { - return tcloudv1alpha1.NamespaceSpec{ +func fullSpec() NamespaceSpec { + return NamespaceSpec{ Project: "proj-1", Tags: map[string]string{"team": "core", "env": "prod"}, - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}, {Region: "aws-us-west-2"}}, + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}, {Region: "aws-us-west-2"}}, RetentionDays: 30, - MTLSAuth: &tcloudv1alpha1.MTLSAuthSpec{ + MTLSAuth: &MTLSAuthSpec{ Enabled: true, AcceptedClientCA: "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n", - CertificateFilters: []tcloudv1alpha1.CertificateFilterSpec{ + CertificateFilters: []CertificateFilterSpec{ {CommonName: "cn", Organization: "org", OrganizationalUnit: "ou", SubjectAlternativeName: "san"}, {CommonName: "cn2"}, }, }, - APIKeyAuth: &tcloudv1alpha1.APIKeyAuthSpec{Enabled: true}, - SearchAttributes: map[string]tcloudv1alpha1.SearchAttributeType{ - "CustomText": tcloudv1alpha1.SearchAttributeTypeText, - "CustomKeyword": tcloudv1alpha1.SearchAttributeTypeKeyword, + APIKeyAuth: &APIKeyAuthSpec{Enabled: true}, + SearchAttributes: map[string]SearchAttributeType{ + "CustomText": SearchAttributeTypeText, + "CustomKeyword": SearchAttributeTypeKeyword, }, - CodecServer: &tcloudv1alpha1.CodecServerSpec{ + CodecServer: &CodecServerSpec{ Endpoint: "https://codec.example.com", PassAccessToken: true, IncludeCrossOriginCredentials: false, - CustomErrorMessage: &tcloudv1alpha1.CodecServerCustomErrorMessage{ - Default: &tcloudv1alpha1.CodecServerErrorMessage{ + CustomErrorMessage: &CodecServerCustomErrorMessage{ + Default: &CodecServerErrorMessage{ Message: "codec unavailable", Link: "https://docs.example.com", }, }, }, Lifecycle: &cloudcommon.LifecycleSpec{EnableDeleteProtection: true}, - HighAvailability: &tcloudv1alpha1.HighAvailabilitySpec{ + HighAvailability: &HighAvailabilitySpec{ DisableManagedFailover: true, DisablePassivePollerForwarding: false, }, ConnectivityRuleIDs: []string{"rule-1", "rule-2"}, - Capacity: &tcloudv1alpha1.CapacitySpec{ - Provisioned: &tcloudv1alpha1.ProvisionedCapacity{Value: 4}, + Capacity: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 4}, }, - Fairness: &tcloudv1alpha1.FairnessSpec{TaskQueueFairnessEnabled: true}, - EncryptionValidation: &tcloudv1alpha1.EncryptionValidationSpec{ - Mode: tcloudv1alpha1.EncryptionValidationModeWarn, + Fairness: &FairnessSpec{TaskQueueFairnessEnabled: true}, + EncryptionValidation: &EncryptionValidationSpec{ + Mode: EncryptionValidationModeWarn, MetadataKey: "encoding", MetadataValues: []string{"binary/encrypted"}, InspectHeader: true, @@ -129,26 +128,26 @@ func fullProtoNamespace() *tcloudnamespacev1.Namespace { } } -func crWithSpec(spec tcloudv1alpha1.NamespaceSpec) *tcloudv1alpha1.Namespace { - cr := &tcloudv1alpha1.Namespace{Spec: spec} - cr.Name = "my-ns" - return cr +func namespaceWithSpec(spec NamespaceSpec) *Namespace { + ns := &Namespace{Spec: spec} + ns.Name = "my-ns" + return ns } -func TestNamespaceSpecFromProto(t *testing.T) { - minimal := tcloudv1alpha1.NamespaceSpec{ - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, +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 tcloudv1alpha1.NamespaceSpec + exp NamespaceSpec }{ { name: "nil namespace", in: nil, - exp: tcloudv1alpha1.NamespaceSpec{}, + exp: NamespaceSpec{}, }, { name: "nil spec keeps outer fields", @@ -156,7 +155,7 @@ func TestNamespaceSpecFromProto(t *testing.T) { ProjectId: "proj-1", Tags: map[string]string{"a": "1"}, }, - exp: tcloudv1alpha1.NamespaceSpec{ + exp: NamespaceSpec{ Project: "proj-1", Tags: map[string]string{"a": "1"}, }, @@ -180,69 +179,123 @@ func TestNamespaceSpecFromProto(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(tt *testing.T) { - require.Equal(tt, tc.exp, namespaceSpecFromProto(tc.in)) + var got Namespace + got.FromProto(tc.in) + require.Equal(tt, tc.exp, got.Spec) }) } } -func TestNamespaceSpecFromProto_CopiesCollections(t *testing.T) { +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() - got := namespaceSpecFromProto(in) + 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.Tags["team"]) - require.Equal("rule-1", got.ConnectivityRuleIDs[0]) - require.Equal("binary/encrypted", got.EncryptionValidation.MetadataValues[0]) + 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 TestNamespaceSpecToProto(t *testing.T) { +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 *tcloudv1alpha1.Namespace - exp *tcloudnamespacev1.NamespaceSpec + in *Namespace + exp *tcloudnamespacev1.Namespace }{ { name: "unset optional blocks stay nil", - in: crWithSpec(tcloudv1alpha1.NamespaceSpec{ - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + in: namespaceWithSpec(NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, RetentionDays: 7, }), - exp: &tcloudnamespacev1.NamespaceSpec{ - Name: "my-ns", - Replicas: []*tcloudnamespacev1.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, + }, }, }, { - // Project and tags have no place in the proto NamespaceSpec and - // are sent at the request level instead. name: "every field", - in: crWithSpec(fullSpec()), - exp: fullProtoNamespace().GetSpec(), + in: namespaceWithSpec(fullSpec()), + exp: full, }, } for _, tc := range cases { t.Run(tc.name, func(tt *testing.T) { - got := namespaceSpecToProto(tc.in) + got := tc.in.ToProto() require.True(tt, proto.Equal(tc.exp, got), "expected %v, got %v", tc.exp, got) }) } } -func TestNamespaceSpec_RoundTrip(t *testing.T) { +func TestNamespace_RoundTrip(t *testing.T) { cases := []struct { name string - spec tcloudv1alpha1.NamespaceSpec + spec NamespaceSpec }{ { name: "minimal", - spec: tcloudv1alpha1.NamespaceSpec{ - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, RetentionDays: 7, }, }, @@ -252,36 +305,32 @@ func TestNamespaceSpec_RoundTrip(t *testing.T) { }, { name: "on-demand capacity", - spec: tcloudv1alpha1.NamespaceSpec{ - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, RetentionDays: 7, - Capacity: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + Capacity: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, }, }, { name: "explicitly empty blocks stay non-nil", - spec: tcloudv1alpha1.NamespaceSpec{ - Replicas: []tcloudv1alpha1.ReplicaSpec{{Region: "aws-us-east-1"}}, + spec: NamespaceSpec{ + Replicas: []ReplicaSpec{{Region: "aws-us-east-1"}}, RetentionDays: 7, - MTLSAuth: &tcloudv1alpha1.MTLSAuthSpec{}, - APIKeyAuth: &tcloudv1alpha1.APIKeyAuthSpec{}, - CodecServer: &tcloudv1alpha1.CodecServerSpec{CustomErrorMessage: &tcloudv1alpha1.CodecServerCustomErrorMessage{}}, + MTLSAuth: &MTLSAuthSpec{}, + APIKeyAuth: &APIKeyAuthSpec{}, + CodecServer: &CodecServerSpec{CustomErrorMessage: &CodecServerCustomErrorMessage{}}, Lifecycle: &cloudcommon.LifecycleSpec{}, - HighAvailability: &tcloudv1alpha1.HighAvailabilitySpec{}, - Fairness: &tcloudv1alpha1.FairnessSpec{}, - EncryptionValidation: &tcloudv1alpha1.EncryptionValidationSpec{Mode: tcloudv1alpha1.EncryptionValidationModeDisabled}, + HighAvailability: &HighAvailabilitySpec{}, + Fairness: &FairnessSpec{}, + EncryptionValidation: &EncryptionValidationSpec{Mode: EncryptionValidationModeDisabled}, }, }, } for _, tc := range cases { t.Run(tc.name, func(tt *testing.T) { - cr := crWithSpec(tc.spec) - tns := &tcloudnamespacev1.Namespace{ - ProjectId: cr.Spec.Project, - Tags: cr.Spec.Tags, - Spec: namespaceSpecToProto(cr), - } - require.Equal(tt, tc.spec, namespaceSpecFromProto(tns)) + var got Namespace + got.FromProto(namespaceWithSpec(tc.spec).ToProto()) + require.Equal(tt, tc.spec, got.Spec) }) } } @@ -290,7 +339,7 @@ func TestCapacityFromProto(t *testing.T) { cases := []struct { name string in *tcloudnamespacev1.CapacitySpec - exp *tcloudv1alpha1.CapacitySpec + exp *CapacitySpec }{ { name: "nil", @@ -306,7 +355,7 @@ func TestCapacityFromProto(t *testing.T) { OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, }, }, - exp: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + exp: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, }, { name: "provisioned truncates fractional TRUs", @@ -315,8 +364,8 @@ func TestCapacityFromProto(t *testing.T) { Provisioned: &tcloudnamespacev1.CapacitySpec_Provisioned{Value: 2.9}, }, }, - exp: &tcloudv1alpha1.CapacitySpec{ - Provisioned: &tcloudv1alpha1.ProvisionedCapacity{Value: 2}, + exp: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 2}, }, }, } @@ -330,7 +379,7 @@ func TestCapacityFromProto(t *testing.T) { func TestCapacityToProto(t *testing.T) { cases := []struct { name string - in *tcloudv1alpha1.CapacitySpec + in *CapacitySpec exp *tcloudnamespacev1.CapacitySpec }{ { @@ -338,11 +387,11 @@ func TestCapacityToProto(t *testing.T) { }, { name: "no mode set", - in: &tcloudv1alpha1.CapacitySpec{}, + in: &CapacitySpec{}, }, { name: "on demand", - in: &tcloudv1alpha1.CapacitySpec{OnDemand: &tcloudv1alpha1.OnDemandCapacity{}}, + in: &CapacitySpec{OnDemand: &OnDemandCapacity{}}, exp: &tcloudnamespacev1.CapacitySpec{ Spec: &tcloudnamespacev1.CapacitySpec_OnDemand_{ OnDemand: &tcloudnamespacev1.CapacitySpec_OnDemand{}, @@ -351,8 +400,8 @@ func TestCapacityToProto(t *testing.T) { }, { name: "provisioned", - in: &tcloudv1alpha1.CapacitySpec{ - Provisioned: &tcloudv1alpha1.ProvisionedCapacity{Value: 8}, + in: &CapacitySpec{ + Provisioned: &ProvisionedCapacity{Value: 8}, }, exp: &tcloudnamespacev1.CapacitySpec{ Spec: &tcloudnamespacev1.CapacitySpec_Provisioned_{ @@ -374,9 +423,9 @@ func TestCodecServerFromProto_CustomErrorMessageWithoutDefault(t *testing.T) { Endpoint: "https://codec.example.com", CustomErrorMessage: &tcloudnamespacev1.CodecServerSpec_CustomErrorMessage{}, }) - require.Equal(t, &tcloudv1alpha1.CodecServerSpec{ + require.Equal(t, &CodecServerSpec{ Endpoint: "https://codec.example.com", - CustomErrorMessage: &tcloudv1alpha1.CodecServerCustomErrorMessage{}, + CustomErrorMessage: &CodecServerCustomErrorMessage{}, }, got) } @@ -395,7 +444,7 @@ func TestSubMessageConverters_Nil(t *testing.T) { require.Nil(searchAttributesFromProto(nil)) require.Nil(replicasToProto(nil)) - require.Nil(replicasToProto([]tcloudv1alpha1.ReplicaSpec{})) + require.Nil(replicasToProto([]ReplicaSpec{})) require.Nil(mtlsAuthToProto(nil)) require.Nil(apiKeyAuthToProto(nil)) require.Nil(codecServerToProto(nil)) @@ -409,26 +458,26 @@ func TestSubMessageConverters_Nil(t *testing.T) { func TestSearchAttributeTypeConversion(t *testing.T) { pairs := []struct { proto tcloudnamespacev1.NamespaceSpec_SearchAttributeType - cr tcloudv1alpha1.SearchAttributeType + cr SearchAttributeType }{ - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT, tcloudv1alpha1.SearchAttributeTypeText}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, tcloudv1alpha1.SearchAttributeTypeKeyword}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT, tcloudv1alpha1.SearchAttributeTypeInt}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE, tcloudv1alpha1.SearchAttributeTypeDouble}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL, tcloudv1alpha1.SearchAttributeTypeBool}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME, tcloudv1alpha1.SearchAttributeTypeDatetime}, - {tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST, tcloudv1alpha1.SearchAttributeTypeKeywordList}, + {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]tcloudv1alpha1.SearchAttributeType{"a": p.cr}, + 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]tcloudv1alpha1.SearchAttributeType{"a": p.cr}), + searchAttributesToProto(map[string]SearchAttributeType{"a": p.cr}), ) }) } @@ -438,14 +487,14 @@ func TestSearchAttributeTypeConversion(t *testing.T) { "unspecified": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED, "unknown": tcloudnamespacev1.NamespaceSpec_SearchAttributeType(99), }) - require.Equal(tt, map[string]tcloudv1alpha1.SearchAttributeType{ + require.Equal(tt, map[string]SearchAttributeType{ "unspecified": "", "unknown": "99", }, got) }) t.Run("unknown to proto", func(tt *testing.T) { - got := searchAttributesToProto(map[string]tcloudv1alpha1.SearchAttributeType{"a": "Bogus"}) + got := searchAttributesToProto(map[string]SearchAttributeType{"a": "Bogus"}) require.Equal(tt, map[string]tcloudnamespacev1.NamespaceSpec_SearchAttributeType{ "a": tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_UNSPECIFIED, }, got) @@ -455,11 +504,11 @@ func TestSearchAttributeTypeConversion(t *testing.T) { func TestEncryptionValidationModeConversion(t *testing.T) { pairs := []struct { proto tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode - cr tcloudv1alpha1.EncryptionValidationMode + cr EncryptionValidationMode }{ - {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED, tcloudv1alpha1.EncryptionValidationModeDisabled}, - {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN, tcloudv1alpha1.EncryptionValidationModeWarn}, - {tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY, tcloudv1alpha1.EncryptionValidationModeDeny}, + {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) { @@ -472,11 +521,11 @@ func TestEncryptionValidationModeConversion(t *testing.T) { t.Run("unspecified and unknown from proto", func(tt *testing.T) { require := require.New(tt) require.Equal( - tcloudv1alpha1.EncryptionValidationMode(""), + EncryptionValidationMode(""), encryptionValidationModeFromProto(tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED), ) require.Equal( - tcloudv1alpha1.EncryptionValidationMode("99"), + EncryptionValidationMode("99"), encryptionValidationModeFromProto(tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode(99)), ) }) @@ -493,78 +542,3 @@ func TestEncryptionValidationModeConversion(t *testing.T) { ) }) } - -func TestResourceStateFromProto(t *testing.T) { - cases := []struct { - in tcloudresourcev1.ResourceState - exp cloudcommon.ResourceState - }{ - {tcloudresourcev1.ResourceState_RESOURCE_STATE_UNSPECIFIED, ""}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING, cloudcommon.ResourceStateActivating}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED, cloudcommon.ResourceStateActivationFailed}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE, cloudcommon.ResourceStateActive}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING, cloudcommon.ResourceStateUpdating}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED, cloudcommon.ResourceStateUpdateFailed}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING, cloudcommon.ResourceStateDeleting}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED, cloudcommon.ResourceStateDeleteFailed}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED, cloudcommon.ResourceStateDeleted}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED, cloudcommon.ResourceStateSuspended}, - {tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED, cloudcommon.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) { - require.Equal(tt, tc.exp, resourceStateFromProto(tc.in)) - }) - } -} - -func TestResourceStatusFromProto(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.Equal(tt, tc.exp, resourceStatusFromProto(tc.in)) - }) - } -} - -func TestTimeFromProto(t *testing.T) { - require := require.New(t) - - require.Nil(timeFromProto(nil)) - - ts := time.Date(2026, 1, 2, 3, 4, 5, 6, time.UTC) - require.Equal(&metav1.Time{Time: ts}, timeFromProto(timestamppb.New(ts))) -} diff --git a/controller/namespace/convert.go b/controller/namespace/convert.go deleted file mode 100644 index 25e2f8a..0000000 --- a/controller/namespace/convert.go +++ /dev/null @@ -1,512 +0,0 @@ -package namespace - -import ( - "maps" - "slices" - - tcloudnamespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1" - tcloudresourcev1 "go.temporal.io/cloud-sdk/api/resource/v1" - "google.golang.org/protobuf/types/known/timestamppb" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - cloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" - tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" -) - -// namespaceSpecFromProto returns the [tcloudv1alpha1.NamespaceSpec] that -// represents the supplied Temporal Cloud Namespace. -// -// The whole Namespace is taken, rather than only its spec, because some CR -// spec fields (Project, Tags) live on the outer Namespace message. -func namespaceSpecFromProto( - tns *tcloudnamespacev1.Namespace, -) tcloudv1alpha1.NamespaceSpec { - spec := tns.GetSpec() - return tcloudv1alpha1.NamespaceSpec{ - Project: tns.GetProjectId(), - Tags: maps.Clone(tns.GetTags()), - Replicas: replicasFromProto(spec.GetReplicas()), - RetentionDays: spec.GetRetentionDays(), - MTLSAuth: mtlsAuthFromProto(spec.GetMtlsAuth()), - APIKeyAuth: apiKeyAuthFromProto(spec.GetApiKeyAuth()), - SearchAttributes: searchAttributesFromProto(spec.GetSearchAttributes()), - CodecServer: codecServerFromProto(spec.GetCodecServer()), - Lifecycle: lifecycleFromProto(spec.GetLifecycle()), - HighAvailability: highAvailabilityFromProto(spec.GetHighAvailability()), - ConnectivityRuleIDs: slices.Clone(spec.GetConnectivityRuleIds()), - Capacity: capacityFromProto(spec.GetCapacitySpec()), - Fairness: fairnessFromProto(spec.GetFairness()), - EncryptionValidation: encryptionValidationFromProto(spec.GetEncryptionValidation()), - Description: spec.GetDescription(), - } -} - -// resourceStatusFromProto returns the [cloudcommon.ResourceStatus] that -// represents the supplied Temporal Cloud Namespace. -func resourceStatusFromProto( - tns *tcloudnamespacev1.Namespace, -) cloudcommon.ResourceStatus { - return cloudcommon.ResourceStatus{ - ID: tns.GetNamespace(), - ResourceVersion: tns.GetResourceVersion(), - State: resourceStateFromProto(tns.GetState()), - AsyncOperationID: tns.GetAsyncOperationId(), - CreatedTime: timeFromProto(tns.GetCreatedTime()), - LastModifiedTime: timeFromProto(tns.GetLastModifiedTime()), - } -} - -func replicasFromProto( - in []*tcloudnamespacev1.ReplicaSpec, -) []tcloudv1alpha1.ReplicaSpec { - if len(in) == 0 { - return nil - } - out := make([]tcloudv1alpha1.ReplicaSpec, 0, len(in)) - for _, r := range in { - out = append(out, tcloudv1alpha1.ReplicaSpec{Region: r.GetRegion()}) - } - return out -} - -func mtlsAuthFromProto( - in *tcloudnamespacev1.MtlsAuthSpec, -) *tcloudv1alpha1.MTLSAuthSpec { - if in == nil { - return nil - } - out := &tcloudv1alpha1.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, - tcloudv1alpha1.CertificateFilterSpec{ - CommonName: f.GetCommonName(), - Organization: f.GetOrganization(), - OrganizationalUnit: f.GetOrganizationalUnit(), - SubjectAlternativeName: f.GetSubjectAlternativeName(), - }, - ) - } - return out -} - -func apiKeyAuthFromProto( - in *tcloudnamespacev1.ApiKeyAuthSpec, -) *tcloudv1alpha1.APIKeyAuthSpec { - if in == nil { - return nil - } - return &tcloudv1alpha1.APIKeyAuthSpec{Enabled: in.GetEnabled()} -} - -func codecServerFromProto( - in *tcloudnamespacev1.CodecServerSpec, -) *tcloudv1alpha1.CodecServerSpec { - if in == nil { - return nil - } - out := &tcloudv1alpha1.CodecServerSpec{ - Endpoint: in.GetEndpoint(), - PassAccessToken: in.GetPassAccessToken(), - IncludeCrossOriginCredentials: in.GetIncludeCrossOriginCredentials(), - } - if cem := in.GetCustomErrorMessage(); cem != nil { - out.CustomErrorMessage = &tcloudv1alpha1.CodecServerCustomErrorMessage{} - if def := cem.GetDefault(); def != nil { - out.CustomErrorMessage.Default = &tcloudv1alpha1.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, -) *tcloudv1alpha1.HighAvailabilitySpec { - if in == nil { - return nil - } - return &tcloudv1alpha1.HighAvailabilitySpec{ - DisableManagedFailover: in.GetDisableManagedFailover(), - DisablePassivePollerForwarding: in.GetDisablePassivePollerForwarding(), - } -} - -func capacityFromProto( - in *tcloudnamespacev1.CapacitySpec, -) *tcloudv1alpha1.CapacitySpec { - switch { - case in.GetOnDemand() != nil: - return &tcloudv1alpha1.CapacitySpec{ - OnDemand: &tcloudv1alpha1.OnDemandCapacity{}, - } - case in.GetProvisioned() != nil: - return &tcloudv1alpha1.CapacitySpec{ - Provisioned: &tcloudv1alpha1.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, -) *tcloudv1alpha1.FairnessSpec { - if in == nil { - return nil - } - return &tcloudv1alpha1.FairnessSpec{ - TaskQueueFairnessEnabled: in.GetTaskQueueFairnessEnabled(), - } -} - -func encryptionValidationFromProto( - in *tcloudnamespacev1.EncryptionValidationSpec, -) *tcloudv1alpha1.EncryptionValidationSpec { - if in == nil { - return nil - } - return &tcloudv1alpha1.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]tcloudv1alpha1.SearchAttributeType { - if len(in) == 0 { - return nil - } - out := make(map[string]tcloudv1alpha1.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] = tcloudv1alpha1.SearchAttributeTypeText - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD: - out[name] = tcloudv1alpha1.SearchAttributeTypeKeyword - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT: - out[name] = tcloudv1alpha1.SearchAttributeTypeInt - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE: - out[name] = tcloudv1alpha1.SearchAttributeTypeDouble - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL: - out[name] = tcloudv1alpha1.SearchAttributeTypeBool - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME: - out[name] = tcloudv1alpha1.SearchAttributeTypeDatetime - case tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD_LIST: - out[name] = tcloudv1alpha1.SearchAttributeTypeKeywordList - default: - out[name] = tcloudv1alpha1.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, -) tcloudv1alpha1.EncryptionValidationMode { - switch in { - case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED: - return "" - case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED: - return tcloudv1alpha1.EncryptionValidationModeDisabled - case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN: - return tcloudv1alpha1.EncryptionValidationModeWarn - case tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY: - return tcloudv1alpha1.EncryptionValidationModeDeny - default: - return tcloudv1alpha1.EncryptionValidationMode(in.String()) - } -} - -// resourceStateFromProto converts a Temporal Cloud resource state to its CR -// name. Unspecified and unknown states map to "": the CR field is -// enum-validated, so an unrecognized value would fail the status patch. -func resourceStateFromProto( - in tcloudresourcev1.ResourceState, -) cloudcommon.ResourceState { - switch in { - case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATING: - return cloudcommon.ResourceStateActivating - case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVATION_FAILED: - return cloudcommon.ResourceStateActivationFailed - case tcloudresourcev1.ResourceState_RESOURCE_STATE_ACTIVE: - return cloudcommon.ResourceStateActive - case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATING: - return cloudcommon.ResourceStateUpdating - case tcloudresourcev1.ResourceState_RESOURCE_STATE_UPDATE_FAILED: - return cloudcommon.ResourceStateUpdateFailed - case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETING: - return cloudcommon.ResourceStateDeleting - case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETE_FAILED: - return cloudcommon.ResourceStateDeleteFailed - case tcloudresourcev1.ResourceState_RESOURCE_STATE_DELETED: - return cloudcommon.ResourceStateDeleted - case tcloudresourcev1.ResourceState_RESOURCE_STATE_SUSPENDED: - return cloudcommon.ResourceStateSuspended - case tcloudresourcev1.ResourceState_RESOURCE_STATE_EXPIRED: - return cloudcommon.ResourceStateExpired - default: - return "" - } -} - -func timeFromProto(ts *timestamppb.Timestamp) *metav1.Time { - if ts == nil { - return nil - } - t := metav1.NewTime(ts.AsTime()) - return &t -} - -// namespaceSpecToProto returns the Temporal Cloud NamespaceSpec that -// represents the supplied Namespace CR. The namespace name comes from the CR's -// metadata.name. Project and tags are not part of the Temporal Cloud -// NamespaceSpec and are sent separately. -func namespaceSpecToProto( - cr *tcloudv1alpha1.Namespace, -) *tcloudnamespacev1.NamespaceSpec { - spec := &cr.Spec - return &tcloudnamespacev1.NamespaceSpec{ - Name: cr.Name, - Replicas: replicasToProto(spec.Replicas), - RetentionDays: spec.RetentionDays, - MtlsAuth: mtlsAuthToProto(spec.MTLSAuth), - ApiKeyAuth: apiKeyAuthToProto(spec.APIKeyAuth), - SearchAttributes: searchAttributesToProto(spec.SearchAttributes), - CodecServer: codecServerToProto(spec.CodecServer), - Lifecycle: lifecycleToProto(spec.Lifecycle), - HighAvailability: highAvailabilityToProto(spec.HighAvailability), - ConnectivityRuleIds: slices.Clone(spec.ConnectivityRuleIDs), - CapacitySpec: capacityToProto(spec.Capacity), - Fairness: fairnessToProto(spec.Fairness), - EncryptionValidation: encryptionValidationToProto(spec.EncryptionValidation), - Description: spec.Description, - } -} - -func replicasToProto( - in []tcloudv1alpha1.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 *tcloudv1alpha1.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 *tcloudv1alpha1.APIKeyAuthSpec, -) *tcloudnamespacev1.ApiKeyAuthSpec { - if in == nil { - return nil - } - return &tcloudnamespacev1.ApiKeyAuthSpec{Enabled: in.Enabled} -} - -func codecServerToProto( - in *tcloudv1alpha1.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 *tcloudv1alpha1.HighAvailabilitySpec, -) *tcloudnamespacev1.HighAvailabilitySpec { - if in == nil { - return nil - } - return &tcloudnamespacev1.HighAvailabilitySpec{ - DisableManagedFailover: in.DisableManagedFailover, - DisablePassivePollerForwarding: in.DisablePassivePollerForwarding, - } -} - -func capacityToProto( - in *tcloudv1alpha1.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 *tcloudv1alpha1.FairnessSpec, -) *tcloudnamespacev1.FairnessSpec { - if in == nil { - return nil - } - return &tcloudnamespacev1.FairnessSpec{ - TaskQueueFairnessEnabled: in.TaskQueueFairnessEnabled, - } -} - -func encryptionValidationToProto( - in *tcloudv1alpha1.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]tcloudv1alpha1.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 tcloudv1alpha1.SearchAttributeTypeText: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_TEXT - case tcloudv1alpha1.SearchAttributeTypeKeyword: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD - case tcloudv1alpha1.SearchAttributeTypeInt: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_INT - case tcloudv1alpha1.SearchAttributeTypeDouble: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DOUBLE - case tcloudv1alpha1.SearchAttributeTypeBool: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_BOOL - case tcloudv1alpha1.SearchAttributeTypeDatetime: - out[name] = tcloudnamespacev1.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_DATETIME - case tcloudv1alpha1.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 tcloudv1alpha1.EncryptionValidationMode, -) tcloudnamespacev1.EncryptionValidationSpec_EncryptionValidationMode { - switch in { - case tcloudv1alpha1.EncryptionValidationModeDisabled: - return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DISABLED - case tcloudv1alpha1.EncryptionValidationModeWarn: - return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_WARN - case tcloudv1alpha1.EncryptionValidationModeDeny: - return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_DENY - default: - return tcloudnamespacev1.EncryptionValidationSpec_ENCRYPTION_VALIDATION_MODE_UNSPECIFIED - } -} diff --git a/controller/namespace/reconcile.go b/controller/namespace/reconcile.go index e7a4370..f6cdc27 100644 --- a/controller/namespace/reconcile.go +++ b/controller/namespace/reconcile.go @@ -113,22 +113,22 @@ func (r *Reconciler) ResourceSetLatest( // "pop" the underlying *tcloudv1alpha1.Namespace from the [types.Resource] // wrapper. This is a pointer so by modifying it, you mutate the supplied // `latest` parameter. - ns := latest.(*res).cr + cr := latest.(*res).cr var tns *tcloudnamespacev1.Namespace - if ns.Status.ID == "" { + 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, ns.Name) + 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: ns.Status.ID, + Namespace: cr.Status.ID, }) if status.Code(err) == codes.NotFound { // The namespace was deleted outside of the operator. Treat it @@ -144,28 +144,28 @@ func (r *Reconciler) ResourceSetLatest( if tns == nil { // The namespace does not exist in Temporal Cloud, so mutate latest // spec and status to be empty. - ns.Spec = tcloudv1alpha1.NamespaceSpec{} - ns.Status.ResourceStatus = cloudcommon.ResourceStatus{} + cr.Spec = tcloudv1alpha1.NamespaceSpec{} + cr.Status.ResourceStatus = cloudcommon.ResourceStatus{} return nil } - ns.Spec = namespaceSpecFromProto(tns) - ns.Status.ResourceStatus = resourceStatusFromProto(tns) - cr := latest.(*res).cr - _ = cr + cr.FromProto(tns) - // 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() + // 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 } @@ -214,10 +214,11 @@ func syncNamespace( latest *tcloudv1alpha1.Namespace, ) error { if latest.Status.ID == "" { + tns := desired.ToProto() resp, err := tcc.CreateNamespace(ctx, &tcloudservicev1.CreateNamespaceRequest{ - Spec: namespaceSpecToProto(desired), - Tags: desired.Spec.Tags, - ProjectId: desired.Spec.Project, + Spec: tns.GetSpec(), + Tags: tns.GetTags(), + ProjectId: tns.GetProjectId(), }) if err != nil { return cloudCallError("creating tcloud namespace", err) @@ -228,18 +229,39 @@ func syncNamespace( ) } + // 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.ResourceStateActivationFailed, cloudcommon.ResourceStateUpdateFailed: + 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 @@ -262,7 +284,7 @@ func syncNamespace( ) { _, err := tcc.UpdateNamespace(ctx, &tcloudservicev1.UpdateNamespaceRequest{ Namespace: id, - Spec: namespaceSpecToProto(desired), + Spec: desired.ToProto().GetSpec(), ResourceVersion: latest.Status.ResourceVersion, }) if err != nil { @@ -380,10 +402,13 @@ func tagsDiff( // 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") @@ -391,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_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 362f0ec..c99afb5 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +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.4.0 - go.temporal.io/cloud-sdk v0.20.0 - google.golang.org/grpc v1.84.0 - google.golang.org/protobuf v1.36.12 - 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 @@ -90,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 d9cb424..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= @@ -162,8 +162,6 @@ go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6Tb go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.temporal.io/api v1.63.5 h1:c11+kPYHkXXL3UiShPdbMD+xtvqGsbTibUA9ypmiCa4= go.temporal.io/api v1.63.5/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= -go.temporal.io/cloud-sdk v0.20.0 h1:H66BX7e9acBLn1g9zHhXu9FB1fI9t4zIEb6to08iA6c= -go.temporal.io/cloud-sdk v0.20.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= go.temporal.io/cloud-sdk v0.21.0 h1:netTthDj6WbnTIM0l0OJw3IIQCkBVuxAgOAdwUX8ntM= go.temporal.io/cloud-sdk v0.21.0/go.mod h1:W2O9t9tvo3Q/LhGgYdj8JijWbN5C84os+cz/BadIHYI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 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