Files
feedc0de 5cbd3da40b
Validate and publish / Test image and chart (push) Successful in 2m58s
Validate and publish / Publish image and OCI chart (push) Failing after 23s
Update CI and package prefix
2026-08-28 23:22:26 +02:00

189 lines
7.1 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
mikrotikv1alpha1 "code.brunner.ninja/feedc0de/mikrotik-operator/api/v1alpha1"
"code.brunner.ninja/feedc0de/mikrotik-operator/internal/routeros"
)
const (
syncInterval = 2 * time.Minute
routerFinalizer = "mikrotik.brunner.ninja/nat-cleanup"
)
type RouterClientFactory func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error)
type MikroTikRouterReconciler struct {
client.Client
Scheme *runtime.Scheme
ClientFactory RouterClientFactory
}
func (r *MikroTikRouterReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
var router mikrotikv1alpha1.MikroTikRouter
if err := r.Get(ctx, request.NamespacedName, &router); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !router.DeletionTimestamp.IsZero() {
return r.finalize(ctx, &router)
}
if !controllerutil.ContainsFinalizer(&router, routerFinalizer) {
controllerutil.AddFinalizer(&router, routerFinalizer)
if err := r.Update(ctx, &router); err != nil {
return ctrl.Result{}, fmt.Errorf("add cleanup finalizer: %w", err)
}
return ctrl.Result{Requeue: true}, nil
}
api, err := r.routerClient(ctx, &router)
if err != nil {
if statusErr := r.setStatus(ctx, &router, 0, metav1.ConditionFalse, "ConfigurationError", err.Error()); statusErr != nil {
return ctrl.Result{}, errors.Join(err, statusErr)
}
return ctrl.Result{RequeueAfter: syncInterval}, nil
}
var serviceList corev1.ServiceList
if err := r.List(ctx, &serviceList); err != nil {
return ctrl.Result{}, fmt.Errorf("list Services: %w", err)
}
desired, preserve, issues := DesiredRules(&router, serviceList.Items)
managedRules, err := api.Sync(ctx, desired, preserve)
if err != nil {
if statusErr := r.setStatus(ctx, &router, router.Status.ManagedRules, metav1.ConditionFalse, "RouterAPIError", err.Error()); statusErr != nil {
return ctrl.Result{}, errors.Join(err, statusErr)
}
return ctrl.Result{}, err
}
if len(issues) > 0 {
message := issueMessage(issues)
if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionFalse, "ServiceConfigurationError", message); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: syncInterval}, nil
}
if err := r.setStatus(ctx, &router, int32(managedRules), metav1.ConditionTrue, "Synchronized", "RouterOS NAT rules are synchronized"); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: syncInterval}, nil
}
func (r *MikroTikRouterReconciler) finalize(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(router, routerFinalizer) {
return ctrl.Result{}, nil
}
api, err := r.routerClient(ctx, router)
if err != nil {
return ctrl.Result{}, fmt.Errorf("initialize RouterOS client for cleanup: %w", err)
}
if _, err := api.Sync(ctx, nil, map[string]struct{}{}); err != nil {
return ctrl.Result{}, fmt.Errorf("remove managed NAT rules during router deletion: %w", err)
}
controllerutil.RemoveFinalizer(router, routerFinalizer)
if err := r.Update(ctx, router); err != nil {
return ctrl.Result{}, fmt.Errorf("remove cleanup finalizer: %w", err)
}
return ctrl.Result{}, nil
}
func (r *MikroTikRouterReconciler) routerClient(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter) (routeros.API, error) {
ref := router.Spec.CredentialsSecretRef
var credentials corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, &credentials); err != nil {
if apierrors.IsNotFound(err) {
return nil, fmt.Errorf("credentials Secret %s/%s does not exist", ref.Namespace, ref.Name)
}
return nil, fmt.Errorf("read credentials Secret: %w", err)
}
username, usernameOK := credentials.Data["username"]
password, passwordOK := credentials.Data["password"]
if !usernameOK || !passwordOK {
return nil, errors.New("credentials Secret must contain username and password keys")
}
if len(username) == 0 || len(password) == 0 {
return nil, errors.New("credentials Secret username and password must not be empty")
}
tlsConfig := routeros.TLSConfig{InsecureSkipVerify: router.Spec.TLS.InsecureSkipVerify}
if caRef := router.Spec.TLS.CASecretRef; caRef != nil {
var caSecret corev1.Secret
if err := r.Get(ctx, types.NamespacedName{Namespace: caRef.Namespace, Name: caRef.Name}, &caSecret); err != nil {
return nil, fmt.Errorf("read CA Secret %s/%s: %w", caRef.Namespace, caRef.Name, err)
}
var ok bool
tlsConfig.CABundle, ok = caSecret.Data["ca.crt"]
if !ok {
return nil, fmt.Errorf("CA Secret %s/%s must contain ca.crt", caRef.Namespace, caRef.Name)
}
}
return r.ClientFactory(router.Spec.Endpoint, string(username), string(password), tlsConfig)
}
func (r *MikroTikRouterReconciler) setStatus(ctx context.Context, router *mikrotikv1alpha1.MikroTikRouter, count int32, conditionStatus metav1.ConditionStatus, reason, message string) error {
now := metav1.Now()
router.Status.ObservedGeneration = router.Generation
router.Status.ManagedRules = count
router.Status.LastSyncTime = &now
meta.SetStatusCondition(&router.Status.Conditions, metav1.Condition{
Type: "Ready", Status: conditionStatus, Reason: reason, Message: message,
ObservedGeneration: router.Generation,
})
return r.Status().Update(ctx, router)
}
func issueMessage(issues []RuleIssue) string {
const maxIssues = 3
messages := make([]string, 0, min(len(issues), maxIssues))
for i, issue := range issues {
if i == maxIssues {
break
}
messages = append(messages, issue.Service+": "+issue.Message)
}
if len(issues) > maxIssues {
messages = append(messages, fmt.Sprintf("and %d more", len(issues)-maxIssues))
}
return strings.Join(messages, "; ")
}
func (r *MikroTikRouterReconciler) enqueueAllRouters(ctx context.Context, _ client.Object) []reconcile.Request {
var routers mikrotikv1alpha1.MikroTikRouterList
if err := r.List(ctx, &routers); err != nil {
return nil
}
requests := make([]reconcile.Request, 0, len(routers.Items))
for i := range routers.Items {
requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: routers.Items[i].Name}})
}
return requests
}
func (r *MikroTikRouterReconciler) SetupWithManager(manager ctrl.Manager) error {
if r.ClientFactory == nil {
r.ClientFactory = func(endpoint, username, password string, tlsConfig routeros.TLSConfig) (routeros.API, error) {
return routeros.NewClient(endpoint, username, password, tlsConfig)
}
}
return ctrl.NewControllerManagedBy(manager).
For(&mikrotikv1alpha1.MikroTikRouter{}).
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(r.enqueueAllRouters)).
Complete(r)
}