fix: Load Balancer service status not working when using default mapping strategy (#693)

* refactor(service-controller): Move to own directory

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

* feat: Add TCP Addresses mock client

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

* chore: Update boilerplate.go.txt

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

* chore: Add helper function in common types

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

* test: Add kginkgo helpers

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

* test: Add more tests for LB services

In doing so, migrate to envtest to test the controller. Also fix a bug while we are at it

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

* docs: Add a spec for the service controller

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

---------

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
This commit is contained in:
Jonathan Stacks
2025-10-24 13:38:36 -05:00
committed by GitHub
parent df963d5fdd
commit 92b135c9c2
20 changed files with 1627 additions and 197 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
/*
MIT License
Copyright (c) 2024 ngrok, Inc.
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
+1 -1
View File
@@ -3,7 +3,7 @@
/*
MIT License
Copyright (c) 2024 ngrok, Inc.
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
+13
View File
@@ -55,3 +55,16 @@ type EndpointWithDomain interface {
GetDomainRef() *K8sObjectRefOptionalNamespace
SetDomainRef(*K8sObjectRefOptionalNamespace)
}
// ToClientObjectKey converts the K8sObjectRefOptionalNamespace to a client.ObjectKey,
// using the provided defaultNamespace if Namespace is nil
func (ref *K8sObjectRefOptionalNamespace) ToClientObjectKey(defaultNamespace string) client.ObjectKey {
namespace := defaultNamespace
if ref.Namespace != nil {
namespace = *ref.Namespace
}
return client.ObjectKey{
Name: ref.Name,
Namespace: namespace,
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
/*
MIT License
Copyright (c) 2024 ngrok, Inc.
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
+2 -1
View File
@@ -64,6 +64,7 @@ import (
gatewaycontroller "github.com/ngrok/ngrok-operator/internal/controller/gateway"
ingresscontroller "github.com/ngrok/ngrok-operator/internal/controller/ingress"
ngrokcontroller "github.com/ngrok/ngrok-operator/internal/controller/ngrok"
servicecontroller "github.com/ngrok/ngrok-operator/internal/controller/service"
"github.com/ngrok/ngrok-operator/internal/ngrokapi"
"github.com/ngrok/ngrok-operator/internal/util"
"github.com/ngrok/ngrok-operator/internal/version"
@@ -535,7 +536,7 @@ func enableIngressFeatureSet(_ context.Context, opts apiManagerOpts, mgr ctrl.Ma
return fmt.Errorf("unable to create ingress controller: %w", err)
}
if err := (&ingresscontroller.ServiceReconciler{
if err := (&servicecontroller.ServiceReconciler{
Client: mgr.GetClient(),
Log: ctrl.Log.WithName("controllers").WithName("service"),
Scheme: mgr.GetScheme(),
+1 -1
View File
@@ -1,7 +1,7 @@
/*
MIT License
Copyright (c) 2024 ngrok, Inc.
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
+4 -1
View File
@@ -48,6 +48,9 @@ const (
EndpointPoolingAnnotation = "k8s.ngrok.com/pooling-enabled"
EndpointPoolingAnnotationKey = "pooling-enabled"
TrafficPolicyAnnotation = "k8s.ngrok.com/traffic-policy"
TrafficPolicyAnnotationKey = "traffic-policy"
// This annotation can be used on a service to control whether the endpoint is a TCP or TLS endpoint.
// Examples:
// * tcp://1.tcp.ngrok.io:12345
@@ -70,7 +73,7 @@ const (
// Extracts a single traffic policy str from the annotation
// k8s.ngrok.com/traffic-policy: "module1"
func ExtractNgrokTrafficPolicyFromAnnotations(obj client.Object) (string, error) {
policies, err := parser.GetStringSliceAnnotation("traffic-policy", obj)
policies, err := parser.GetStringSliceAnnotation(TrafficPolicyAnnotationKey, obj)
if err != nil {
return "", err
+2 -2
View File
@@ -37,7 +37,7 @@ func TestExtractNgrokTrafficPolicyFromAnnotations(t *testing.T) {
{
name: "Valid traffic policy",
annotations: map[string]string{
"k8s.ngrok.com/traffic-policy": "policy1",
annotations.TrafficPolicyAnnotation: "policy1",
},
expected: "policy1",
expectedErr: nil,
@@ -51,7 +51,7 @@ func TestExtractNgrokTrafficPolicyFromAnnotations(t *testing.T) {
{
name: "Multiple traffic policies (invalid)",
annotations: map[string]string{
"k8s.ngrok.com/traffic-policy": "policy1,policy2",
annotations.TrafficPolicyAnnotation: "policy1,policy2",
},
expected: "",
expectedErr: errors.New("multiple traffic policies are not supported: [policy1 policy2]"),
@@ -56,10 +56,10 @@ var _ = Describe("BoundEndpoint Controller", func() {
})
Context("Single endpoint", func() {
It("should create services and set conditions", func() {
It("should create services and set conditions", func(ctx SpecContext) {
By("Creating target namespace")
expectCreateNs("test-namespace")
defer expectDeleteNs("test-namespace")
kginkgo.ExpectCreateNamespace(ctx, "test-namespace")
defer kginkgo.ExpectDeleteNamespace(ctx, "test-namespace")
By("Setting up mock API with one endpoint")
setMockEndpoints([]ngrok.Endpoint{
@@ -153,10 +153,10 @@ var _ = Describe("BoundEndpoint Controller", func() {
})
Context("Multiple endpoints", func() {
It("should aggregate endpoints targeting the same service", func() {
It("should aggregate endpoints targeting the same service", func(ctx SpecContext) {
By("Creating target namespace")
expectCreateNs("multi-namespace")
defer expectDeleteNs("multi-namespace")
kginkgo.ExpectCreateNamespace(ctx, "multi-namespace")
defer kginkgo.ExpectDeleteNamespace(ctx, "multi-namespace")
By("Setting up mock API with two endpoints pointing to same service")
setMockEndpoints([]ngrok.Endpoint{
@@ -235,10 +235,10 @@ var _ = Describe("BoundEndpoint Controller", func() {
})
Context("Status updates", func() {
It("should not get stuck in provisioning when adding endpoints", func() {
It("should not get stuck in provisioning when adding endpoints", func(ctx SpecContext) {
By("Creating target namespace")
expectCreateNs("status-namespace")
defer expectDeleteNs("status-namespace")
kginkgo.ExpectCreateNamespace(ctx, "status-namespace")
defer kginkgo.ExpectDeleteNamespace(ctx, "status-namespace")
By("Setting up mock API with one endpoint initially")
setMockEndpoints([]ngrok.Endpoint{
+2 -5
View File
@@ -65,9 +65,7 @@ var (
k8sManager ctrl.Manager
pollerController *BoundEndpointPoller
// Test helper closures
expectCreateNs func(string)
expectDeleteNs func(string)
kginkgo *testutils.KGinkgo
)
func TestControllers(t *testing.T) {
@@ -105,8 +103,7 @@ var _ = BeforeSuite(func() {
Expect(k8sClient).NotTo(BeNil())
// Initialize test helper closures
expectCreateNs = testutils.ExpectCreateNamespace(k8sClient)
expectDeleteNs = testutils.ExpectDeleteNamespace(k8sClient)
kginkgo = testutils.NewKGinkgo(k8sClient)
// Create the operator namespace that the poller will use
operatorNs := &v1.Namespace{
@@ -1,48 +0,0 @@
package ingress
import (
. "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"
)
func newTestService(isLoadBalancer bool, isOurLoadBalancerClass bool, annotations map[string]string) *corev1.Service {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "test-namespace",
Annotations: annotations,
},
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Name: "tcp",
Protocol: corev1.ProtocolTCP,
Port: 80,
},
},
},
}
if isLoadBalancer {
svc.Spec.Type = corev1.ServiceTypeLoadBalancer
}
if isOurLoadBalancerClass {
svc.Spec.LoadBalancerClass = ptr.To(NgrokLoadBalancerClass)
} else {
svc.Spec.LoadBalancerClass = ptr.To("not-ngrok")
}
return svc
}
var _ = Describe("ServiceController", func() {
DescribeTable("shouldHandleService", func(svc *corev1.Service, expected bool) {
Expect(shouldHandleService(svc)).To(Equal(expected))
},
Entry("Non-LoadBalancer service", newTestService(false, false, nil), false),
Entry("LoadBalancer service, but not our class", newTestService(true, false, nil), false),
Entry("LoadBalancer service, our class, but no annotations", newTestService(true, true, nil), true),
)
})
@@ -21,7 +21,7 @@ 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
package service
import (
"context"
@@ -55,6 +55,7 @@ import (
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
@@ -110,8 +111,7 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
}
controller := ctrl.NewControllerManagedBy(mgr).
For(&corev1.Service{}).
WithEventFilter(predicate.Funcs{
For(&corev1.Service{}, builder.WithPredicates(predicate.Funcs{
// Only handle services that are of type LoadBalancer and have the correct load balancer class
CreateFunc: func(e event.CreateEvent) bool {
svc, ok := e.Object.(*corev1.Service)
@@ -120,7 +120,7 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
}
return shouldHandleService(svc)
},
}).
})).
// Watch traffic policies for changes
Watches(
&ngrokv1alpha1.NgrokTrafficPolicy{},
@@ -246,7 +246,7 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
}
if len(svc.Spec.Ports) < 1 {
log.Info("Service has no ports, skipping")
r.Recorder.Event(svc, corev1.EventTypeWarning, "NoPorts", "Unable to handle service with no ports")
return ctrl.Result{}, nil
}
@@ -655,6 +655,7 @@ func (r *baseSubresourceReconciler[T, PT]) GetOwnedResources(ctx context.Context
if err != nil {
return nil, err
}
ptrs := make([]PT, len(owned))
objects := make([]client.Object, len(owned))
@@ -762,69 +763,7 @@ func newServiceCloudEndpointReconciler() serviceSubresourceReconciler {
existing.Spec = desired.Spec
},
updateStatus: func(ctx context.Context, c client.Client, svc *corev1.Service, endpoint *ngrokv1alpha1.CloudEndpoint) error {
clearIngressStatus := func(svc *corev1.Service) error {
svc.Status.LoadBalancer.Ingress = nil
return c.Status().Update(ctx, svc)
}
hostname := ""
port := int32(443)
// Check if the computed URL is set, if so, let's parse and use it
computedURL, err := annotations.ExtractComputedURL(svc)
switch {
case err == nil:
// Let's parse out the host and port
targetURL, err := url.Parse(computedURL)
if err != nil {
return err
}
hostname = targetURL.Hostname()
if p := targetURL.Port(); p != "" {
x, err := strconv.ParseInt(p, 10, 32)
if err != nil {
return err
}
port = int32(x)
}
case !errors.IsMissingAnnotations(err): // Some other error
return err
default: // computedURL not present, fallback to the domain annotation
domain, err := parser.GetStringAnnotation("domain", svc)
if err != nil {
if errors.IsMissingAnnotations(err) {
return clearIngressStatus(svc)
}
return err
}
// Use this domain temporarily, but also check if there is a
// more specific CNAME value on the domain to use
hostname = domain
if endpoint.Status.DomainRef != nil {
// Lookup the domain
domain := &ingressv1alpha1.Domain{}
if err := c.Get(ctx, client.ObjectKey{Namespace: *endpoint.Status.DomainRef.Namespace, Name: endpoint.Status.DomainRef.Name}, domain); err != nil {
return err
}
if domain.Status.CNAMETarget != nil {
hostname = *domain.Status.CNAMETarget
}
}
}
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{
{
Hostname: hostname,
Ports: []corev1.PortStatus{
{
Port: port,
Protocol: corev1.ProtocolTCP,
},
},
},
}
return c.Status().Update(ctx, svc)
return updateStatus(ctx, c, svc, endpoint)
},
}
}
@@ -844,9 +783,8 @@ func newServiceAgentEndpointReconciler() serviceSubresourceReconciler {
mergeExisting: func(desired ngrokv1alpha1.AgentEndpoint, existing *ngrokv1alpha1.AgentEndpoint) {
existing.Spec = desired.Spec
},
updateStatus: func(_ context.Context, _ client.Client, _ *corev1.Service, _ *ngrokv1alpha1.AgentEndpoint) error {
// AgentEndpoints don't interact with the service status
return nil
updateStatus: func(ctx context.Context, c client.Client, svc *corev1.Service, endpoint *ngrokv1alpha1.AgentEndpoint) error {
return updateStatus(ctx, c, svc, endpoint)
},
}
}
@@ -864,3 +802,71 @@ func getNgrokTrafficPolicyForService(ctx context.Context, c client.Client, svc *
err = c.Get(ctx, client.ObjectKey{Namespace: svc.Namespace, Name: policyName}, policy)
return policy, err
}
func updateStatus(ctx context.Context, c client.Client, svc *corev1.Service, endpoint ngrokv1alpha1.EndpointWithDomain) error {
clearIngressStatus := func(svc *corev1.Service) error {
svc.Status.LoadBalancer.Ingress = nil
return c.Status().Update(ctx, svc)
}
hostname := ""
port := int32(443)
// Check if the computed URL is set, if so, let's parse and use it
computedURL, err := annotations.ExtractComputedURL(svc)
switch {
case err == nil:
// Let's parse out the host and port
targetURL, err := url.Parse(computedURL)
if err != nil {
return err
}
hostname = targetURL.Hostname()
if p := targetURL.Port(); p != "" {
x, err := strconv.ParseInt(p, 10, 32)
if err != nil {
return err
}
port = int32(x)
}
case !errors.IsMissingAnnotations(err): // Some other error
return err
default: // computedURL not present, fallback to the domain annotation
domain, err := parser.GetStringAnnotation("domain", svc)
if err != nil {
if errors.IsMissingAnnotations(err) {
return clearIngressStatus(svc)
}
return err
}
// Use this domain temporarily, but also check if there is a
// more specific CNAME value on the domain to use
hostname = domain
dr := endpoint.GetDomainRef()
if dr != nil {
// Lookup the domain
domain := &ingressv1alpha1.Domain{}
if err := c.Get(ctx, client.ObjectKey{Namespace: *dr.Namespace, Name: dr.Name}, domain); err != nil {
return err
}
if domain.Status.CNAMETarget != nil {
hostname = *domain.Status.CNAMETarget
}
}
}
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{
{
Hostname: hostname,
Ports: []corev1.PortStatus{
{
Port: port,
Protocol: corev1.ProtocolTCP,
},
},
},
}
return c.Status().Update(ctx, svc)
}
@@ -0,0 +1,739 @@
/*
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"
"time"
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
"github.com/ngrok/ngrok-operator/internal/annotations"
"github.com/ngrok/ngrok-operator/internal/controller"
. "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 = controller.FinalizerName
Annotation_URL = annotations.URLAnnotation
Annotation_Domain = annotations.DomainAnnotation
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 = ptr.To(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 = "default"
svc *corev1.Service
modifiers *ServiceModifiers
)
BeforeEach(func() {
modifiers = &ServiceModifiers{
mods: []ServiceModifier{},
}
})
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() {
// Cleanup all services. For some reason, DeleteAllOf is not working for services and gives
// a “the server does not allow this method on the requested resource.” error.
var svcList corev1.ServiceList
Expect(k8sClient.List(ctx, &svcList, client.InNamespace(namespace))).To(Succeed())
for _, s := range svcList.Items {
err := k8sClient.Delete(ctx, &s, client.PropagationPolicy(metav1.DeletePropagationForeground))
Expect(client.IgnoreNotFound(err)).To(Succeed())
}
deleteAllOpts := []client.DeleteAllOfOption{
client.InNamespace(namespace),
client.PropagationPolicy(metav1.DeletePropagationForeground),
}
Expect(k8sClient.DeleteAllOf(ctx, &ngrokv1alpha1.AgentEndpoint{}, deleteAllOpts...)).To(Succeed())
Expect(k8sClient.DeleteAllOf(ctx, &ngrokv1alpha1.CloudEndpoint{}, deleteAllOpts...)).To(Succeed())
Expect(k8sClient.DeleteAllOf(ctx, &ngrokv1alpha1.NgrokTrafficPolicy{}, deleteAllOpts...)).To(Succeed())
})
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("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("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("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, func(g Gomega, fetched client.Object) {
By("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))
})
})
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("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())
}, 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("By checking an agent endpoint exists")
g.Expect(aeps.Items).To(HaveLen(1))
}, 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))
})
})
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("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))
})
// Change service type 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("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("By verifying agent endpoint was created initially")
g.Expect(aeps.Items).To(HaveLen(1))
}, timeout, interval).Should(Succeed())
// Change service type from LoadBalancer to ClusterIP
By("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() {
BeforeEach(func() {
modifiers.Add(AddAnnotation(Annotation_URL, "tls://example.ngrok.app"))
})
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://example.ngrok.app"))
})
})
It("should NOT update service status (known divergence from spec)", func() {
// This test documents a known divergence between the spec and implementation.
// The spec says tls:// URLs should update status, but the implementation only
// uses computed-url or domain annotations for status updates.
// The controller does NOT currently parse k8s.ngrok.com/url for status.
Consistently(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 empty (not populated from url annotation)")
g.Expect(fetched.Status.LoadBalancer.Ingress).To(BeEmpty())
}, duration, 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://example.ngrok.app"))
By("checking the agent endpoint URL has .internal suffix")
g.Expect(aeps[0].Spec.URL).To(ContainSubstring(".internal"))
})
})
})
})
When("service has domain annotation", func() {
BeforeEach(func() {
modifiers.Add(AddAnnotation(annotations.MappingStrategyAnnotation, string(annotations.MappingStrategy_EndpointsVerbose)))
modifiers.Add(AddAnnotation("k8s.ngrok.com/domain", "test.ngrok.app"))
})
It("should create a cloud endpoint with the specified domain", func() {
kginkgo.EventuallyWithCloudEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
By("checking a cloud endpoint exists")
g.Expect(cleps).To(HaveLen(1))
By("By checking the cloud endpoint has the correct URL with domain")
clep := cleps[0]
g.Expect(clep.Spec.URL).To(ContainSubstring("test.ngrok.app"))
})
})
It("should update service status with the domain", func() {
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(svc), fetched)
g.Expect(err).NotTo(HaveOccurred())
By("By checking the service status uses the domain")
g.Expect(fetched.Status.LoadBalancer.Ingress).NotTo(BeEmpty())
g.Expect(fetched.Status.LoadBalancer.Ingress[0].Hostname).To(ContainSubstring("test.ngrok.app"))
}, 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("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: ptr.To(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))
})
})
})
})
})
})
+145
View File
@@ -0,0 +1,145 @@
/*
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 (
"context"
"path/filepath"
"testing"
"github.com/ngrok/ngrok-operator/internal/mocks/nmockapi"
"github.com/ngrok/ngrok-operator/internal/testutils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/zap/zapcore"
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
"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"
)
// 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
tcpAddrsClient *nmockapi.TCPAddressesClient
ctx context.Context
cancel context.CancelFunc
kginkgo *testutils.KGinkgo
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(
zap.New(
zap.WriteTo(GinkgoWriter),
zap.UseDevMode(true),
zap.Level(zapcore.Level(-5)),
),
)
ctx, cancel = context.WithCancel(GinkgoT().Context())
By("bootstrapping test environment")
operatorAPIs := filepath.Join("..", "..", "..", "helm", "ngrok-operator", "templates", "crds")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{operatorAPIs},
}
var err error
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
err = scheme.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
err = ingressv1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
err = ngrokv1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
// Initialize Expect helpers
kginkgo = testutils.NewKGinkgo(k8sClient)
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())
tcpAddrsClient = nmockapi.NewTCPAddressClient()
err = (&ServiceReconciler{
Client: k8sManager.GetClient(),
Log: logf.Log.WithName("controllers").WithName("Service"),
Recorder: k8sManager.GetEventRecorderFor("service-controller"),
Scheme: k8sManager.GetScheme(),
TCPAddresses: tcpAddrsClient,
}).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())
})
var _ = BeforeEach(func() {
tcpAddrsClient.Reset()
})
+4 -4
View File
@@ -8,7 +8,7 @@ import (
)
const (
finalizerName = "k8s.ngrok.com/finalizer"
FinalizerName = "k8s.ngrok.com/finalizer"
)
func IsUpsert(o client.Object) bool {
@@ -20,15 +20,15 @@ func IsDelete(o client.Object) bool {
}
func HasFinalizer(o client.Object) bool {
return controllerutil.ContainsFinalizer(o, finalizerName)
return controllerutil.ContainsFinalizer(o, FinalizerName)
}
func AddFinalizer(o client.Object) bool {
return controllerutil.AddFinalizer(o, finalizerName)
return controllerutil.AddFinalizer(o, FinalizerName)
}
func RemoveFinalizer(o client.Object) bool {
return controllerutil.RemoveFinalizer(o, finalizerName)
return controllerutil.RemoveFinalizer(o, FinalizerName)
}
func RegisterAndSyncFinalizer(ctx context.Context, c client.Writer, o client.Object) error {
@@ -0,0 +1,45 @@
package nmockapi
import (
context "context"
"errors"
"fmt"
"math/rand"
"github.com/ngrok/ngrok-api-go/v7"
)
type TCPAddressesClient struct {
baseClient[*ngrok.ReservedAddr]
}
func NewTCPAddressClient() *TCPAddressesClient {
return &TCPAddressesClient{
baseClient: newBase[*ngrok.ReservedAddr](
"ra",
),
}
}
func (m *TCPAddressesClient) Create(_ context.Context, item *ngrok.ReservedAddrCreate) (*ngrok.ReservedAddr, error) {
id := m.newID()
newAddr := &ngrok.ReservedAddr{
ID: id,
CreatedAt: m.createdAt(),
Region: item.Region,
Description: item.Description,
URI: "https://mock-api.ngrok.com/reserved_addrs/" + id,
Addr: "0.tcp.ngrok.io:1",
}
// Generate a random port in the range 10000-30000
newAddr.Addr = fmt.Sprintf("%d.tcp.ngrok.io:%d", rand.Intn(7), rand.Intn(20000)+10000)
m.items[id] = newAddr
return newAddr, nil
}
func (m *TCPAddressesClient) Update(_ context.Context, _ *ngrok.ReservedAddrUpdate) (*ngrok.ReservedAddr, error) {
return nil, errors.New("not implemented")
}
@@ -0,0 +1,131 @@
package nmockapi
import (
context "context"
"github.com/ngrok/ngrok-api-go/v7"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TCPAddressesClient", func() {
var (
client *TCPAddressesClient
ctx context.Context
)
BeforeEach(func() {
client = NewTCPAddressClient()
ctx = GinkgoT().Context()
})
Describe("Create", func() {
var (
addr *ngrok.ReservedAddr
err error
)
JustBeforeEach(func() {
addr, err = client.Create(ctx, &ngrok.ReservedAddrCreate{
Region: "us",
Description: "test address",
})
})
It("should create a new reserved address", func() {
Expect(err).To(BeNil())
Expect(addr).ToNot(BeNil())
Expect(addr.ID).ToNot(BeEmpty())
Expect(addr.CreatedAt).ToNot(BeEmpty())
Expect(addr.Region).To(Equal("us"))
Expect(addr.Description).To(Equal("test address"))
Expect(addr.URI).To(ContainSubstring(addr.ID))
Expect(addr.Addr).To(MatchRegexp(`^[0-6]\.tcp\.ngrok\.io:[1-3]\d{4}$`))
})
})
Describe("List", func() {
var (
addrs []*ngrok.ReservedAddr
err error
)
JustBeforeEach(func(ctx SpecContext) {
addrs = []*ngrok.ReservedAddr{}
iter := client.List(nil)
for iter.Next(ctx) {
addrs = append(addrs, iter.Item())
}
err = iter.Err()
})
When("there are no addresses", func() {
It("the iterator should return an empty list", func() {
Expect(err).ToNot(HaveOccurred())
Expect(addrs).To(BeEmpty())
})
})
When("there are addresses", func() {
BeforeEach(func() {
_, createAddr1Err := client.Create(ctx, &ngrok.ReservedAddrCreate{
Region: "us",
Description: "addr 1",
})
Expect(createAddr1Err).ToNot(HaveOccurred())
_, createAddr2Err := client.Create(ctx, &ngrok.ReservedAddrCreate{
Region: "us",
Description: "addr 2",
})
Expect(createAddr2Err).ToNot(HaveOccurred())
})
It("the iterator should return the list of addresses", func() {
Expect(err).ToNot(HaveOccurred())
Expect(addrs).To(HaveLen(2))
})
})
})
Describe("Delete", func() {
var (
id string
delErr error
)
Context("when the address exists", func() {
BeforeEach(func() {
addr, err := client.Create(ctx, &ngrok.ReservedAddrCreate{
Region: "us",
Description: "to-delete",
})
Expect(err).ToNot(HaveOccurred())
id = addr.ID
})
JustBeforeEach(func() {
delErr = client.Delete(ctx, id)
})
It("should delete the address without error", func() {
Expect(delErr).ToNot(HaveOccurred())
})
})
Context("when the address does not exist", func() {
BeforeEach(func() {
id = "non-existent-id"
})
JustBeforeEach(func() {
delErr = client.Delete(ctx, id)
})
It("should return a not found error", func() {
Expect(delErr).To(HaveOccurred())
Expect(ngrok.IsNotFound(delErr)).To(BeTrue())
})
})
})
})
-52
View File
@@ -1,52 +0,0 @@
package testutils
import (
"context"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// ExpectCreateNamespace returns a function that creates a namespace with the given name.
// The function uses Gomega's Expect internally, so it should only be used in Ginkgo tests.
//
// Example usage:
//
// createNs := testutils.ExpectCreateNamespace(k8sClient)
// createNs("test-namespace")
// defer testutils.ExpectDeleteNamespace(k8sClient)("test-namespace")
func ExpectCreateNamespace(k8sClient client.Client) func(string) {
return func(name string) {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
Expect(k8sClient.Create(context.Background(), ns)).To(Succeed())
}
}
// ExpectDeleteNamespace returns a function that deletes a namespace with the given name.
// The function uses Gomega's Expect internally and expects the delete to succeed or return NotFound.
// This is useful for cleaning up namespaces in defer statements.
//
// Example usage:
//
// deleteNs := testutils.ExpectDeleteNamespace(k8sClient)
// defer deleteNs("test-namespace")
func ExpectDeleteNamespace(k8sClient client.Client) func(string) {
return func(name string) {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
err := k8sClient.Delete(context.Background(), ns)
if err != nil && !apierrors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
}
}
}
+378
View File
@@ -0,0 +1,378 @@
package testutils
import (
"context"
"time"
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
DefaultTimeout = 20 * time.Second
DefaultInterval = 500 * time.Millisecond
)
// KGinkgo is a helper for Ginkgo tests that interact with Kubernetes resources.
// It provides methods to assert conditions on Kubernetes objects using Gomega matchers, especially in conjunction with Eventually.
type KGinkgo struct {
client client.Client
}
// NewKGinkgo creates a new KGinkgo instance
func NewKGinkgo(c client.Client) *KGinkgo {
return &KGinkgo{
client: c,
}
}
type expectOptions struct {
timeout time.Duration
interval time.Duration
}
type KGinkgoOpt func(*expectOptions)
func WithTimeout(timeout time.Duration) KGinkgoOpt {
return func(o *expectOptions) {
o.timeout = timeout
}
}
func WithInterval(interval time.Duration) KGinkgoOpt {
return func(o *expectOptions) {
o.interval = interval
}
}
// ExpectCreateNamespace creates a namespace with the given name.
// The function uses Gomega's Expect internally, so it should only be used in Ginkgo tests.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// namespace := "test-namespace"
// kginkgo.ExpectCreateNamespace(ctx, namespace)
// defer testutils.ExpectDeleteNamespace(namespace)
func (k *KGinkgo) ExpectCreateNamespace(ctx context.Context, name string) {
GinkgoHelper()
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
Expect(k.client.Create(ctx, ns)).To(Succeed())
}
// ExpectDeleteNamespace deletes a namespace with the given name.
// The function uses Gomega's Expect internally and expects the delete to succeed or return NotFound.
// This is useful for cleaning up namespaces in defer statements.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// namespace := "test-namespace"
// defer kginkgo.ExpectDeleteNamespace(ctx, namespace)
func (k *KGinkgo) ExpectDeleteNamespace(ctx context.Context, name string) {
GinkgoHelper()
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
err := k.client.Delete(ctx, ns)
if err != nil && !apierrors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
}
}
// ExpectFinalizerToBeAdded asserts that the specified finalizer is eventually added to the given Kubernetes object.
// It will continually update the object from the client to check for the finalizer
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.ExpectFinalizerToBeAdded(ctx, myObject, "my.finalizer.io")
func (k *KGinkgo) ExpectFinalizerToBeAdded(ctx context.Context, obj client.Object, finalizer string, opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
key := client.ObjectKeyFromObject(obj)
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k.client.Get(ctx, key, fetched)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(fetched.GetFinalizers()).To(ContainElement(finalizer))
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// ExpectFinalizerToExist asserts that the specified finalizer eventually exists on the given Kubernetes object.
// It will continually update the object from the client to check for the finalizer
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.ExpectFinalizerToBeRemoved(ctx, myObject, "my.finalizer.io")
func (k *KGinkgo) ExpectFinalizerToBeRemoved(ctx context.Context, obj client.Object, finalizer string, opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
key := client.ObjectKeyFromObject(obj)
Eventually(func(g Gomega) {
fetched := &corev1.Service{}
err := k.client.Get(ctx, key, fetched)
// If the object is not found, the finalizer has been removed and the object deleted
if client.IgnoreNotFound(err) == nil {
return
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(fetched.GetFinalizers()).ToNot(ContainElement(finalizer))
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// ExpectHasAnnotation asserts that the given Kubernetes object eventually has the specified annotation key.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.ExpectHasAnnotation(ctx, myObject, "my.annotation/key")
func (k *KGinkgo) ExpectHasAnnotation(ctx context.Context, obj client.Object, key string, opts ...KGinkgoOpt) {
GinkgoHelper()
k.EventuallyWithObject(ctx, obj, func(g Gomega, fetched client.Object) {
annotations := fetched.GetAnnotations()
g.Expect(annotations).NotTo(BeEmpty())
g.Expect(annotations).To(HaveKey(key))
}, opts...)
}
// ExpectAnnotationValue asserts that the given Kubernetes object eventually has the specified annotation key with the expected value.
// It will continually update the object from the client to check for the annotation and its value.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.ExpectAnnotationValue(ctx, myObject, "my.annotation/key", "expected-value")
func (k *KGinkgo) ExpectAnnotationValue(ctx context.Context, obj client.Object, key, expectedValue string, opts ...KGinkgoOpt) {
GinkgoHelper()
k.EventuallyWithObject(ctx, obj, func(g Gomega, fetched client.Object) {
annotations := fetched.GetAnnotations()
g.Expect(annotations).NotTo(BeEmpty())
actualValue, exists := annotations[key]
g.Expect(exists).To(BeTrue(), "expected annotation %q to exist", key)
g.Expect(actualValue).To(Equal(expectedValue), "expected annotation %q to have value %q but got %q", key, expectedValue, actualValue)
}, opts...)
}
// EventuallyWithObject continually fetches the given Kubernetes object and invokes the inner function with the fetched object.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// check := func(g Gomega, fetched client.Object) {
// g.Expect(fetched.GetAnnotations()).To(HaveKey("my.annotation/key"))
// }
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.EventuallyWithObject(ctx, myObject, check)
func (k *KGinkgo) EventuallyWithObject(ctx context.Context, obj client.Object, inner func(g Gomega, fetched client.Object), opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
objKey := client.ObjectKeyFromObject(obj)
Eventually(func(g Gomega) {
fetched := obj.DeepCopyObject().(client.Object)
g.Expect(k.client.Get(ctx, objKey, fetched)).NotTo(HaveOccurred())
inner(g, fetched)
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// EventuallyWithCloudEndpoints continually fetches the CloudEndpoints in the given namespace and invokes the inner function with the list.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// check := func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
// g.Expect(len(cleps)).To(Equal(2))
// }
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.EventuallyWithCloudEndpoints(ctx, "test-namespace", check)
func (k *KGinkgo) EventuallyWithCloudEndpoints(ctx context.Context, namespace string, inner func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint), opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
Eventually(func(g Gomega) {
// List CloudEndpoints in the namespace
cleps, err := k.getCloudEndpoints(ctx, namespace)
g.Expect(err).NotTo(HaveOccurred())
inner(g, cleps)
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// ConsistentlyWithCloudEndpoints continually fetches the CloudEndpoints in the given namespace and invokes the inner function with the list.
// The function uses Gomega's Consistently internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// check := func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint) {
// g.Expect(len(cleps)).To(Equal(2))
// }
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.ConsistentlyWithCloudEndpoints(ctx, "test-namespace", check)
func (k *KGinkgo) ConsistentlyWithCloudEndpoints(ctx context.Context, namespace string, inner func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint), opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
Consistently(func(g Gomega) {
// List CloudEndpoints in the namespace
cleps, err := k.getCloudEndpoints(ctx, namespace)
g.Expect(err).NotTo(HaveOccurred())
inner(g, cleps)
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// EventuallyWithAgentEndpoints continually fetches the AgentEndpoints in the given namespace and invokes the inner function with the list.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// check := func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint) {
// g.Expect(len(aeps)).To(Equal(2))
// }
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.EventuallyWithAgentEndpoints(ctx, "test-namespace", check)
func (k *KGinkgo) EventuallyWithAgentEndpoints(ctx context.Context, namespace string, inner func(g Gomega, aeps []ngrokv1alpha1.AgentEndpoint), opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
Eventually(func(g Gomega) {
// List AgentEndpoints in the namespace
aeps, err := k.getAgentEndpoints(ctx, namespace)
g.Expect(err).NotTo(HaveOccurred())
inner(g, aeps)
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// EventuallyWithCloudAndAgentEndpoints continually fetches both CloudEndpoints and AgentEndpoints in the given namespace
// and invokes the inner function with both lists.
// The function uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// check := func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
// g.Expect(len(cleps)).To(Equal(2))
// g.Expect(len(aeps)).To(Equal(3))
// }
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.EventuallyWithCloudAndAgentEndpoints(ctx, "test-namespace", check)
func (k *KGinkgo) EventuallyWithCloudAndAgentEndpoints(ctx context.Context, namespace string, inner func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint), opts ...KGinkgoOpt) {
GinkgoHelper()
eo := makeKGinkgoOptions(opts...)
Eventually(func(g Gomega) {
// List CloudEndpoints in the namespace
cleps, err := k.getCloudEndpoints(ctx, namespace)
g.Expect(err).NotTo(HaveOccurred())
// List AgentEndpoints in the namespace
aeps, err := k.getAgentEndpoints(ctx, namespace)
g.Expect(err).NotTo(HaveOccurred())
inner(g, cleps, aeps)
}).WithTimeout(eo.timeout).WithPolling(eo.interval).Should(Succeed())
}
// EventuallyExpectNoEndpoints asserts that there are eventually no CloudEndpoints or AgentEndpoints in the given namespace.
// It uses Gomega's Eventually internally, so it should only be used in Ginkgo tests.
// You can pass optional KGinkgoOpt parameters to customize the timeout and polling interval.
//
// Example usage:
//
// kginkgo := testutils.NewKGinkgo(k8sClient)
// kginkgo.EventuallyExpectNoEndpoints(ctx, "test-namespace")
func (k *KGinkgo) EventuallyExpectNoEndpoints(ctx context.Context, namespace string, opts ...KGinkgoOpt) {
GinkgoHelper()
By("verifying no cloud or agent endpoints remain")
k.EventuallyWithCloudAndAgentEndpoints(ctx, namespace, func(g Gomega, cleps []ngrokv1alpha1.CloudEndpoint, aeps []ngrokv1alpha1.AgentEndpoint) {
By("verifying no cloud endpoints remain")
g.Expect(cleps).To(BeEmpty())
By("verifying no agent endpoints remain")
g.Expect(aeps).To(BeEmpty())
}, opts...)
}
func (k *KGinkgo) getCloudEndpoints(ctx context.Context, namespace string) ([]ngrokv1alpha1.CloudEndpoint, error) {
GinkgoHelper()
clepList := &ngrokv1alpha1.CloudEndpointList{}
listOpts := []client.ListOption{
client.InNamespace(namespace),
}
if err := k.client.List(ctx, clepList, listOpts...); err != nil {
return nil, err
}
return clepList.Items, nil
}
func (k *KGinkgo) getAgentEndpoints(ctx context.Context, namespace string) ([]ngrokv1alpha1.AgentEndpoint, error) {
GinkgoHelper()
aepList := &ngrokv1alpha1.AgentEndpointList{}
listOpts := []client.ListOption{
client.InNamespace(namespace),
}
if err := k.client.List(ctx, aepList, listOpts...); err != nil {
return nil, err
}
return aepList.Items, nil
}
func makeKGinkgoOptions(opts ...KGinkgoOpt) *expectOptions {
eo := &expectOptions{
timeout: DefaultTimeout,
interval: DefaultInterval,
}
for _, o := range opts {
o(eo)
}
return eo
}
+72
View File
@@ -0,0 +1,72 @@
# Service LoadBalancer Controller Specification
## Executive Summary
The Service LoadBalancer controller reconciles Kubernetes Services of type `LoadBalancer` with the ngrok load balancer class
(`loadBalancerClass: ngrok`). It materializes these Services in ngrok endpoint Custom Resources (CloudEndpoint and/or AgentEndpoint), applies NgrokTrafficPolicy configurations, and updates Service status with the externally reachable address (hostname/port).
## Features
### Common Behavior
The ngrok Service LoadBalancer controller will only manage Services(`corev1.Service`) that meet the following criteria:
- `spec.type: LoadBalancer`
- `spec.loadBalancerClass: ngrok`
If these criteria are not met, the controller will clean up any previously created ngrok endpoints and remove its finalizer from the Service.
When managing a qualifying Service, the controller will:
1. Add a finalizer(`k8s.ngrok.com/finalizer`) to the Service to ensure proper cleanup on deletion.
2. Create and manage a single `CloudEndpoint` and/or `AgentEndpoint` resource based on the mapping strategy. An owner reference to the Service will be set on the created endpoint(s).
2. If the traffic-policy annotation is present, resolve the traffic policy and apply it to the created endpoint(s).
3. Update the Service's `status.loadBalancer.ingress` field with the externally reachable hostname and port.
### TCP Load Balancers
TCP Load Balancer is the default behavior. When the domain annotation is not specified, or the `k8s.ngrok.com/url` annotation specifies a `tcp://` scheme,
the controller will create a TCP Load Balancer.
### TLS Termination
When a Service specifies a domain or url with the `tls://` scheme, the controller will create a TLS-terminated load balancer.
### Annotations
#### `k8s.ngrok.com/domain` (deprecated)
Signifies intent to create a TLS-terminated load balancer with the specified domain.
#### `k8s.ngrok.com/mapping-strategy`
Allowed values: `endpoints`, `endpoints-verbose`
Default Value: `endpoints`
When unspecified, it defaults to `endpoints` and only an `AgentEndpoint` will be created for the Service.
When set to `endpoints-verbose`, both a `CloudEndpoint` and an internal `AgentEndpoint`, an endpoint with a url ending in `.internal` will be created for the Service.
#### `k8s.ngrok.com/url`
This replaces the deprecated `k8s.ngrok.com/domain` annotation.
Examples:
* `k8s.ngrok.com/url: "tcp://1.tcp.ngrok.io:12345"` - Creates a TCP load balancer using the specified ngrok TCP address. It must be reserved in the ngrok dashboard/API first.
* `k8s.ngrok.com/url: "tcp://"` - Creates a TCP load balancer using a dynamically assigned ngrok TCP address.
* `k8s.ngrok.com/url: "tls://example.com"` - Creates a TLS-terminated load balancer for the specified domain.
#### `k8s.ngrok.com/traffic-policy`
Specifies the name of a `NgrokTrafficPolicy` resource in the same namespace to apply to the created endpoint(s).
The controller will watch for changes to the referenced `NgrokTrafficPolicy` and update the endpoint(s) accordingly.
When the mapping strategy is `endpoints-verbose`, the traffic policy will be applied to the `CloudEndpoint`.
When the mapping strategy is `endpoints`, the traffic policy will be applied to the `AgentEndpoint`.
#### `k8s.ngrok.com/computed-url` (internal)
This annotation is set by the controller to reflect the actual externally reachable URL of the load balancer.
In the case of TCP load balancers with dynamically assigned addresses, this annotation will contain the assigned ngrok TCP address.
### Special Cases
When an eligible Service has no ports defined, the controller will emit a warning event and will not create any endpoints.