mirror of
https://github.com/ngrok/ngrok-operator.git
synced 2026-05-17 16:50:44 +00:00
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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
+77
-71
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user