mirror of
https://github.com/ngrok/ngrok-operator.git
synced 2026-05-17 16:50:44 +00:00
aa1781d348
* chore: Update to go 1.26.1 Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com> * chore: Run 'go fix ./...' for go 1.26.1 Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com> * chore: Upgrade go modules Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com> * chore: Fix deprecations and linter warnings Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com> --------- Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
396 lines
14 KiB
Go
396 lines
14 KiB
Go
/*
|
|
MIT License
|
|
|
|
Copyright (c) 2024 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 ngrok
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
v1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/client-go/tools/events"
|
|
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/predicate"
|
|
|
|
"github.com/go-logr/logr"
|
|
"github.com/ngrok/ngrok-api-go/v7"
|
|
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
|
|
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
|
|
"github.com/ngrok/ngrok-operator/internal/controller"
|
|
"github.com/ngrok/ngrok-operator/internal/controller/labels"
|
|
domainpkg "github.com/ngrok/ngrok-operator/internal/domain"
|
|
"github.com/ngrok/ngrok-operator/internal/ngrokapi"
|
|
)
|
|
|
|
const (
|
|
trafficPolicyNameIndex = "spec.trafficPolicyName"
|
|
)
|
|
|
|
// CloudEndpointReconciler reconciles a CloudEndpoint object
|
|
type CloudEndpointReconciler struct {
|
|
client.Client
|
|
Scheme *runtime.Scheme
|
|
controller *controller.BaseController[*ngrokv1alpha1.CloudEndpoint]
|
|
|
|
Log logr.Logger
|
|
Recorder events.EventRecorder
|
|
NgrokClientset ngrokapi.Clientset
|
|
DrainState controller.DrainState
|
|
|
|
ControllerLabels labels.ControllerLabelValues
|
|
DefaultDomainReclaimPolicy *ingressv1alpha1.DomainReclaimPolicy
|
|
DomainManager *domainpkg.Manager
|
|
}
|
|
|
|
// Define a custom error types to catch and handle requeuing logic for
|
|
var (
|
|
ErrInvalidTrafficPolicyConfig = errors.New("invalid TrafficPolicy configuration: both TrafficPolicyName and TrafficPolicy are set")
|
|
)
|
|
|
|
// SetupWithManager sets up the controller with the Manager.
|
|
// It also sets up a Field Indexer to index Cloud Endpoints by their Traffic Policy name
|
|
// Additionally, this triggers updates when a trafficPolicy is created or updated but not when deleted
|
|
func (r *CloudEndpointReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
if r.NgrokClientset == nil {
|
|
return errors.New("NgrokClientset is required")
|
|
}
|
|
|
|
// Initialize domain manager if not already set
|
|
if r.DomainManager == nil {
|
|
if err := labels.ValidateControllerLabelValues(r.ControllerLabels); err != nil {
|
|
return err
|
|
}
|
|
|
|
opts := []domainpkg.ManagerOption{
|
|
domainpkg.WithControllerLabels(r.ControllerLabels),
|
|
}
|
|
|
|
if r.DefaultDomainReclaimPolicy != nil {
|
|
opts = append(opts, domainpkg.WithDefaultDomainReclaimPolicy(*r.DefaultDomainReclaimPolicy))
|
|
}
|
|
|
|
dm, err := domainpkg.NewManager(r.Client, r.Recorder, opts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.DomainManager = dm
|
|
}
|
|
|
|
r.controller = &controller.BaseController[*ngrokv1alpha1.CloudEndpoint]{
|
|
Kube: r.Client,
|
|
Log: r.Log,
|
|
Recorder: r.Recorder,
|
|
DrainState: r.DrainState,
|
|
|
|
StatusID: func(clep *ngrokv1alpha1.CloudEndpoint) string { return clep.Status.ID },
|
|
Create: r.create,
|
|
Update: r.update,
|
|
Delete: r.delete,
|
|
ErrResult: func(_ controller.BaseControllerOp, cr *ngrokv1alpha1.CloudEndpoint, err error) (ctrl.Result, error) {
|
|
retryableErrors := []int{
|
|
// 18016 and 18017 are state based errors that can happen when endpoint pooling for a given URL
|
|
// disagrees with an already active endpoint with the same URL. Since this state can change in ngrok when moving
|
|
// between agent and cloud endpoints, we need to retry on this 400, instead of assuming its terminal like we
|
|
// do for other 400s.
|
|
//
|
|
// Ref:
|
|
// * https://ngrok.com/docs/errors/err_ngrok_18016/
|
|
// * https://ngrok.com/docs/errors/err_ngrok_18017/
|
|
18016,
|
|
18017,
|
|
}
|
|
if ngrok.IsErrorCode(err, retryableErrors...) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if errors.Is(err, domainpkg.ErrDomainNotReady) {
|
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
|
}
|
|
if errors.Is(err, ErrInvalidTrafficPolicyConfig) {
|
|
r.Recorder.Eventf(cr, nil, v1.EventTypeWarning, "ConfigError", "Reconcile", err.Error())
|
|
r.Log.Error(err, "invalid TrafficPolicy configuration", "name", cr.Name, "namespace", cr.Namespace)
|
|
return ctrl.Result{}, nil // Do not requeue
|
|
}
|
|
return controller.CtrlResultForErr(err)
|
|
},
|
|
}
|
|
|
|
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &ngrokv1alpha1.CloudEndpoint{}, trafficPolicyNameIndex, func(o client.Object) []string {
|
|
clep, ok := o.(*ngrokv1alpha1.CloudEndpoint)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return []string{clep.Spec.TrafficPolicyName}
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
For(&ngrokv1alpha1.CloudEndpoint{}, builder.WithPredicates(
|
|
predicate.GenerationChangedPredicate{},
|
|
)).
|
|
Watches(
|
|
&ngrokv1alpha1.NgrokTrafficPolicy{},
|
|
r.controller.NewEnqueueRequestForMapFunc(r.findCloudEndpointForTrafficPolicy),
|
|
// Don't process delete events as it will just fail to look it up.
|
|
// Instead rely on the user to either delete the CloudEndpoint CR or update it with a new TrafficPolicy name
|
|
builder.WithPredicates(&predicate.Funcs{
|
|
DeleteFunc: func(_ event.DeleteEvent) bool {
|
|
return false
|
|
},
|
|
}),
|
|
).
|
|
Watches(
|
|
&ingressv1alpha1.Domain{},
|
|
r.controller.NewEnqueueRequestForMapFunc(r.findCloudEndpointsForDomain),
|
|
).
|
|
Complete(r)
|
|
}
|
|
|
|
// #region Reconcile CRUD
|
|
|
|
// +kubebuilder:rbac:groups=ngrok.k8s.ngrok.com,resources=cloudendpoints,verbs=get;list;watch;create;update;patch;delete
|
|
// +kubebuilder:rbac:groups=ngrok.k8s.ngrok.com,resources=cloudendpoints/status,verbs=get;update;patch
|
|
// +kubebuilder:rbac:groups=ngrok.k8s.ngrok.com,resources=cloudendpoints/finalizers,verbs=update
|
|
|
|
func (r *CloudEndpointReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
return r.controller.Reconcile(ctx, req, new(ngrokv1alpha1.CloudEndpoint))
|
|
}
|
|
|
|
// Create will make sure a domain is created before creating the Cloud Endpoint
|
|
// It also looks up the Traffic Policy and creates the Cloud Endpoint using this Traffic Policy JSON
|
|
func (r *CloudEndpointReconciler) create(ctx context.Context, clep *ngrokv1alpha1.CloudEndpoint) error {
|
|
// EnsureDomainExists handles its own domain-related status
|
|
domainResult, err := r.DomainManager.EnsureDomainExists(ctx, clep)
|
|
if err != nil {
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
policy, err := r.getTrafficPolicy(ctx, clep)
|
|
if err != nil {
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
createParams := &ngrok.EndpointCreate{
|
|
Type: "cloud",
|
|
URL: clep.Spec.URL,
|
|
Description: &clep.Spec.Description,
|
|
Metadata: &clep.Spec.Metadata,
|
|
TrafficPolicy: policy,
|
|
Bindings: clep.Spec.Bindings,
|
|
PoolingEnabled: clep.Spec.PoolingEnabled,
|
|
}
|
|
|
|
ngrokClep, err := r.NgrokClientset.Endpoints().Create(ctx, createParams)
|
|
if err != nil {
|
|
setCloudEndpointCreatedCondition(clep, false, ReasonCloudEndpointCreationFailed, fmt.Sprintf("Failed to create cloud endpoint: %v", err))
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
// Set success condition
|
|
setCloudEndpointCreatedCondition(clep, true, ReasonCloudEndpointCreated, "CloudEndpoint created successfully")
|
|
|
|
return r.updateStatus(ctx, clep, ngrokClep, domainResult, nil)
|
|
}
|
|
|
|
// Update is called when we have a status ID and want to update the resource in the ngrok API
|
|
// If it fails to find the resource by ID, create a new one instead
|
|
func (r *CloudEndpointReconciler) update(ctx context.Context, clep *ngrokv1alpha1.CloudEndpoint) error {
|
|
domainResult, err := r.DomainManager.EnsureDomainExists(ctx, clep)
|
|
if err != nil {
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
policy, err := r.getTrafficPolicy(ctx, clep)
|
|
if err != nil {
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
updateParams := &ngrok.EndpointUpdate{
|
|
ID: clep.Status.ID,
|
|
Url: &clep.Spec.URL,
|
|
Description: &clep.Spec.Description,
|
|
Metadata: &clep.Spec.Metadata,
|
|
TrafficPolicy: &policy,
|
|
Bindings: clep.Spec.Bindings,
|
|
PoolingEnabled: clep.Spec.PoolingEnabled,
|
|
}
|
|
|
|
ngrokClep, err := r.NgrokClientset.Endpoints().Update(ctx, updateParams)
|
|
if ngrok.IsNotFound(err) {
|
|
// Couldn't find endpoint by ID to update, so blank it out and create a new one
|
|
r.Recorder.Eventf(clep, nil, v1.EventTypeWarning, "EndpointNotFound", "Reconcile", fmt.Sprintf("Failed to update endpoint %s by ID because it was not found. Creating a new one", clep.Status.ID))
|
|
clep.Status.ID = ""
|
|
_ = r.Client.Status().Update(ctx, clep)
|
|
return r.create(ctx, clep)
|
|
}
|
|
if err != nil {
|
|
setCloudEndpointCreatedCondition(clep, false, ReasonCloudEndpointCreationFailed, fmt.Sprintf("Failed to update cloud endpoint: %v", err))
|
|
return r.updateStatus(ctx, clep, nil, domainResult, err)
|
|
}
|
|
|
|
// Set success condition
|
|
setCloudEndpointCreatedCondition(clep, true, ReasonCloudEndpointCreated, "CloudEndpoint updated successfully")
|
|
|
|
return r.updateStatus(ctx, clep, ngrokClep, domainResult, nil)
|
|
}
|
|
|
|
// Simply attempt to delete it. The base controller handles not found errors
|
|
func (r *CloudEndpointReconciler) delete(ctx context.Context, clep *ngrokv1alpha1.CloudEndpoint) error {
|
|
return r.NgrokClientset.Endpoints().Delete(ctx, clep.Status.ID)
|
|
}
|
|
|
|
func (r *CloudEndpointReconciler) updateStatus(ctx context.Context, clep *ngrokv1alpha1.CloudEndpoint, ngrokClep *ngrok.Endpoint, domainResult *domainpkg.DomainResult, statusErr error) error {
|
|
// Update status fields if we have an endpoint
|
|
if ngrokClep != nil {
|
|
clep.Status.ID = ngrokClep.ID
|
|
}
|
|
|
|
// Calculate overall Ready condition based on other conditions and domain status
|
|
calculateCloudEndpointReadyCondition(clep, domainResult)
|
|
|
|
// Write status to k8s API
|
|
if err := r.controller.ReconcileStatus(ctx, clep, statusErr); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Requeue if domain is not ready (fallback to watch for convergence)
|
|
if domainResult != nil {
|
|
return domainResult.RequeueError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// #region Helper Functions
|
|
|
|
// findCloudEndpointForTrafficPolicy searches for any Cloud Endpoints CRs that have a reference to a particular Traffic Policy
|
|
func (r *CloudEndpointReconciler) findCloudEndpointForTrafficPolicy(ctx context.Context, o client.Object) []ctrl.Request {
|
|
tp, ok := o.(*ngrokv1alpha1.NgrokTrafficPolicy)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Use the index to find CloudEndpoints that reference this TrafficPolicy
|
|
var cloudEndpointList ngrokv1alpha1.CloudEndpointList
|
|
if err := r.Client.List(ctx, &cloudEndpointList,
|
|
client.InNamespace(tp.Namespace),
|
|
client.MatchingFields{trafficPolicyNameIndex: tp.Name}); err != nil {
|
|
r.Log.Error(err, "failed to list CloudEndpoints using index")
|
|
return nil
|
|
}
|
|
|
|
// Collect the requests for matching CloudEndpoints
|
|
var requests []ctrl.Request
|
|
for _, clep := range cloudEndpointList.Items {
|
|
requests = append(requests, ctrl.Request{
|
|
NamespacedName: client.ObjectKey{
|
|
Name: clep.Name,
|
|
Namespace: clep.Namespace,
|
|
},
|
|
})
|
|
}
|
|
|
|
return requests
|
|
}
|
|
|
|
// findCloudEndpointsForDomain searches for any CloudEndpoint CRs that reference a particular Domain
|
|
func (r *CloudEndpointReconciler) findCloudEndpointsForDomain(ctx context.Context, o client.Object) []ctrl.Request {
|
|
domain, ok := o.(*ingressv1alpha1.Domain)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
var endpoints ngrokv1alpha1.CloudEndpointList
|
|
if err := r.Client.List(ctx, &endpoints, client.InNamespace(domain.Namespace)); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var requests []ctrl.Request
|
|
for _, ep := range endpoints.Items {
|
|
if ep.GetDomainRef().Matches(domain) {
|
|
requests = append(requests, ctrl.Request{
|
|
NamespacedName: client.ObjectKeyFromObject(&ep),
|
|
})
|
|
}
|
|
}
|
|
return requests
|
|
}
|
|
|
|
// getTrafficPolicy returns the TrafficPolicy JSON string from either the name reference or inline policy
|
|
func (r *CloudEndpointReconciler) getTrafficPolicy(ctx context.Context, clep *ngrokv1alpha1.CloudEndpoint) (string, error) {
|
|
// Ensure mutually exclusive fields are not both set
|
|
if clep.Spec.TrafficPolicyName != "" && clep.Spec.TrafficPolicy != nil {
|
|
return "", ErrInvalidTrafficPolicyConfig
|
|
}
|
|
|
|
var policy string
|
|
var err error
|
|
|
|
// Handle either finding the TrafficPolicy by name or using the inline policy
|
|
if clep.Spec.TrafficPolicyName != "" {
|
|
policy, err = r.findTrafficPolicyByName(ctx, clep.Spec.TrafficPolicyName, clep.Namespace)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
} else if clep.Spec.TrafficPolicy != nil {
|
|
// Marshal the inline TrafficPolicy to JSON
|
|
policyBytes, err := clep.Spec.TrafficPolicy.Policy.MarshalJSON()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal inline TrafficPolicy: %w", err)
|
|
}
|
|
policy = string(policyBytes)
|
|
}
|
|
|
|
return policy, nil
|
|
}
|
|
|
|
// findTrafficPolicyByName fetches the TrafficPolicy CRD from the API server and returns the JSON policy as a string
|
|
func (r *CloudEndpointReconciler) findTrafficPolicyByName(ctx context.Context, tpName, tpNamespace string) (string, error) {
|
|
log := ctrl.LoggerFrom(ctx).WithValues("name", tpName, "namespace", tpNamespace)
|
|
|
|
// Create a TrafficPolicy object to store the fetched result
|
|
tp := &ngrokv1alpha1.NgrokTrafficPolicy{}
|
|
key := client.ObjectKey{Name: tpName, Namespace: tpNamespace}
|
|
|
|
// Attempt to get the TrafficPolicy from the API server
|
|
if err := r.Client.Get(ctx, key, tp); err != nil {
|
|
r.Recorder.Eventf(tp, nil, v1.EventTypeWarning, "TrafficPolicyNotFound", "Reconcile", fmt.Sprintf("Failed to find TrafficPolicy %s", tpName))
|
|
return "", err
|
|
}
|
|
|
|
// Convert the JSON policy to a string
|
|
policyBytes, err := tp.Spec.Policy.MarshalJSON()
|
|
if err != nil {
|
|
log.Error(err, "failed to marshal TrafficPolicy JSON")
|
|
return "", err
|
|
}
|
|
|
|
return string(policyBytes), nil
|
|
}
|