Files
ngrok-operator/internal/controller/service/controller_test.go
T
Jonathan Stacks aa1781d348 Dependency updates (#785)
* chore: Update to go 1.26.1

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* chore: Run 'go fix ./...' for go 1.26.1

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* chore: Upgrade go modules

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* chore: Fix deprecations and linter warnings

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

---------

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
2026-03-23 16:50:43 +00:00

882 lines
31 KiB
Go

/*
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 service
import (
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"time"
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
"github.com/ngrok/ngrok-operator/internal/annotations"
"github.com/ngrok/ngrok-operator/internal/controller/labels"
"github.com/ngrok/ngrok-operator/internal/testutils"
"github.com/ngrok/ngrok-operator/internal/util"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
LoadBalancer = corev1.ServiceTypeLoadBalancer
ClusterIP = corev1.ServiceTypeClusterIP
FinalizerName = util.FinalizerName
Annotation_URL = annotations.URLAnnotation
Annotation_MappingStrategy = annotations.MappingStrategyAnnotation
Annotation_TrafficPolicy = annotations.TrafficPolicyAnnotation
)
// getCloudEndpoints fetches CloudEndpoints in the given namespace
func getCloudEndpoints(k8sClient client.Client, namespace string) (*ngrokv1alpha1.CloudEndpointList, error) {
clepList := &ngrokv1alpha1.CloudEndpointList{}
listOpts := []client.ListOption{
client.InNamespace(namespace),
}
err := k8sClient.List(ctx, clepList, listOpts...)
return clepList, err
}
// getAgentEndpoints fetches AgentEndpoints in the given namespace
func getAgentEndpoints(k8sClient client.Client, namespace string) (*ngrokv1alpha1.AgentEndpointList, error) {
aepList := &ngrokv1alpha1.AgentEndpointList{}
listOpts := []client.ListOption{
client.InNamespace(namespace),
}
err := k8sClient.List(ctx, aepList, listOpts...)
return aepList, err
}
type ServiceModifier func(*corev1.Service)
type ServiceModifiers struct {
mods []ServiceModifier
}
func (sm *ServiceModifiers) Add(modifier ServiceModifier) {
sm.mods = append(sm.mods, modifier)
}
func (sm ServiceModifiers) Apply(svc *corev1.Service) {
for _, modify := range sm.mods {
modify(svc)
}
}
func SetServiceType(svcType corev1.ServiceType) ServiceModifier {
return func(svc *corev1.Service) {
svc.Spec.Type = svcType
}
}
func SetLoadBalancerClass(lbClass string) ServiceModifier {
return func(svc *corev1.Service) {
svc.Spec.LoadBalancerClass = new(lbClass)
}
}
func AddAnnotation(key, value string) ServiceModifier {
return func(svc *corev1.Service) {
if svc.Annotations == nil {
svc.Annotations = map[string]string{}
}
svc.Annotations[key] = value
}
}
func SetMappingStrategy(strategy annotations.MappingStrategy) ServiceModifier {
return func(svc *corev1.Service) {
AddAnnotation(annotations.MappingStrategyAnnotation, string(strategy))(svc)
}
}
var _ = Describe("ServiceController", func() {
const (
timeout = 10 * time.Second
duration = 10 * time.Second
interval = 250 * time.Millisecond
)
var (
namespace string
svc *corev1.Service
modifiers *ServiceModifiers
)
BeforeEach(func() {
modifiers = &ServiceModifiers{
mods: []ServiceModifier{},
}
namespace = fmt.Sprintf("test-namespace-%d", rand.IntN(100000))
kginkgo.ExpectCreateNamespace(ctx, namespace)
})
JustBeforeEach(func() {
svc = &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("test-service-%d", rand.IntN(100000)),
Namespace: namespace,
Annotations: map[string]string{},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeLoadBalancer,
Ports: []corev1.ServicePort{
{
Name: "tcp",
Protocol: corev1.ProtocolTCP,
Port: 80,
},
},
},
}
modifiers.Apply(svc)
Expect(k8sClient.Create(ctx, svc)).To(Succeed())
})
AfterEach(func(ctx SpecContext) {
kginkgo.ExpectDeleteNamespace(ctx, namespace)
})
When("the service type is not a LoadBalancer", func() {
BeforeEach(func() {
modifiers.Add(SetServiceType(ClusterIP))
})
It("should ignore the service", func() {
Consistently(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the service is not modified")
g.Expect(fetched.Finalizers).To(BeEmpty())
}, duration, interval).Should(Succeed())
})
})
When("service type is LoadBalancer", func() {
BeforeEach(func() {
modifiers.Add(SetServiceType(LoadBalancer))
})
When("the service has a non-ngrok load balancer class", func() {
It("should ignore the service", func() {
Consistently(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the service is not modified")
g.Expect(fetched.Finalizers).To(BeEmpty())
}, duration, interval).Should(Succeed())
})
})
When("the service has the ngrok load balancer class", func() {
BeforeEach(func() {
modifiers.Add(SetLoadBalancerClass(NgrokLoadBalancerClass))
})
It("should have a finalizer added", func() {
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the service has a finalizer added")
g.Expect(fetched.Finalizers).To(ContainElement(FinalizerName))
}, timeout, interval).Should(Succeed())
})
When("the service does not have a URL annotation", func() {
It("Should reserve a TCP address", func() {
kginkgo.EventuallyWithObject(ctx, svc.DeepCopy(), func(g Gomega, fetched client.Object) {
By("checking the service has a URL annotation")
GinkgoLogr.Info("Got service", "fetched", fetched)
a := fetched.GetAnnotations()
g.Expect(a).NotTo(BeEmpty())
urlAnnotation, exists := a[annotations.ComputedURLAnnotation]
g.Expect(exists).To(BeTrue())
g.Expect(urlAnnotation).To(MatchRegexp(`^tcp://[a-zA-Z0-9\-\.]+:\d+$`))
})
})
})
When("endpoints verbose", func() {
BeforeEach(func() {
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("Should create a cloud endpoint", func() {
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
clep := cleps[0]
By("verifying the cloud endpoint has the controller labels added")
g.Expect(
labels.HasControllerLabels(
&clep,
controllerLabelNamespace,
controllerLabelName,
),
).To(BeTrue())
})
})
It("should update service status with hostname and port", func() {
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the service status is updated")
g.Expect(fetched.Status.LoadBalancer.Ingress).NotTo(BeEmpty())
g.Expect(fetched.Status.LoadBalancer.Ingress[0].Hostname).NotTo(BeEmpty())
By("verifying the resource version does not change unnecessarily")
kginkgo.ConsistentlyExpectResourceVersionNotToChange(ctx, svc, testutils.WithTimeout(10*time.Second))
}, timeout, interval).Should(Succeed())
})
It("should create an agent endpoint for the cloud endpoint", func() {
Eventually(func(g Gomega) {
aeps, err := getAgentEndpoints(k8sClient, namespace)
g.Expect(err).NotTo(HaveOccurred())
By("checking an agent endpoint exists")
g.Expect(aeps.Items).To(HaveLen(1))
aep := aeps.Items[0]
By("verifying the agent endpoint has the controller labels added")
g.Expect(
labels.HasControllerLabels(
&aep,
controllerLabelNamespace,
controllerLabelName,
),
).To(BeTrue())
}, timeout, interval).Should(Succeed())
})
It("should create an agent endpoint with .internal suffix in URL", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint URL has .internal suffix")
aep := aeps[0]
g.Expect(aep.Spec.URL).To(ContainSubstring(".internal"))
})
})
When("the service is deleted", func() {
It("should clean up all owned resources", func() {
kginkgo.ExpectFinalizerToBeAdded(ctx, svc, FinalizerName)
// Wait for resources to be created
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
})
By("deleting the service")
Expect(k8sClient.Delete(ctx, svc)).To(Succeed())
// Verify all owned resources are cleaned up
kginkgo.EventuallyExpectNoEndpoints(ctx, namespace)
})
It("should remove the finalizer after cleanup", func() {
// Wait for finalizer to be added
kginkgo.ExpectFinalizerToBeAdded(ctx, svc, FinalizerName)
// Delete the service
Expect(k8sClient.Delete(ctx, svc)).To(Succeed())
// Verify service is fully deleted (finalizer removed)
kginkgo.ExpectFinalizerToBeRemoved(ctx, svc, FinalizerName)
})
})
})
When("endpoints default (no annotation)", func() {
It("Should not create a cloud endpoint", func() {
kginkgo.ConsistentlyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking no cloud endpoints exist")
g.Expect(cleps).To(BeEmpty())
})
})
It("Should create an agent endpoint", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
aep := aeps[0]
By("verifying the agent endpoint has the controller labels added")
g.Expect(
labels.HasControllerLabels(
&aep,
controllerLabelNamespace,
controllerLabelName,
),
).To(BeTrue())
})
})
It("Should update service status with hostname and port", func() {
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the service status is updated")
g.Expect(fetched.Status.LoadBalancer.Ingress).NotTo(BeEmpty())
g.Expect(fetched.Status.LoadBalancer.Ingress[0].Hostname).NotTo(BeEmpty())
}).WithTimeout(timeout).WithPolling(interval).Should(Succeed())
})
When("service has a traffic policy annotation", func() {
var (
policy *ngrokv1alpha1.NgrokTrafficPolicy
policyName string
)
BeforeEach(func() {
policyName = fmt.Sprintf("test-policy-collapsed-%d", rand.IntN(100000))
policy = &ngrokv1alpha1.NgrokTrafficPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: policyName,
Namespace: namespace,
},
Spec: ngrokv1alpha1.NgrokTrafficPolicySpec{
Policy: json.RawMessage(`{"on_tcp_connect": [{"actions": [{"type": "restrict-ips", "config": {"deny": ["5.6.7.8/32"]}}]}]}`),
},
}
Expect(k8sClient.Create(ctx, policy)).To(Succeed())
modifiers.Add(AddAnnotation(Annotation_TrafficPolicy, policyName))
})
It("should apply traffic policy to the agent endpoint", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has the traffic policy")
aep := aeps[0]
g.Expect(aep.Spec.TrafficPolicy).NotTo(BeNil())
g.Expect(string(aep.Spec.TrafficPolicy.Inline)).To(ContainSubstring("deny"))
g.Expect(string(aep.Spec.TrafficPolicy.Inline)).To(ContainSubstring("5.6.7.8/32"))
})
})
It("should update agent endpoint when traffic policy changes", func() {
// Wait for initial reconciliation
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has the initial traffic policy")
aep := aeps[0]
g.Expect(aep.Spec.TrafficPolicy).NotTo(BeNil())
g.Expect(string(aep.Spec.TrafficPolicy.Inline)).To(ContainSubstring("deny"))
})
// Update the policy
fetched := &ngrokv1alpha1.NgrokTrafficPolicy{}
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), fetched)).To(Succeed())
fetched.Spec.Policy = json.RawMessage(`{"on_tcp_connect": [{"actions": [{"type": "restrict-ips", "config": {"allow": ["5.6.7.8/32"]}}]}]}`)
Expect(k8sClient.Update(ctx, fetched)).To(Succeed())
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has the updated traffic policy")
aep := aeps[0]
g.Expect(aep.Spec.TrafficPolicy).NotTo(BeNil())
g.Expect(string(aep.Spec.TrafficPolicy.Inline)).To(ContainSubstring("allow"))
})
})
})
})
When("service type changes from LoadBalancer to ClusterIP", func() {
BeforeEach(func() {
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("should clean up owned resources and remove finalizer", func() {
// Wait for resources to be created
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
})
By("changing service type from LoadBalancer to ClusterIP")
Eventually(func() error {
fetched := &corev1.Service{}
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched); err != nil {
return err
}
fetched.Spec.Type = ClusterIP
return k8sClient.Update(ctx, fetched)
}, timeout, interval).Should(Succeed())
// Verify resources are cleaned up
kginkgo.EventuallyExpectNoEndpoints(ctx, namespace)
// Verify finalizer is removed
kginkgo.ExpectFinalizerToBeRemoved(ctx, svc, FinalizerName)
})
It("should ensure no cloudendpoints or agentendpoints remain after type change", func() {
// Wait for initial resources to be created
Eventually(func(g Gomega) {
cleps, err := getCloudEndpoints(k8sClient, namespace)
g.Expect(err).NotTo(HaveOccurred())
By("verifying cloud endpoint was created initially")
g.Expect(cleps.Items).To(HaveLen(1))
aeps, err := getAgentEndpoints(k8sClient, namespace)
g.Expect(err).NotTo(HaveOccurred())
By("verifying agent endpoint was created initially")
g.Expect(aeps.Items).To(HaveLen(1))
}, timeout, interval).Should(Succeed())
By("changing service type from LoadBalancer to ClusterIP")
Eventually(func() error {
fetched := &corev1.Service{}
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched); err != nil {
return err
}
fetched.Spec.Type = ClusterIP
return k8sClient.Update(ctx, fetched)
}, timeout, interval).Should(Succeed())
// Verify all owned endpoints are completely cleaned up
kginkgo.EventuallyExpectNoEndpoints(ctx, namespace)
})
})
When("service with ngrok load balancer class is deleted", func() {
BeforeEach(func() {
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("should clean up owned resources when service is deleted", func() {
// Wait for resources to be created
kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
})
By("deleting the service")
Expect(k8sClient.Delete(ctx, svc)).To(Succeed())
// Verify resources are cleaned up
kginkgo.EventuallyExpectNoEndpoints(ctx, namespace)
// Verify finalizer is removed
kginkgo.ExpectFinalizerToBeRemoved(ctx, svc, FinalizerName)
})
})
When("service has explicit tcp:// URL annotation", func() {
BeforeEach(func() {
modifiers.Add(AddAnnotation(Annotation_URL, "tcp://1.tcp.ngrok.io:12345"))
})
It("should create an agent endpoint with the explicit TCP URL", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has the explicit TCP URL")
aep := aeps[0]
g.Expect(aep.Spec.URL).To(Equal("tcp://1.tcp.ngrok.io:12345"))
})
})
It("should not create a cloud endpoint in default mapping", func() {
kginkgo.ConsistentlyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking no cloud endpoints exist")
g.Expect(cleps).To(BeEmpty())
})
})
It("should have owner reference pointing to the service", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has correct owner reference")
aep := aeps[0]
g.Expect(aep.OwnerReferences).To(HaveLen(1))
g.Expect(aep.OwnerReferences[0].Kind).To(Equal("Service"))
g.Expect(aep.OwnerReferences[0].Name).To(Equal(svc.Name))
g.Expect(aep.OwnerReferences[0].UID).To(Equal(svc.UID))
g.Expect(*aep.OwnerReferences[0].Controller).To(BeTrue())
})
})
})
When("service has tls:// URL annotation", func() {
const (
tlsDomain = "example.ngrok.app"
domainName = "example-ngrok-app"
)
BeforeEach(func() {
modifiers.Add(AddAnnotation(Annotation_URL, "tls://"+tlsDomain))
})
It("should create an agent endpoint with the TLS URL", func() {
kginkgo.EventuallyWithAgentEndpoints(ctx, namespace, func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the agent endpoint has the TLS URL")
aep := aeps[0]
g.Expect(aep.Spec.URL).To(Equal("tls://" + tlsDomain))
})
})
It("should set computed-url annotation and update service status after domainRef is set", func() {
By("waiting for agent endpoint to be created")
Eventually(func(g Gomega) {
aeps, err := getAgentEndpoints(k8sClient, namespace)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(aeps.Items).To(HaveLen(1))
}, timeout, interval).Should(Succeed())
By("creating a Domain CRD for the ngrok domain")
domain := &ingressv1alpha1.Domain{
ObjectMeta: metav1.ObjectMeta{
Name: domainName,
Namespace: namespace,
},
Spec: ingressv1alpha1.DomainSpec{
Domain: tlsDomain,
},
}
Expect(k8sClient.Create(ctx, domain)).To(Succeed())
By("updating the Domain status (ngrok domains don't have CNAME target)")
Eventually(func(_ Gomega) error {
fetchedDomain := &ingressv1alpha1.Domain{}
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), fetchedDomain); err != nil {
return err
}
fetchedDomain.Status.Domain = tlsDomain
fetchedDomain.Status.ID = "rd_test123"
// ngrok domains (*.ngrok.app) don't have a CNAME target
return k8sClient.Status().Update(ctx, fetchedDomain)
}, timeout, interval).Should(Succeed())
By("updating the AgentEndpoint status with domainRef")
Eventually(func(_ Gomega) error {
// Re-list agent endpoints to find the current one, since the
// reconciler may have deleted and recreated it with a new name
// due to informer cache races.
aeps, err := getAgentEndpoints(k8sClient, namespace)
if err != nil {
return err
}
if len(aeps.Items) != 1 {
return fmt.Errorf("expected 1 agent endpoint, got %d", len(aeps.Items))
}
fetchedAep := &aeps.Items[0]
fetchedAep.Status.DomainRef = &ngrokv1alpha1.K8sObjectRefOptionalNamespace{
Name: domainName,
Namespace: new(namespace),
}
return k8sClient.Status().Update(ctx, fetchedAep)
}, timeout, interval).Should(Succeed())
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("checking the computed-url annotation is set")
computedURL, exists := fetched.GetAnnotations()[annotations.ComputedURLAnnotation]
g.Expect(exists).To(BeTrue())
g.Expect(computedURL).To(Equal("tls://" + tlsDomain))
By("checking the service status is populated with the domain (no CNAME for ngrok domains)")
g.Expect(fetched.Status.LoadBalancer.Ingress).NotTo(BeEmpty())
g.Expect(fetched.Status.LoadBalancer.Ingress[0].Hostname).To(Equal(tlsDomain))
}, timeout, interval).Should(Succeed())
})
When("with endpoints-verbose mapping", func() {
BeforeEach(func() {
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("should create both cloud and agent endpoints", func() {
kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the cloud endpoint has the TLS URL")
g.Expect(cleps[0].Spec.URL).To(Equal("tls://" + tlsDomain))
By("checking the agent endpoint URL has .internal suffix")
g.Expect(aeps[0].Spec.URL).To(ContainSubstring(".internal"))
})
})
})
})
When("service has tls:// URL with custom domain (not *.ngrok.app)", func() {
const (
customDomain = "service-test-custom-domain.example.com"
cnameTarget = "abc123xyz.ngrok-cname.com"
)
BeforeEach(func() {
modifiers.Add(AddAnnotation(Annotation_URL, "tls://"+customDomain))
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("should create cloud and agent endpoints", func() {
kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the cloud endpoint has the custom domain URL")
g.Expect(cleps[0].Spec.URL).To(Equal("tls://" + customDomain))
})
})
It("should use CNAME target from Domain status for service hostname", func() {
By("waiting for cloud endpoint to be created")
Eventually(func(g Gomega) {
cleps, err := getCloudEndpoints(k8sClient, namespace)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(cleps.Items).To(HaveLen(1))
}, timeout, interval).Should(Succeed())
By("creating a Domain CRD with CNAME target in status")
domainName := strings.ReplaceAll(customDomain, ".", "-")
domain := &ingressv1alpha1.Domain{
ObjectMeta: metav1.ObjectMeta{
Name: domainName,
Namespace: namespace,
},
Spec: ingressv1alpha1.DomainSpec{
Domain: customDomain,
},
}
Expect(k8sClient.Create(ctx, domain)).To(Succeed())
By("updating the Domain status with CNAME target")
Eventually(func(_ Gomega) error {
fetchedDomain := &ingressv1alpha1.Domain{}
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), fetchedDomain); err != nil {
return err
}
fetchedDomain.Status.CNAMETarget = ptr.To(cnameTarget)
fetchedDomain.Status.Domain = customDomain
fetchedDomain.Status.ID = "rd_test123"
return k8sClient.Status().Update(ctx, fetchedDomain)
}, timeout, interval).Should(Succeed())
By("updating the CloudEndpoint status with domainRef")
Eventually(func(_ Gomega) error {
cleps, err := getCloudEndpoints(k8sClient, namespace)
if err != nil {
return err
}
if len(cleps.Items) != 1 {
return fmt.Errorf("expected 1 CloudEndpoint, got %d", len(cleps.Items))
}
fetchedClep := &cleps.Items[0]
fetchedClep.Status.DomainRef = &ngrokv1alpha1.K8sObjectRefOptionalNamespace{
Name: domainName,
Namespace: new(namespace),
}
return k8sClient.Status().Update(ctx, fetchedClep)
}, timeout, interval).Should(Succeed())
By("checking the service status hostname is the CNAME target, not the custom domain")
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(fetched.Status.LoadBalancer.Ingress).NotTo(BeEmpty(), "expected ingress status to be populated")
hostname := fetched.Status.LoadBalancer.Ingress[0].Hostname
By("hostname should be CNAME target ending in ngrok-cname.com, not the custom domain")
g.Expect(hostname).To(Equal(cnameTarget), "expected hostname to be CNAME target %q but got %q", cnameTarget, hostname)
g.Expect(hostname).NotTo(Equal(customDomain), "hostname should NOT be the custom domain directly")
}, timeout, interval).Should(Succeed())
})
})
When("service has a traffic policy annotation", func() {
var (
policy *ngrokv1alpha1.NgrokTrafficPolicy
policyName string
)
BeforeEach(func() {
policyName = fmt.Sprintf("test-policy-%d", rand.IntN(100000))
policy = &ngrokv1alpha1.NgrokTrafficPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: policyName,
Namespace: namespace,
},
Spec: ngrokv1alpha1.NgrokTrafficPolicySpec{
Policy: json.RawMessage(`{"on_tcp_connect": [{"actions": [{"type": "restrict-ips", "config": {"deny": ["1.2.3.4/32"]}}]}]}`),
},
}
Expect(k8sClient.Create(ctx, policy)).To(Succeed())
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
modifiers.Add(AddAnnotation(Annotation_TrafficPolicy, policyName))
})
It("should create a cloud endpoint with the traffic policy", func() {
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking the cloud endpoint has the traffic policy")
clep := cleps[0]
g.Expect(clep.Spec.TrafficPolicy).NotTo(BeNil())
})
})
When("the traffic policy is updated", func() {
It("should trigger service reconciliation", func() {
// Wait for initial reconciliation
kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the cloud endpoint has the initial traffic policy")
clep := cleps[0]
g.Expect(clep.Spec.TrafficPolicy).NotTo(BeNil())
g.Expect(string(clep.Spec.TrafficPolicy.Policy)).To(ContainSubstring("deny"))
})
// Update the policy
fetched := &ngrokv1alpha1.NgrokTrafficPolicy{}
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(policy), fetched)).To(Succeed())
fetched.Spec.Policy = json.RawMessage(`{"on_tcp_connect": [{"actions": [{"type": "restrict-ips", "config": {"allow": ["1.2.3.4/32"]}}]}]}`)
Expect(k8sClient.Update(ctx, fetched)).To(Succeed())
kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("checking an agent endpoint exists")
g.Expect(aeps).To(HaveLen(1))
By("checking the cloud endpoint has the updated traffic policy")
clep := cleps[0]
g.Expect(clep.Spec.TrafficPolicy).NotTo(BeNil())
g.Expect(string(clep.Spec.TrafficPolicy.Policy)).To(ContainSubstring("allow"))
})
})
})
})
When("multiple resources are owned by the service (error condition)", func() {
BeforeEach(func() {
modifiers.Add(SetMappingStrategy(annotations.MappingStrategy_EndpointsVerbose))
})
It("should delete extra resources keeping only one", func(ctx SpecContext) {
// Wait for first resource to be created
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
})
// Manually create a second cloud endpoint owned by the service
fetched := &corev1.Service{}
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)).To(Succeed())
extraClep := &ngrokv1alpha1.CloudEndpoint{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-extra", svc.Name),
Namespace: namespace,
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "v1",
Kind: "Service",
Name: fetched.Name,
UID: fetched.UID,
Controller: new(true),
},
},
},
Spec: ngrokv1alpha1.CloudEndpointSpec{
URL: "tcp://1.tcp.ngrok.io:12345",
},
}
Expect(k8sClient.Create(ctx, extraClep)).To(Succeed())
// Verify only one cloud endpoint remains
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking only one cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
})
})
})
})
})
})