From aa260794b655e83717397b3b24d2ac43383598c4 Mon Sep 17 00:00:00 2001 From: Jay Pipes Date: Sun, 25 Oct 2026 12:00:06 -0400 Subject: [PATCH 1/2] introduce a fake Temporal Cloud server Adds functionality for starting a fake Temporal Cloud server for use in unit testing. Updates the base reconciler and derived reconciler for namespace controller to use newly refactored `kube-temporal/pkg/testutil` library and `kube-temporal/pkg/types.Resource` interfaces from kube-temporal@v0.5.0 Signed-off-by: Jay Pipes --- controller/namespace/reconcile.go | 19 +-- controller/namespace/reconcile_test.go | 132 ++++++++++++++---- controller/namespace/resource.go | 17 +++ go.mod | 14 +- go.sum | 8 +- pkg/reconciler/base/reconciler.go | 12 +- pkg/reconciler/base/reconciler_test.go | 11 +- pkg/testutil/{ => kube}/kube.go | 10 +- .../temporal/cloud/fake/server/namespace.go | 68 +++++++++ .../temporal/cloud/fake/server/server.go | 67 +++++++++ 10 files changed, 292 insertions(+), 66 deletions(-) rename pkg/testutil/{ => kube}/kube.go (70%) create mode 100644 pkg/testutil/temporal/cloud/fake/server/namespace.go create mode 100644 pkg/testutil/temporal/cloud/fake/server/server.go diff --git a/controller/namespace/reconcile.go b/controller/namespace/reconcile.go index f3d6a6b..66fd25f 100644 --- a/controller/namespace/reconcile.go +++ b/controller/namespace/reconcile.go @@ -6,6 +6,7 @@ import ( ctrlreqlog "github.com/temporalio/kube-temporal/pkg/controller/request/log" "github.com/temporalio/kube-temporal/pkg/types" + tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" tcclient "go.temporal.io/cloud-sdk/cloudclient" ctrlrt "sigs.k8s.io/controller-runtime" kclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -105,19 +106,19 @@ 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 - _ = ns + 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. - // - // latestObserved, err := tcc.CloudService().GetNamespace(...) - // if err != nil { - // return fmt.Errorf("failed getting tcloud namespace: %w", err) - // } - // ns.Spec.ProjectID = latestObserved.ProjectId - // ... + req := tcloudservicev1.GetNamespaceRequest{} + 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 } diff --git a/controller/namespace/reconcile_test.go b/controller/namespace/reconcile_test.go index e2ad38a..e216568 100644 --- a/controller/namespace/reconcile_test.go +++ b/controller/namespace/reconcile_test.go @@ -2,20 +2,25 @@ package namespace_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" "github.com/temporalio/cloud-operator/controller/namespace" - "github.com/temporalio/cloud-operator/pkg/testutil" - kttestutil "github.com/temporalio/kube-temporal/pkg/testutil" + "github.com/temporalio/cloud-operator/pkg/testutil/kube" + fakeserver "github.com/temporalio/cloud-operator/pkg/testutil/temporal/cloud/fake/server" + kttestutilcompare "github.com/temporalio/kube-temporal/pkg/testutil/compare" + kttestutilkube "github.com/temporalio/kube-temporal/pkg/testutil/kube" "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" ctrlrt "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) const ( - kubeNamespace = "kube-temporal" + kubeNamespace = "temporal-cloud-operator" ) func TestReconciler_ResourceFromRequest(t *testing.T) { @@ -41,17 +46,17 @@ func TestReconciler_ResourceFromRequest(t *testing.T) { { name: "fail if resource not found", client: func() client.Client { - return testutil.NewFakeClient() + return kube.NewFakeClient() }(), - req: kttestutil.NewResourceRequest(tnsName, kubeNamespace), - expErr: "namespaces.cloud.temporal.io \"kube-temporal\" not found", + req: kttestutilkube.NewResourceRequest(kubeNamespace, tnsName), + expErr: "namespaces.cloud.temporal.io \"tmprl-ns\" not found", }, { name: "yields resource", client: func() client.Client { - return testutil.NewFakeClient(tns) + return kube.NewFakeClient(tns) }(), - req: kttestutil.NewResourceRequest(kubeNamespace, tnsName), + req: kttestutilkube.NewResourceRequest(kubeNamespace, tnsName), exp: namespace.NewResource(tns), }, } @@ -74,7 +79,7 @@ func TestReconciler_ResourceFromRequest(t *testing.T) { require.Nil(err) if tc.exp != nil { - require.EqualExportedValues(tc.exp, got) + require.Equal(tc.exp, got) } }) } @@ -82,49 +87,126 @@ func TestReconciler_ResourceFromRequest(t *testing.T) { func TestReconciler_ResourceSetLatest(t *testing.T) { tnsName := "tmprl-ns" + project := "tmprl-project" + projectOther := "tmprl-project-other" tns := &tcloudv1alpha1.Namespace{} tns.Name = tnsName tns.Namespace = kubeNamespace - - tcc, err := testutil.NewFakeCloudClient() - require.Nil(t, err) + tns.Spec.Project = project cases := []struct { - name string - r *namespace.Reconciler - res types.Resource - expErr string + name string + r *namespace.Reconciler + reconOpts []namespace.NewReconcilerOption + serverOpts []fakeserver.NewOption + getNamespace fakeserver.GetNamespaceFunc + res types.Resource + latest types.Resource + expErr string }{ { name: "fail nil cloud connection manager or default client", - r: namespace.NewReconciler(), - res: namespace.NewResource(tns), expErr: "connection manager nil and no default client set", + r: namespace.NewReconciler(), // imitate no conn mgr + res: namespace.NewResource(tns), }, { - name: "mutates latest resource", - r: namespace.NewReconciler( - namespace.WithKubeClient( - testutil.NewFakeClient(tns), + name: "fail if tcloud get namespace errors", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespaceReturn( + nil, fmt.Errorf("Indy! Bad dates!"), ), - namespace.WithDefaultCloudClient(*tcc), - ), + }, + res: namespace.NewResource(tns), + expErr: "failed getting tcloud namespace: rpc error: code = Unknown desc = Indy! Bad dates!", + }, + { + name: "latest spec is mutated", + serverOpts: []fakeserver.NewOption{ + fakeserver.OnGetNamespaceReturn( + &tcloudservicev1.GetNamespaceResponse{ + Namespace: &tcloudnamespacev1.Namespace{ + Spec: &tcloudnamespacev1.NamespaceSpec{ + Name: tnsName, + }, + ProjectId: projectOther, + }, + }, 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) + }(), }, } for _, tc := range cases { t.Run(tc.name, func(tt *testing.T) { require := require.New(tt) + sopts := tc.serverOpts + if tc.getNamespace != nil { + sopts = append( + sopts, fakeserver.WithGetNamespace(tc.getNamespace), + ) + } + + srv, err := fakeserver.New(sopts...) + require.Nil(err) + stop := srv.Start() + tt.Cleanup(stop) + + tcc, err := srv.Client() + require.Nil(err) + + r := tc.r + if r == nil { + ropts := []namespace.NewReconcilerOption{ + namespace.WithDefaultCloudClient(*tcc), + namespace.WithKubeClient( + kube.NewFakeClient(tns), + ), + } + ropts = append(ropts, tc.reconOpts...) + + r = namespace.NewReconciler(ropts...) + } + ctx := context.TODO() - err := tc.r.ResourceSetLatest(ctx, tc.res) + // We expect the types.Resource supplied to ResourceSetLatest to be + // mutated, so make a deep copy so we can track any changes made. + latest := tc.res.DeepCopy() + + err = r.ResourceSetLatest(ctx, latest) if tc.expErr != "" { require.ErrorContains(err, tc.expErr) return } require.Nil(err) + if tc.latest != nil { + require.True( + kttestutilcompare.EqualIgnoringFields( + tc.latest, latest, + kttestutilkube.ResourceIgnoredFields..., + ), + kttestutilcompare.DiffIgnoringFields( + tc.latest, latest, + kttestutilkube.ResourceIgnoredFields..., + ), + ) + } }) } } diff --git a/controller/namespace/resource.go b/controller/namespace/resource.go index 2634287..3d086ee 100644 --- a/controller/namespace/resource.go +++ b/controller/namespace/resource.go @@ -3,6 +3,7 @@ package namespace import ( "log/slog" + "github.com/temporalio/kube-temporal/pkg/kube/annotation" "github.com/temporalio/kube-temporal/pkg/kube/transform" "github.com/temporalio/kube-temporal/pkg/resource" "github.com/temporalio/kube-temporal/pkg/types" @@ -22,6 +23,11 @@ type res struct { cr *tcloudv1alpha1.Namespace } +// CR returns the underlying custom resource. +func (r res) CR() any { + return r.cr +} + // IsBeingDeleted returns true if the Kubernetes resource has a non-zero // deletion timestamp func (r res) IsBeingDeleted() bool { @@ -58,6 +64,17 @@ func (r *res) ReplaceConditions(conditions []*metav1.Condition) { r.cr.Status.Conditions = conditions } +// Annotations returns the set of Annotations. +func (r *res) Annotations() annotation.Annotations { + return r.cr.ObjectMeta.Annotations +} + +// ReplaceAnnotations replaces the resource's set of Annotations with +// the supplied Annotations. +func (r *res) ReplaceAnnotations(annos annotation.Annotations) { + r.cr.ObjectMeta.Annotations = annos +} + // DeepCopy returns a copy of the resource func (r res) DeepCopy() types.Resource { crCopy := r.cr.DeepCopy() diff --git a/go.mod b/go.mod index c09ef66..eebb2e0 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,9 @@ 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.7.1 + github.com/temporalio/kube-temporal v0.5.0 + go.temporal.io/cloud-sdk v0.21.0 + google.golang.org/grpc v1.84.0 k8s.io/api v0.37.1 k8s.io/apimachinery v0.37.1 k8s.io/client-go v0.37.1 @@ -17,7 +18,6 @@ require ( require ( cel.dev/expr v0.25.2 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -25,7 +25,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.10.0 // indirect @@ -45,9 +44,10 @@ require ( github.com/go-openapi/swag/stringutils v0.29.2 // indirect github.com/go-openapi/swag/typeutils v0.29.2 // indirect github.com/go-openapi/swag/yamlutils v0.29.2 // indirect - github.com/go-openapi/testify/v2 v2.8.0 // indirect github.com/google/cel-go v0.29.2 // indirect github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect @@ -56,8 +56,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.33.0 // indirect - github.com/onsi/gomega v1.44.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.25.0 // indirect github.com/prometheus/client_model v0.6.3 // indirect @@ -75,7 +73,6 @@ require ( go.opentelemetry.io/otel/trace v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.temporal.io/api v1.63.5 // indirect - go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect @@ -89,7 +86,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 diff --git a/go.sum b/go.sum index aa236a3..37cee79 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.4.0 h1:dvRaPoV6brzViW1QYG+1mPt5X/RCTTFn+ZjDgfpVZPs= -github.com/temporalio/kube-temporal v0.4.0/go.mod h1:QR80csThD/3jzUo2NS5wRoHReCQfEwTXUJiVEPP8Pbg= +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/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,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.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= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/pkg/reconciler/base/reconciler.go b/pkg/reconciler/base/reconciler.go index dc65974..9948fa3 100644 --- a/pkg/reconciler/base/reconciler.go +++ b/pkg/reconciler/base/reconciler.go @@ -5,11 +5,11 @@ import ( "fmt" ctrlreqlog "github.com/temporalio/kube-temporal/pkg/controller/request/log" + "github.com/temporalio/kube-temporal/pkg/kube/annotation" ktbase "github.com/temporalio/kube-temporal/pkg/reconciler/base" "github.com/temporalio/kube-temporal/pkg/types" tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" tcclient "go.temporal.io/cloud-sdk/cloudclient" - "k8s.io/apimachinery/pkg/api/meta" kclient "sigs.k8s.io/controller-runtime/pkg/client" tcloudcommon "github.com/temporalio/cloud-operator/api/cloud/common" @@ -161,13 +161,9 @@ func (r *Reconciler) CloudClientFor( case string, *tcloudv1alpha1.ConnectionSpec: cli, err = r.ccManager.Client(subject) case types.Resource: - ro := subject.RuntimeObject() - metaAccessor := meta.NewAccessor() - annos, err := metaAccessor.Annotations(ro) - if err != nil { - return nil, fmt.Errorf("failed getting resource annotations: %w", err) - } - hp, ok := annos[tcloudcommon.AnnotationKeyTemporalCloudHostPort] + hp, ok := annotation.Value( + subject, tcloudcommon.AnnotationKeyTemporalCloudHostPort, + ) if !ok { return nil, fmt.Errorf( "no %q annotation on resource", diff --git a/pkg/reconciler/base/reconciler_test.go b/pkg/reconciler/base/reconciler_test.go index d9756bd..19e76d3 100644 --- a/pkg/reconciler/base/reconciler_test.go +++ b/pkg/reconciler/base/reconciler_test.go @@ -13,6 +13,7 @@ import ( "github.com/temporalio/cloud-operator/pkg/reconciler/base" conmgr "github.com/temporalio/cloud-operator/pkg/temporal/cloud/connection/manager" "github.com/temporalio/cloud-operator/pkg/testutil" + "github.com/temporalio/cloud-operator/pkg/testutil/kube" tcorev1 "github.com/temporalio/kube-temporal/api/core/v1" tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -70,7 +71,7 @@ func TestReconciler_CloudClientFor(t *testing.T) { name: "fail if subject not resource, string or conn spec", r: base.New( base.WithKubeClient( - testutil.NewFakeClient(tns()), + kube.NewFakeClient(tns()), ), ), subject: int64(1), @@ -100,7 +101,7 @@ func TestReconciler_CloudClientFor(t *testing.T) { r: func() *base.Reconciler { b := base.New( base.WithKubeClient( - testutil.NewFakeClient(tns()), + kube.NewFakeClient(tns()), ), ) ccm := b.CloudConnectionManager() @@ -132,7 +133,7 @@ func TestReconciler_CloudClientFor(t *testing.T) { r: func() *base.Reconciler { b := base.New( base.WithKubeClient( - testutil.NewFakeClient(tns()), + kube.NewFakeClient(tns()), ), ) ccm := b.CloudConnectionManager() @@ -150,7 +151,7 @@ func TestReconciler_CloudClientFor(t *testing.T) { r: func() *base.Reconciler { b := base.New( base.WithKubeClient( - testutil.NewFakeClient(tns()), + kube.NewFakeClient(tns()), ), ) ccm := b.CloudConnectionManager() @@ -168,7 +169,7 @@ func TestReconciler_CloudClientFor(t *testing.T) { r: func() *base.Reconciler { b := base.New( base.WithKubeClient( - testutil.NewFakeClient(tns()), + kube.NewFakeClient(tns()), ), ) ccm := b.CloudConnectionManager() diff --git a/pkg/testutil/kube.go b/pkg/testutil/kube/kube.go similarity index 70% rename from pkg/testutil/kube.go rename to pkg/testutil/kube/kube.go index 5880f75..cf64f3d 100644 --- a/pkg/testutil/kube.go +++ b/pkg/testutil/kube/kube.go @@ -1,11 +1,10 @@ -package testutil +package kube import ( tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" - corev1 "k8s.io/api/core/v1" + kttestutilkube "github.com/temporalio/kube-temporal/pkg/testutil/kube" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - kcrfake "sigs.k8s.io/controller-runtime/pkg/client/fake" ) var ( @@ -13,15 +12,14 @@ var ( ) func init() { - _ = corev1.AddToScheme(scheme) _ = tcloudv1alpha1.AddToScheme(scheme) } // NewFakeClient returns a controller-runtime Client populated with the // supplied objects. func NewFakeClient(objs ...runtime.Object) client.Client { - return kcrfake.NewClientBuilder(). - WithScheme(scheme). + builder := kttestutilkube.NewFakeClientBuilder() + return builder.WithScheme(scheme). WithRuntimeObjects(objs...). Build() } diff --git a/pkg/testutil/temporal/cloud/fake/server/namespace.go b/pkg/testutil/temporal/cloud/fake/server/namespace.go new file mode 100644 index 0000000..8e7d069 --- /dev/null +++ b/pkg/testutil/temporal/cloud/fake/server/namespace.go @@ -0,0 +1,68 @@ +package server + +import ( + "context" + + tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" +) + +type GetNamespaceFunc func( + context.Context, + *tcloudservicev1.GetNamespaceRequest, +) (*tcloudservicev1.GetNamespaceResponse, error) + +// WithGetNamespace sets the fake server's GetNamespace function override. +func WithGetNamespace(fn GetNamespaceFunc) NewOption { + return func(s *fakeServer) { + s.getNamespaceFunc = fn + } +} + +// OnGetNamespace allows the test author to supply a function that checks +// arguments passed to the GetNamespace method are expected as well as set a +// return value from GetNamespace. +func OnGetNamespace( + asserter func(*tcloudservicev1.GetNamespaceRequest), + returner func() (*tcloudservicev1.GetNamespaceResponse, error), +) NewOption { + return func(s *fakeServer) { + fn := func( + ctx context.Context, req *tcloudservicev1.GetNamespaceRequest, + ) (*tcloudservicev1.GetNamespaceResponse, error) { + if asserter != nil { + asserter(req) + } + if returner == nil { + return nil, nil + } + return returner() + } + s.getNamespaceFunc = fn + } +} + +// OnGetNamespaceReturn allows the test author to set the returned value from +// the GetNamespace method. +func OnGetNamespaceReturn( + resp *tcloudservicev1.GetNamespaceResponse, + err error, +) NewOption { + return func(s *fakeServer) { + fn := func( + context.Context, *tcloudservicev1.GetNamespaceRequest, + ) (*tcloudservicev1.GetNamespaceResponse, error) { + return resp, err + } + s.getNamespaceFunc = fn + } +} + +func (s fakeServer) GetNamespace( + ctx context.Context, + req *tcloudservicev1.GetNamespaceRequest, +) (*tcloudservicev1.GetNamespaceResponse, error) { + if s.getNamespaceFunc != nil { + return s.getNamespaceFunc(ctx, req) + } + return s.UnimplementedCloudServiceServer.GetNamespace(ctx, req) +} diff --git a/pkg/testutil/temporal/cloud/fake/server/server.go b/pkg/testutil/temporal/cloud/fake/server/server.go new file mode 100644 index 0000000..cc57d0f --- /dev/null +++ b/pkg/testutil/temporal/cloud/fake/server/server.go @@ -0,0 +1,67 @@ +package server + +import ( + "net" + + "github.com/temporalio/cloud-operator/pkg/testutil" + tcloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" + tcclient "go.temporal.io/cloud-sdk/cloudclient" + "google.golang.org/grpc" +) + +type fakeServer struct { + grpcServer *grpc.Server + lis net.Listener + tcloudservicev1.UnimplementedCloudServiceServer + + getNamespaceFunc GetNamespaceFunc +} + +// HostPort returns the address of the TCP localhost port that the fake server +// is listening on. +func (s fakeServer) HostPort() string { + return s.lis.Addr().String() +} + +// Client returns a Temporal Cloud client.Client object set up to connect to +// the fake Temporal Cloud server. +func (s fakeServer) Client() (*tcclient.Client, error) { + opts := testutil.NewCloudClientOptions() + opts.AllowInsecure = true + opts.HostPort = s.HostPort() + opts.APIKey = testutil.FakeTemporalCloudAPIKeyContents + return tcclient.New(*opts) +} + +// Start starts the gRPC server in a new goroutine and returns a stopper +// function. +func (s *fakeServer) Start() func() { + if s.grpcServer == nil { + panic("called Start on nil fakeServer") + } + go s.grpcServer.Serve(s.lis) + return s.grpcServer.Stop +} + +type NewOption func(*fakeServer) + +// New returns a fully functional gRPC server that handles requests to the +// Temporal Cloud API. +func New(opts ...NewOption) (*fakeServer, error) { + s := fakeServer{} + for _, opt := range opts { + opt(&s) + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, err + } + + s.grpcServer = grpc.NewServer() + s.lis = lis + + tcloudservicev1.RegisterCloudServiceServer(s.grpcServer, &s) + + return &s, nil +} From 537121a8933133f174635701b5dcc397c965203b Mon Sep 17 00:00:00 2001 From: Jay Pipes Date: Mon, 26 Oct 2026 20:02:08 -0400 Subject: [PATCH 2/2] Add separate GetNamespace assertion helper Signed-off-by: Jay Pipes --- controller/namespace/reconcile.go | 4 ++- controller/namespace/reconcile_test.go | 33 ++++++++++++------- .../temporal/cloud/fake/server/namespace.go | 21 +++++------- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/controller/namespace/reconcile.go b/controller/namespace/reconcile.go index 66fd25f..49b3fe6 100644 --- a/controller/namespace/reconcile.go +++ b/controller/namespace/reconcile.go @@ -112,7 +112,9 @@ func (r *Reconciler) ResourceSetLatest( // 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{} + req := tcloudservicev1.GetNamespaceRequest{ + Namespace: cr.Name, + } resp, err := tcc.GetNamespace(ctx, &req) if err != nil { return fmt.Errorf("failed getting tcloud namespace: %w", err) diff --git a/controller/namespace/reconcile_test.go b/controller/namespace/reconcile_test.go index e216568..863a49d 100644 --- a/controller/namespace/reconcile_test.go +++ b/controller/namespace/reconcile_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" tcloudv1alpha1 "github.com/temporalio/cloud-operator/api/cloud/v1alpha1" "github.com/temporalio/cloud-operator/controller/namespace" @@ -95,14 +96,13 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { tns.Spec.Project = project cases := []struct { - name string - r *namespace.Reconciler - reconOpts []namespace.NewReconcilerOption - serverOpts []fakeserver.NewOption - getNamespace fakeserver.GetNamespaceFunc - res types.Resource - latest types.Resource - expErr string + name string + r *namespace.Reconciler + serverOpts []fakeserver.NewOption + asserter func(*testing.T) fakeserver.GetNamespaceAssertFunc + res types.Resource + latest types.Resource + expErr string }{ { name: "fail nil cloud connection manager or default client", @@ -120,6 +120,16 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { res: namespace.NewResource(tns), expErr: "failed getting tcloud namespace: rpc error: code = Unknown desc = Indy! Bad dates!", }, + { + name: "fail if get namespace request empty", + asserter: func(tt *testing.T) fakeserver.GetNamespaceAssertFunc { + assert := assert.New(tt) + return func(req *tcloudservicev1.GetNamespaceRequest) { + assert.NotEmpty(req.GetNamespace()) + } + }, + res: namespace.NewResource(tns), + }, { name: "latest spec is mutated", serverOpts: []fakeserver.NewOption{ @@ -155,9 +165,11 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { require := require.New(tt) sopts := tc.serverOpts - if tc.getNamespace != nil { + + if tc.asserter != nil { sopts = append( - sopts, fakeserver.WithGetNamespace(tc.getNamespace), + sopts, + fakeserver.OnGetNamespaceAssert(tc.asserter(tt)), ) } @@ -177,7 +189,6 @@ func TestReconciler_ResourceSetLatest(t *testing.T) { kube.NewFakeClient(tns), ), } - ropts = append(ropts, tc.reconOpts...) r = namespace.NewReconciler(ropts...) } diff --git a/pkg/testutil/temporal/cloud/fake/server/namespace.go b/pkg/testutil/temporal/cloud/fake/server/namespace.go index 8e7d069..95e126a 100644 --- a/pkg/testutil/temporal/cloud/fake/server/namespace.go +++ b/pkg/testutil/temporal/cloud/fake/server/namespace.go @@ -11,6 +11,8 @@ type GetNamespaceFunc func( *tcloudservicev1.GetNamespaceRequest, ) (*tcloudservicev1.GetNamespaceResponse, error) +type GetNamespaceAssertFunc func(*tcloudservicev1.GetNamespaceRequest) + // WithGetNamespace sets the fake server's GetNamespace function override. func WithGetNamespace(fn GetNamespaceFunc) NewOption { return func(s *fakeServer) { @@ -18,24 +20,17 @@ func WithGetNamespace(fn GetNamespaceFunc) NewOption { } } -// OnGetNamespace allows the test author to supply a function that checks -// arguments passed to the GetNamespace method are expected as well as set a -// return value from GetNamespace. -func OnGetNamespace( - asserter func(*tcloudservicev1.GetNamespaceRequest), - returner func() (*tcloudservicev1.GetNamespaceResponse, error), +// OnGetNamespaceAssert allows the test author to supply a function that checks +// arguments passed to the GetNamespace method are expected. +func OnGetNamespaceAssert( + asserter GetNamespaceAssertFunc, ) NewOption { return func(s *fakeServer) { fn := func( ctx context.Context, req *tcloudservicev1.GetNamespaceRequest, ) (*tcloudservicev1.GetNamespaceResponse, error) { - if asserter != nil { - asserter(req) - } - if returner == nil { - return nil, nil - } - return returner() + asserter(req) + return nil, nil } s.getNamespaceFunc = fn }