diff --git a/.gitattributes b/.gitattributes index 62001b63..5bb92fc3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,4 @@ manifest-bundle.yaml linguist-generated=true helm/ngrok-operator/values.schema.json linguist-generated=true # Generated by mockgen -internal/mocks/conn.go linguist-generated=true -internal/mocks/dialer.go linguist-generated=true -internal/mocks/tunnel.go linguist-generated=true +internal/mocks/mock_*.go linguist-generated=true diff --git a/api/ingress/v1alpha1/domain_types.go b/api/ingress/v1alpha1/domain_types.go index d642d85c..398af34d 100644 --- a/api/ingress/v1alpha1/domain_types.go +++ b/api/ingress/v1alpha1/domain_types.go @@ -29,6 +29,7 @@ import ( "github.com/ngrok/ngrok-api-go/v7" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! @@ -126,7 +127,7 @@ func (d *Domain) Equal(ngrokDomain *ngrok.ReservedDomain) bool { d.Status.Region == ngrokDomain.Region && d.Status.Domain == ngrokDomain.Domain && d.Status.URI == ngrokDomain.URI && - d.Status.CNAMETarget == ngrokDomain.CNAMETarget && + ptr.Equal(d.Status.CNAMETarget, ngrokDomain.CNAMETarget) && d.Spec.Description == ngrokDomain.Description && d.Spec.Metadata == ngrokDomain.Metadata } diff --git a/go.mod b/go.mod index a0d3f52f..18db319d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/ngrok/ngrok-api-go/v7 v7.3.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.36.3 + github.com/segmentio/ksuid v1.0.4 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 go.uber.org/mock v0.5.2 @@ -112,6 +113,7 @@ require ( ) tool ( + github.com/onsi/ginkgo/v2/ginkgo go.uber.org/mock/mockgen sigs.k8s.io/controller-runtime/tools/setup-envtest sigs.k8s.io/controller-tools/cmd/controller-gen diff --git a/go.sum b/go.sum index 6853309b..36923845 100644 --- a/go.sum +++ b/go.sum @@ -475,6 +475,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= diff --git a/internal/controller/ingress/domain_controller_test.go b/internal/controller/ingress/domain_controller_test.go new file mode 100644 index 00000000..7e69a948 --- /dev/null +++ b/internal/controller/ingress/domain_controller_test.go @@ -0,0 +1,399 @@ +package ingress + +import ( + "context" + "fmt" + "time" + + "github.com/ngrok/ngrok-api-go/v7" + ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1" + "github.com/ngrok/ngrok-operator/internal/controller" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/rand" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("DomainReconciler", func() { + const ( + timeout = 10 * time.Second + duration = 10 * time.Second + interval = 250 * time.Millisecond + NgrokManagedDomainSuffix = "ngrok.app" + CustomDomainSuffix = "custom-domain.xyz" + ) + + var ( + ctx context.Context + namespace string = "default" + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + + AfterEach(func() { + // List all domain CRs in the env test cluster + domains := &ingressv1alpha1.DomainList{} + err := k8sClient.List(ctx, domains) + Expect(err).ToNot(HaveOccurred()) + // Delete all domain CRs in the env test cluster + for _, d := range domains.Items { + Expect(k8sClient.Delete(ctx, &d)).To(Succeed()) + } + + // Eventually, listing all the domain CRs should return an empty list because we + // deleted all of them and the finalizer should have cleaned up the ngrok domains. + Eventually(func(g Gomega) { + domains := &ingressv1alpha1.DomainList{} + err := k8sClient.List(ctx, domains) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(domains.Items).To(BeEmpty()) + }, timeout, interval).Should(Succeed()) + + // Reset the internal state of the domain client between tests + // to ensure that each test starts with a clean slate. + domainClient.Reset() + }) + + Describe("CreateDomain", func() { + var ( + createDomainErr error + domainSuffix string + domainName string + domain *ingressv1alpha1.Domain + ) + + JustBeforeEach(func() { + domain = &ingressv1alpha1.Domain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-domain", + Namespace: namespace, + }, + Spec: ingressv1alpha1.DomainSpec{ + Domain: domainName, + }, + } + createDomainErr = k8sClient.Create(ctx, domain) + }) + + When("the domain is a ngrok managed domain", func() { + BeforeEach(func() { + domainSuffix = NgrokManagedDomainSuffix + domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix) + }) + + When("the domain does not exist in ngrok", func() { + It("should create the domain in ngrok", func() { + Expect(createDomainErr).ToNot(HaveOccurred()) + + Eventually(func(g Gomega) { + foundDomain := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(foundDomain.Status.ID).To(MatchRegexp("^rd")) + g.Expect(foundDomain.Status.Domain).To(Equal(domainName)) + g.Expect(foundDomain.Status.CNAMETarget).To(BeNil()) + }, timeout, interval).Should(Succeed()) + }) + }) + + When("the domain already exists in ngrok", func() { + var ( + existingDomain *ngrok.ReservedDomain + preCreateDomainErr error + ) + BeforeEach(func() { + existingDomain, preCreateDomainErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName}) + Expect(preCreateDomainErr).ToNot(HaveOccurred()) + }) + + It("should use the existing domain in ngrok", func() { + Expect(createDomainErr).ToNot(HaveOccurred()) + + Eventually(func(g Gomega) { + foundDomain := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(foundDomain.Status.ID).To(Equal(existingDomain.ID)) + g.Expect(foundDomain.Status.Domain).To(Equal(domainName)) + g.Expect(foundDomain.Status.CNAMETarget).To(BeNil()) + }, timeout, interval).Should(Succeed()) + }) + }) + }) + + When("the domain is a custom domain", func() { + BeforeEach(func() { + domainSuffix = CustomDomainSuffix + domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix) + }) + + When("the domain does not exist in ngrok", func() { + It("should create the domain in ngrok", func() { + Expect(createDomainErr).ToNot(HaveOccurred()) + + Eventually(func(g Gomega) { + foundDomain := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(foundDomain.Status.ID).To(MatchRegexp("^rd")) + g.Expect(foundDomain.Status.Domain).To(Equal(domainName)) + g.Expect(foundDomain.Status.CNAMETarget).ToNot(BeNil()) + g.Expect(*foundDomain.Status.CNAMETarget).To(MatchRegexp("\\.ngrok-cname\\.com$")) + }, timeout, interval).Should(Succeed()) + }) + }) + + When("the domain already exists in ngrok", func() { + var ( + existingDomain *ngrok.ReservedDomain + preCreateDomainErr error + ) + + BeforeEach(func() { + existingDomain, preCreateDomainErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName}) + Expect(preCreateDomainErr).ToNot(HaveOccurred()) + + GinkgoLogr.Info("Pre-created domain", "domain", existingDomain.Domain, "id", existingDomain.ID) + }) + + It("should use the existing domain in ngrok", func() { + Expect(createDomainErr).ToNot(HaveOccurred()) + + Eventually(func(g Gomega) { + foundDomain := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(foundDomain.Status.ID).To(Equal(existingDomain.ID)) + g.Expect(foundDomain.Status.Domain).To(Equal(domainName)) + g.Expect(foundDomain.Status.CNAMETarget).ToNot(BeNil()) + }, timeout, interval).Should(Succeed()) + }) + }) + }) + }) + + Describe("UpdateDomain", func() { + var ( + domainName string + domain *ingressv1alpha1.Domain + objKey client.ObjectKey + ) + + BeforeEach(func() { + name := fmt.Sprintf("test-domain-%s", rand.String(10)) + domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), NgrokManagedDomainSuffix) + objKey = client.ObjectKey{ + Name: name, + Namespace: namespace, + } + domain = &ingressv1alpha1.Domain{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: ingressv1alpha1.DomainSpec{ + Domain: domainName, + }, + } + domain.Spec.Metadata = "starting metadata" + domain.Spec.Description = "starting description" + + Expect(k8sClient.Create(ctx, domain)).To(Succeed()) + }) + + It("updates the domain metadata", func() { + patch := client.MergeFrom(domain.DeepCopy()) + domain.Spec.Metadata = "updated metadata" + Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed()) + + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, objKey, d) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(d.Spec.Metadata).To(Equal("updated metadata")) + g.Expect(d.Status.ID).ToNot(BeEmpty()) + g.Expect(d.Status.Domain).To(Equal(domainName)) + }, timeout, interval).Should(Succeed()) + }) + + It("updates the domain description", func() { + patch := client.MergeFrom(domain.DeepCopy()) + domain.Spec.Description = "updated description" + Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed()) + + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, objKey, d) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(d.Spec.Description).To(Equal("updated description")) + g.Expect(d.Status.ID).ToNot(BeEmpty()) + g.Expect(d.Status.Domain).To(Equal(domainName)) + }, timeout, interval).Should(Succeed()) + }) + + When("the domain was manually deleted in ngrok", func() { + var ( + previousID string + ) + BeforeEach(func() { + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, objKey, d) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(d.Status.ID).ToNot(BeEmpty()) + g.Expect(d.Status.Domain).To(Equal(domainName)) + + previousID = d.Status.ID + g.Expect(domainClient.Delete(ctx, previousID)).To(Succeed()) + }) + }) + + It("should create a new domain in ngrok", func() { + // Simulate a manual reconcile by adding an annotation + patch := client.MergeFrom(domain.DeepCopy()) + controller.AddAnnotations(domain, map[string]string{ + "manual-reconcile": "true", + }) + Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed()) + + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, objKey, d) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(d.Status.ID).ToNot(BeEmpty()) + g.Expect(d.Status.ID).ToNot(Equal(previousID)) + g.Expect(d.Status.Domain).To(Equal(domainName)) + g.Expect(d.Status.CNAMETarget).To(BeNil()) + }, timeout, interval).Should(Succeed()) + }) + }) + }) + + Describe("DeleteDomain", func() { + var ( + reclaimPolicy ingressv1alpha1.DomainReclaimPolicy + domain *ingressv1alpha1.Domain + + ngrokDomainID string + deleteDomainInNgrokBeforeK8s bool + ) + + JustBeforeEach(func() { + By("Creating the domain") + domain = &ingressv1alpha1.Domain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-domain", + Namespace: namespace, + }, + Spec: ingressv1alpha1.DomainSpec{ + ReclaimPolicy: reclaimPolicy, + Domain: fmt.Sprintf("test-domain-%s.%s", rand.String(10), NgrokManagedDomainSuffix), + }, + } + Expect(k8sClient.Create(ctx, domain)).To(Succeed()) + + By("Waiting for the domain to be created in ngrok") + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), d) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(d.Status.ID).ToNot(BeEmpty()) + g.Expect(d.Status.Domain).To(Equal(domain.Spec.Domain)) + + ngrokDomainID = d.Status.ID + }, timeout, interval).Should(Succeed()) + + if deleteDomainInNgrokBeforeK8s { + By("Deleting the domain in ngrok") + // Simulate the domain being deleted in ngrok + Expect(domainClient.Delete(ctx, ngrokDomainID)).To(Succeed()) + } + + By("Deleting the domain CR in k8s") + Expect(k8sClient.Delete(ctx, domain)).To(Succeed()) + + By("Waiting for the domain to be deleted in kubernetes") + Eventually(func(g Gomega) { + d := &ingressv1alpha1.Domain{} + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), d) + g.Expect(err).To(HaveOccurred()) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }, timeout, interval).Should(Succeed()) + }) + + When("The domain exists in ngrok", func() { + When("The reclaim policy is set to delete", func() { + BeforeEach(func() { + reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyDelete + }) + + It("should delete the domain in ngrok", func() { + rd, err := domainClient.Get(ctx, ngrokDomainID) + Expect(ngrok.IsNotFound(err)).To(BeTrue()) + Expect(rd).To(BeNil()) + }) + }) + + When("The reclaim policy is set to retain", func() { + BeforeEach(func() { + reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyRetain + }) + + It("should not delete the domain in ngrok", func() { + rd, err := domainClient.Get(ctx, ngrokDomainID) + Expect(err).ToNot(HaveOccurred()) + Expect(rd).ToNot(BeNil()) + Expect(rd.ID).To(Equal(ngrokDomainID)) + }) + }) + }) + + When("The domain does not exist in ngrok", func() { + BeforeEach(func() { + deleteDomainInNgrokBeforeK8s = true + }) + + When("The reclaim policy is set to delete", func() { + BeforeEach(func() { + reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyDelete + }) + + It("The domain should be deleted in ngrok", func() { + rd, err := domainClient.Get(ctx, ngrokDomainID) + Expect(ngrok.IsNotFound(err)).To(BeTrue()) + Expect(rd).To(BeNil()) + }) + }) + + When("The reclaim policy is set to retain", func() { + BeforeEach(func() { + reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyRetain + }) + + It("The domain is still missing in ngrok", func() { + iter := domainClient.List(&ngrok.Paging{}) + for iter.Next(ctx) { + d := iter.Item() + if d.Domain == domain.Spec.Domain { + Fail("Domain should not exist in ngrok") + } + } + Expect(iter.Err()).To(BeNil()) + }) + }) + }) + }) +}) diff --git a/internal/controller/ingress/httpsedge_controller_test.go b/internal/controller/ingress/httpsedge_controller_test.go index e3799517..4503df44 100644 --- a/internal/controller/ingress/httpsedge_controller_test.go +++ b/internal/controller/ingress/httpsedge_controller_test.go @@ -1,19 +1,12 @@ package ingress import ( - "testing" - "github.com/ngrok/ngrok-api-go/v7" ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -func TestControllers(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Controllers package Test Suite") -} - var _ = Describe("HTTPSEdgeController", func() { DescribeTable("isMigratingAuthProviders", func(current *ngrok.HTTPSEdgeRoute, desired *ingressv1alpha1.HTTPSEdgeRouteSpec, expected bool) { Expect(isMigratingAuthProviders(current, desired)).To(Equal(expected)) diff --git a/internal/controller/ingress/suite_test.go b/internal/controller/ingress/suite_test.go new file mode 100644 index 00000000..9d64e572 --- /dev/null +++ b/internal/controller/ingress/suite_test.go @@ -0,0 +1,156 @@ +/* +MIT License + +Copyright (c) 2025 ngrok, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package ingress + +import ( + "context" + "path/filepath" + "testing" + + "github.com/go-logr/logr" + "github.com/ngrok/ngrok-operator/internal/mocks/nmockapi" + "github.com/ngrok/ngrok-operator/internal/testutils" + "github.com/ngrok/ngrok-operator/pkg/managerdriver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1" + ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + driver *managerdriver.Driver + domainClient *nmockapi.DomainClient + + ctx context.Context + cancel context.CancelFunc +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(GinkgoT().Context()) + + By("bootstrapping test environment") + operatorAPIs := filepath.Join("..", "..", "..", "helm", "ngrok-operator", "templates", "crds") + gwAPIs := filepath.Join(".", "testdata", "gatewayapi-crds.yaml") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{operatorAPIs, gwAPIs}, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + schemeAdders := []func(*runtime.Scheme) error{ + scheme.AddToScheme, + ngrokv1alpha1.AddToScheme, + ingressv1alpha1.AddToScheme, + gatewayv1.Install, + gatewayv1alpha2.Install, + } + for _, addFunc := range schemeAdders { + Expect(addFunc(scheme.Scheme)).NotTo(HaveOccurred()) + } + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + driver = managerdriver.NewDriver( + logr.New(logr.Discard().GetSink()), + scheme.Scheme, + testutils.DefaultControllerName, + types.NamespacedName{ + Name: "test-manager-name", + Namespace: "test-manager-namespace", + }, + managerdriver.WithGatewayEnabled(true), + managerdriver.WithSyncAllowConcurrent(true), + ) + + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: server.Options{ + // Set to 0 to disable the metrics server for tests + BindAddress: "0", + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sManager).NotTo(BeNil()) + + domainClient = nmockapi.NewDomainClient() + + err = (&DomainReconciler{ + Client: k8sManager.GetClient(), + Log: logf.Log.WithName("controllers").WithName("Domain"), + Recorder: k8sManager.GetEventRecorderFor("domain-controller"), + Scheme: k8sManager.GetScheme(), + DomainsClient: domainClient, + }).SetupWithManager(k8sManager) + + Expect(err).NotTo(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/internal/mocks/gen.go b/internal/mocks/gen.go index dd8054d6..5691d8e1 100644 --- a/internal/mocks/gen.go +++ b/internal/mocks/gen.go @@ -1,7 +1,10 @@ package mocks -//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination conn.go net Conn +// Note: Generate the mock files with names like mock_*.go. This is so that +// the generated files are picked up by the .gitattributes file. -//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination tunnel.go golang.ngrok.com/ngrok Tunnel +//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_conn.go net Conn -//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer +//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_tunnel.go golang.ngrok.com/ngrok Tunnel + +//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer diff --git a/internal/mocks/conn.go b/internal/mocks/mock_conn.go similarity index 98% rename from internal/mocks/conn.go rename to internal/mocks/mock_conn.go index e4055d61..609b20fb 100644 --- a/internal/mocks/conn.go +++ b/internal/mocks/mock_conn.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -package mocks -destination conn.go net Conn +// mockgen -package mocks -destination mock_conn.go net Conn // // Package mocks is a generated GoMock package. diff --git a/internal/mocks/dialer.go b/internal/mocks/mock_dialer.go similarity index 93% rename from internal/mocks/dialer.go rename to internal/mocks/mock_dialer.go index ad5a894d..d90ec4ea 100644 --- a/internal/mocks/dialer.go +++ b/internal/mocks/mock_dialer.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -package mocks -destination dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer +// mockgen -package mocks -destination mock_dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer // // Package mocks is a generated GoMock package. diff --git a/internal/mocks/tunnel.go b/internal/mocks/mock_tunnel.go similarity index 98% rename from internal/mocks/tunnel.go rename to internal/mocks/mock_tunnel.go index e1bd5552..f674ce7c 100644 --- a/internal/mocks/tunnel.go +++ b/internal/mocks/mock_tunnel.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -package mocks -destination tunnel.go golang.ngrok.com/ngrok Tunnel +// mockgen -package mocks -destination mock_tunnel.go golang.ngrok.com/ngrok Tunnel // // Package mocks is a generated GoMock package. diff --git a/internal/mocks/nmockapi/base_client.go b/internal/mocks/nmockapi/base_client.go new file mode 100644 index 00000000..ab8e9a19 --- /dev/null +++ b/internal/mocks/nmockapi/base_client.go @@ -0,0 +1,76 @@ +package nmockapi + +import ( + context "context" + "fmt" + "maps" + "net/http" + "slices" + "time" + + "github.com/ngrok/ngrok-api-go/v7" + "github.com/segmentio/ksuid" +) + +type baseClient[T any] struct { + idPrefix string + items map[string]T +} + +func newBase[T any](idPrefix string) baseClient[T] { + return baseClient[T]{ + items: make(map[string]T), + idPrefix: idPrefix, + } +} + +func (m *baseClient[T]) Get(_ context.Context, id string) (T, error) { + item, ok := m.items[id] + if !ok { + return *new(T), m.notFoundErr() + } + return item, nil +} + +func (m *baseClient[T]) List(_ *ngrok.Paging) ngrok.Iter[T] { + items := slices.Collect(maps.Values(m.items)) + return NewIter(items, nil) +} + +func (m *baseClient[T]) Delete(ctx context.Context, id string) error { + _, err := m.Get(ctx, id) + if err != nil { + return err + } + delete(m.items, id) + return nil +} + +// Reset clears the items in the client. +// This is useful for resetting the state of the client between tests, without allocating a new client. +func (m *baseClient[T]) Reset() { + m.items = make(map[string]T) +} + +func (m *baseClient[T]) newID() string { + return fmt.Sprintf("%s_%s", m.idPrefix, ksuid.New().String()) +} + +func (m *baseClient[T]) notFoundErr() error { + return &ngrok.Error{ + StatusCode: http.StatusNotFound, + } +} + +func (m *baseClient[T]) any(predicate func(T) bool) bool { + for _, item := range m.items { + if predicate(item) { + return true + } + } + return false +} + +func (m *baseClient[T]) createdAt() string { + return time.Now().Format(time.RFC3339) +} diff --git a/internal/mocks/nmockapi/domain_client.go b/internal/mocks/nmockapi/domain_client.go new file mode 100644 index 00000000..2afb70c7 --- /dev/null +++ b/internal/mocks/nmockapi/domain_client.go @@ -0,0 +1,105 @@ +package nmockapi + +import ( + context "context" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/ngrok/ngrok-api-go/v7" + "k8s.io/apimachinery/pkg/util/rand" +) + +// DomainClient is a mock implementation of the ngrok API client for managing reserved domains. It +// tries to mimic the behavior of the actual ngrok API client, but it is not a complete +// implementation. It is used for testing purposes only and should not be used in production +// environments. +type DomainClient struct { + baseClient[*ngrok.ReservedDomain] +} + +func NewDomainClient() *DomainClient { + return &DomainClient{ + baseClient: newBase[*ngrok.ReservedDomain]( + "rd", + ), + } +} + +func (m *DomainClient) Create(_ context.Context, item *ngrok.ReservedDomainCreate) (*ngrok.ReservedDomain, error) { + if m.any(func(rd *ngrok.ReservedDomain) bool { return rd.Domain == item.Domain }) { + return nil, &ngrok.Error{ + StatusCode: http.StatusConflict, + Msg: fmt.Sprintf("Domain %s already exists", item.Domain), + ErrorCode: "ERR_NGROK_413", + } + } + + id := m.newID() + + newDomain := &ngrok.ReservedDomain{ + ID: id, + CreatedAt: m.createdAt(), + Domain: item.Domain, + Region: item.Region, + URI: fmt.Sprintf("https://mock-api.ngrok.com/reserved_domains/%s", id), + } + + if !isNgrokManagedDomain(newDomain) { + cname := fmt.Sprintf("%s.%s.ngrok-cname.com", rand.String(17), rand.String(17)) + newDomain.CNAMETarget = &cname + } + m.items[id] = newDomain + return newDomain, nil +} + +func (m *DomainClient) Update(ctx context.Context, item *ngrok.ReservedDomainUpdate) (*ngrok.ReservedDomain, error) { + existingItem, err := m.Get(ctx, item.ID) + if err != nil { + return nil, err + } + + if item.Description != nil { + existingItem.Description = *item.Description + } + if item.Metadata != nil { + existingItem.Metadata = *item.Metadata + } + + if item.CertificateID != nil { + existingItem.Certificate = &ngrok.Ref{ + ID: *item.CertificateID, + URI: fmt.Sprintf("https://mock-api.ngrok.com/certificates/%s", *item.CertificateID), + } + } + + if item.CertificateManagementPolicy != nil { + existingItem.CertificateManagementPolicy = item.CertificateManagementPolicy + } + + m.items[item.ID] = existingItem + return existingItem, nil +} + +var ( + ngrokManagedDomainSuffixes = []string{ + "ngrok.app", + "ngrok.dev", + "ngrok.pizza", + "ngrok-free.app", + "ngrok-free.dev", + "ngrok-free.pizza", + "ngrok.io", + } +) + +func isNgrokManagedDomain(domain *ngrok.ReservedDomain) bool { + if domain == nil { + return false + } + + return slices.ContainsFunc(ngrokManagedDomainSuffixes, func(suffix string) bool { + return strings.HasSuffix(domain.Domain, suffix) + }) +} diff --git a/internal/mocks/nmockapi/domain_client_test.go b/internal/mocks/nmockapi/domain_client_test.go new file mode 100644 index 00000000..cf324ade --- /dev/null +++ b/internal/mocks/nmockapi/domain_client_test.go @@ -0,0 +1,309 @@ +package nmockapi_test + +import ( + context "context" + "fmt" + "slices" + + "github.com/ngrok/ngrok-api-go/v7" + "github.com/ngrok/ngrok-operator/internal/mocks/nmockapi" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/rand" +) + +var _ = Describe("DomainClient", func() { + const ( + NgrokManagedDomainSuffix = "ngrok.app" + CustomDomainSuffix = "custom-domain.xyz" + ) + + var ( + domainClient *nmockapi.DomainClient + ctx context.Context + ) + + BeforeEach(func() { + domainClient = nmockapi.NewDomainClient() + ctx = GinkgoT().Context() + }) + + Describe("Get()", func() { + var ( + id string + domain *ngrok.ReservedDomain + err error + ) + + JustBeforeEach(func() { + domain, err = domainClient.Get(ctx, id) + }) + + When("the domain exists", func() { + BeforeEach(func() { + domain, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: "test-domain.ngrok.io", + }) + Expect(err).NotTo(HaveOccurred()) + id = domain.ID + }) + + It("should return the domain", func() { + Expect(err).To(BeNil()) + Expect(domain.Domain).To(Equal("test-domain.ngrok.io")) + Expect(domain.ID).To(MatchRegexp("^rd_")) + }) + }) + + When("the domain does not exist", func() { + BeforeEach(func() { + id = "non-existing-id" + }) + + It("should return an ngrok not found error", func() { + Expect(err).To(HaveOccurred()) + Expect(ngrok.IsNotFound(err)).To(BeTrue()) + }) + }) + }) + + Describe("List()", func() { + var ( + domains []*ngrok.ReservedDomain + err error + ) + + JustBeforeEach(func() { + iter := domainClient.List(nil) + + domains = make([]*ngrok.ReservedDomain, 0) + for iter.Next(ctx) { + domains = append(domains, iter.Item()) + } + err = iter.Err() + }) + + When("there are no domains", func() { + It("the iterator should return an empty list", func() { + Expect(err).ToNot(HaveOccurred()) + Expect(domains).To(BeEmpty()) + }) + }) + + When("there are domains", func() { + BeforeEach(func() { + _, createDomain1Err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: "test-domain-1.ngrok.io", + }) + Expect(createDomain1Err).ToNot(HaveOccurred()) + _, createDomain2Err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: "test-domain-2.ngrok.io", + }) + Expect(createDomain2Err).ToNot(HaveOccurred()) + }) + + It("the iterator should return the domains", func() { + Expect(err).ToNot(HaveOccurred()) + Expect(domains).To(HaveLen(2)) + + Expect(slices.ContainsFunc(domains, func(domain *ngrok.ReservedDomain) bool { + return domain.Domain == "test-domain-1.ngrok.io" + })).To(BeTrue()) + Expect(slices.ContainsFunc(domains, func(domain *ngrok.ReservedDomain) bool { + return domain.Domain == "test-domain-2.ngrok.io" + })).To(BeTrue()) + }) + }) + }) + + Describe("Create()", func() { + var ( + domain *ngrok.ReservedDomain + err error + domainSuffix string + domainName string + ) + + JustBeforeEach(func() { + domain, err = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: domainName, + }) + }) + + When("the domain is a ngrok managed domain", func() { + BeforeEach(func() { + domainSuffix = NgrokManagedDomainSuffix + domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix) + }) + + It("should create and return the domain", func() { + Expect(err).To(BeNil()) + Expect(domain.Domain).To(Equal(domainName)) + }) + + It("should create the domain with an ID", func() { + Expect(domain.ID).ToNot(BeEmpty()) + Expect(domain.ID).To(MatchRegexp("^rd_")) + }) + + It("should create the domain with a timestamp", func() { + Expect(domain.CreatedAt).ToNot(BeEmpty()) + }) + + It("should not create the domain with a CNAMETarget", func() { + Expect(domain.CNAMETarget).To(BeNil()) + }) + + When("the domain is already taken", func() { + BeforeEach(func() { + _, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return an ngrok already exists error", func() { + Expect(err).To(HaveOccurred()) + Expect(ngrok.IsErrorCode(err, 413)).To(BeTrue()) + }) + }) + }) + + When("the domain is a custom domain", func() { + BeforeEach(func() { + domainSuffix = CustomDomainSuffix + domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix) + }) + + It("should create and return the domain", func() { + Expect(err).To(BeNil()) + Expect(domain.Domain).To(Equal(domainName)) + }) + + It("should create the domain with an ID", func() { + Expect(domain.ID).ToNot(BeEmpty()) + Expect(domain.ID).To(MatchRegexp("^rd_")) + }) + + It("should create the domain with a timestamp", func() { + Expect(domain.CreatedAt).ToNot(BeEmpty()) + }) + + It("should create the domain with a CNAMETarget", func() { + Expect(domain.CNAMETarget).ToNot(BeNil()) + Expect(*domain.CNAMETarget).To(MatchRegexp("^[a-zA-Z0-9]{17}\\.[a-zA-Z0-9]{17}\\.ngrok-cname\\.com$")) + }) + + When("the domain is already taken", func() { + BeforeEach(func() { + _, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: domainName, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return an ngrok already exists error", func() { + Expect(err).To(HaveOccurred()) + Expect(ngrok.IsErrorCode(err, 413)).To(BeTrue()) + }) + }) + }) + }) + + Describe("Delete()", func() { + var ( + id string + deleteErr error + domain *ngrok.ReservedDomain + getErr error + ) + + JustBeforeEach(func() { + deleteErr = domainClient.Delete(ctx, id) + domain, getErr = domainClient.Get(ctx, id) + }) + + When("the domain exists", func() { + BeforeEach(func() { + domain, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: "test-domain-4.ngrok.io", + }) + Expect(err).ToNot(HaveOccurred()) + id = domain.ID + }) + + It("should delete the domain", func() { + Expect(deleteErr).ToNot(HaveOccurred()) + + Expect(getErr).To(HaveOccurred()) + Expect(ngrok.IsNotFound(getErr)).To(BeTrue()) + + Expect(domain).To(BeNil()) + }) + }) + + When("the domain does not exist", func() { + BeforeEach(func() { + id = "non-existing-id" + }) + + It("Should return an ngrok not found error", func() { + Expect(deleteErr).To(HaveOccurred()) + Expect(ngrok.IsNotFound(deleteErr)).To(BeTrue()) + }) + }) + }) + + Describe("Update()", func() { + var ( + createdDomain *ngrok.ReservedDomain + createErr error + domainUpdate *ngrok.ReservedDomainUpdate + + updatedDomain *ngrok.ReservedDomain + updateErr error + ) + + BeforeEach(func() { + createdDomain, createErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{ + Domain: "test-domain-5.ngrok.io", + }) + Expect(createErr).ToNot(HaveOccurred()) + }) + + JustBeforeEach(func() { + updatedDomain, updateErr = domainClient.Update(ctx, domainUpdate) + }) + + When("the domain exists", func() { + BeforeEach(func() { + domainUpdate = &ngrok.ReservedDomainUpdate{ + ID: createdDomain.ID, + Metadata: ngrok.String("new-metadata"), + Description: ngrok.String("new-description"), + } + }) + + It("should update the domain", func() { + Expect(updateErr).ToNot(HaveOccurred()) + Expect(updatedDomain).ToNot(BeNil()) + Expect(updatedDomain.ID).To(Equal(createdDomain.ID)) + Expect(updatedDomain.Metadata).To(Equal("new-metadata")) + Expect(updatedDomain.Description).To(Equal("new-description")) + }) + }) + + When("the domain does not exist", func() { + BeforeEach(func() { + domainUpdate = &ngrok.ReservedDomainUpdate{ + ID: "non-existing-id", + Metadata: ngrok.String("new-metadata"), + Description: ngrok.String("new-description"), + } + }) + + It("should return a not found error", func() { + Expect(updateErr).To(HaveOccurred()) + Expect(ngrok.IsNotFound(updateErr)).To(BeTrue()) + }) + }) + }) +}) diff --git a/internal/mocks/nmockapi/iter.go b/internal/mocks/nmockapi/iter.go new file mode 100644 index 00000000..843955a9 --- /dev/null +++ b/internal/mocks/nmockapi/iter.go @@ -0,0 +1,41 @@ +package nmockapi + +import context "context" + +// Iter is a mock iterator that implements the ngrok.Iter[T] interface. +type Iter[T any] struct { + items []T + err error + n int +} + +func (m *Iter[T]) Next(_ context.Context) bool { + // If there is an error, stop iteration + if m.err != nil { + return false + } + + // Increment the index + m.n++ + + return m.n < len(m.items) && m.n >= 0 +} + +func (m *Iter[T]) Item() T { + if m.n >= 0 && m.n < len(m.items) { + return m.items[m.n] + } + return *new(T) +} + +func (m *Iter[T]) Err() error { + return m.err +} + +func NewIter[T any](items []T, err error) *Iter[T] { + return &Iter[T]{ + items: items, + err: err, + n: -1, + } +} diff --git a/internal/mocks/nmockapi/iter_test.go b/internal/mocks/nmockapi/iter_test.go new file mode 100644 index 00000000..07472551 --- /dev/null +++ b/internal/mocks/nmockapi/iter_test.go @@ -0,0 +1,134 @@ +package nmockapi_test + +import ( + context "context" + "fmt" + + "github.com/ngrok/ngrok-api-go/v7" + "github.com/ngrok/ngrok-operator/internal/mocks/nmockapi" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Iter", func() { + var ( + iter ngrok.Iter[string] + iterErr error + items []string + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) + + JustBeforeEach(func() { + iter = nmockapi.NewIter(items, iterErr) + }) + + Describe("Item()", func() { + Context("when there is an error", func() { + BeforeEach(func() { + iterErr = fmt.Errorf("ut-oh") + }) + + It("should return an empty item", func() { + Expect(iter.Item()).To(Equal("")) + }) + }) + + Context("when there is no error", func() { + BeforeEach(func() { + iterErr = nil + items = []string{"a", "b", "c"} + }) + + It("should return the current item", func() { + iter.Next(ctx) + Expect(iter.Item()).To(Equal("a")) + + iter.Next(ctx) + Expect(iter.Item()).To(Equal("b")) + + iter.Next(ctx) + Expect(iter.Item()).To(Equal("c")) + }) + + Context("when called before Next", func() { + It("should return an empty item", func() { + Expect(iter.Item()).To(Equal("")) + }) + }) + + Context("when called after Next returns false", func() { + It("should return an empty item", func() { + for iter.Next(ctx) { + // Iterate until there are no more items + } + Expect(iter.Item()).To(Equal("")) + }) + }) + }) + }) + + Describe("Next", func() { + Context("when there is an error", func() { + BeforeEach(func() { + iterErr = fmt.Errorf("ut-oh") + }) + + It("should return false", func() { + Expect(iter.Next(ctx)).To(BeFalse()) + }) + }) + + Context("when there is no error", func() { + BeforeEach(func() { + iterErr = nil + }) + + Context("when there are no items", func() { + BeforeEach(func() { + iter = nmockapi.NewIter([]string{}, nil) + }) + + It("should return false", func() { + + }) + }) + BeforeEach(func() { + iter = nmockapi.NewIter([]string{"a", "b", "c"}, nil) + }) + + It("should return true while there are more values", func() { + Expect(iter.Next(ctx)).To(BeTrue()) + Expect(iter.Next(ctx)).To(BeTrue()) + Expect(iter.Next(ctx)).To(BeTrue()) + + Expect(iter.Next(ctx)).To(BeFalse()) + }) + }) + }) + + Describe("Err()", func() { + Context("when there is an error", func() { + BeforeEach(func() { + iterErr = fmt.Errorf("ut-oh") + }) + + It("should return the error", func() { + Expect(iter.Err()).To(HaveOccurred()) + }) + }) + + Context("when there is no error", func() { + BeforeEach(func() { + iterErr = nil + }) + + It("should return nil", func() { + Expect(iter.Err()).ToNot(HaveOccurred()) + }) + }) + }) +}) diff --git a/internal/mocks/nmockapi/nmockapi_test.go b/internal/mocks/nmockapi/nmockapi_test.go new file mode 100644 index 00000000..d014da54 --- /dev/null +++ b/internal/mocks/nmockapi/nmockapi_test.go @@ -0,0 +1,13 @@ +package nmockapi_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNmockAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "nmockapi Suite") +}