feat: vertical slice application -> provider -> binding (#4)
CD / Create tag (push) Successful in 11s
CD / Build and push (amd64) (push) Successful in 1m32s
CD / Create manifest (push) Successful in 7s

Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
This commit was merged in pull request #4.
This commit is contained in:
2026-05-25 17:14:35 +02:00
committed by t.behrendt
parent 2a091df8b9
commit 26bd576690
65 changed files with 4912 additions and 121 deletions
+230
View File
@@ -0,0 +1,230 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"context"
"fmt"
"net/http"
"slices"
"time"
"golang.org/x/time/rate"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
"gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/internal/baseController"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/application/v1alpha1"
clientset "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned"
operatorscheme "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/scheme"
informers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions/application/v1alpha1"
listers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/listers/application/v1alpha1"
authentikapi "goauthentik.io/api/v3"
)
const controllerAgentName = "application-controller"
const (
SuccessSynced = "Synced"
ErrResourceExists = "ErrResourceExists"
MessageResourceExists = "Resource %q already exists and is not managed by Application"
MessageResourceSynced = "Application synced successfully"
FieldManager = controllerAgentName
)
// Finalizers
const (
DeleteAuthentikApplicationFinalizer = "application.t000-n.de/delete-authentik-application"
)
type ApplicationController struct {
kubeclientset kubernetes.Interface
applicationClientset clientset.Interface
authentik *authentikapi.APIClient
applicationListener listers.ApplicationLister
controller *baseController.Controller
}
func NewController(
ctx context.Context,
kubeclientset kubernetes.Interface,
applicationClientset clientset.Interface,
authentik *authentikapi.APIClient,
applicationInformer informers.ApplicationInformer,
) *ApplicationController {
logger := klog.FromContext(ctx)
utilruntime.Must(operatorscheme.AddToScheme(scheme.Scheme))
logger.V(4).Info("Creating event broadcaster")
eventBroadcaster := record.NewBroadcaster(record.WithContext(ctx))
eventBroadcaster.StartStructuredLogging(0)
eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeclientset.CoreV1().Events("")})
recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: controllerAgentName})
ratelimiter := workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[cache.ObjectName](5*time.Millisecond, 1000*time.Second),
&workqueue.TypedBucketRateLimiter[cache.ObjectName]{Limiter: rate.NewLimiter(rate.Limit(50), 300)},
)
c := &ApplicationController{
kubeclientset: kubeclientset,
applicationClientset: applicationClientset,
authentik: authentik,
applicationListener: applicationInformer.Lister(),
}
c.controller = baseController.NewController(
ctx,
workqueue.NewTypedRateLimitingQueue(ratelimiter),
recorder,
applicationInformer.Informer().HasSynced,
c.syncHandler,
)
logger.Info("Setting up event handlers")
applicationInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: c.controller.Enqueue,
UpdateFunc: func(_, newObj interface{}) {
c.controller.Enqueue(newObj)
},
})
return c
}
func (c *ApplicationController) Run(ctx context.Context, workers int) error {
return c.controller.Run(ctx, workers)
}
func (c *ApplicationController) syncHandler(ctx context.Context, objectRef cache.ObjectName) error {
logger := klog.LoggerWithValues(klog.FromContext(ctx), "objectRef", objectRef)
app, err := c.applicationListener.Applications(objectRef.Namespace).Get(objectRef.Name)
if err != nil {
if errors.IsNotFound(err) {
logger.V(4).Info("Application no longer exists")
return nil
}
return err
}
logger.V(4).Info("sync Application", "name", app.Name)
if !app.ObjectMeta.DeletionTimestamp.IsZero() {
logger.Info("Reconciling deletion of Application", "name", app.Name)
return c.reconcileDelete(ctx, app)
}
if app.Status.PK == "" {
logger.Info("Reconciling creation of Application", "name", app.Name)
return c.reconcileCreate(ctx, app)
}
// Check if all finalizers are present. If not, we add them. Same pattern as above, just needs a helper function to check for presence of a finalizer.
if !slices.Contains(app.ObjectMeta.Finalizers, DeleteAuthentikApplicationFinalizer) {
logger.Info("Ensuring finalizers are present", "name", app.Name)
return c.ensureFinalizers(ctx, app)
}
logger.Info("Reconciling update of Application", "name", app.Name)
return c.reconcileUpdate(ctx, app)
}
func (c *ApplicationController) ensureFinalizers(ctx context.Context, app *v1alpha1.Application) error {
app.ObjectMeta.Finalizers = append(app.ObjectMeta.Finalizers, DeleteAuthentikApplicationFinalizer)
return c.updateApplication(ctx, app)
}
func (c *ApplicationController) reconcileDelete(ctx context.Context, app *v1alpha1.Application) error {
r, err := c.authentik.CoreApi.CoreApplicationsDestroy(ctx, app.Spec.Slug).Execute()
if err != nil {
// This handles an edge-case, where when the Application on Authentik has already been deleted, but the finalizer is still present. We just remove the finalizer and return.
if r != nil && r.StatusCode != http.StatusNotFound {
return fmt.Errorf("error when calling `CoreAPI.CoreApplicationsDestroy`: %w with response %v", err, r)
}
}
app.ObjectMeta.Finalizers = slices.Delete(app.ObjectMeta.Finalizers, slices.Index(app.ObjectMeta.Finalizers, DeleteAuthentikApplicationFinalizer), 1)
return c.updateApplication(ctx, app)
}
func (c *ApplicationController) reconcileUpdate(ctx context.Context, app *v1alpha1.Application) error {
_, r, err := c.authentik.CoreApi.CoreApplicationsRetrieve(ctx, app.Spec.Slug).Execute()
if err != nil {
if r != nil && r.StatusCode == http.StatusNotFound {
// This handles an edge-case, where when the Application on Authentik has been deleted, e.g. by mistake. We just remove the PK and return.
// During the next reconciliation, the Application will be re-created.
app.Status.PK = ""
return c.updateApplicationStatus(ctx, app)
}
return fmt.Errorf("error retrieving existing Application: %v with response %v", err, r)
}
patchedApplicationRequest := &authentikapi.PatchedApplicationRequest{
Name: &app.Spec.Name,
Slug: &app.Spec.Slug,
Provider: *authentikapi.NewNullableInt32(&app.Spec.Provider),
}
resp, r, err := c.authentik.CoreApi.CoreApplicationsPartialUpdate(ctx, app.Spec.Slug).PatchedApplicationRequest(*patchedApplicationRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `CoreAPI.CoreApplicationsPartialUpdate`: %w with response %v", err, r)
}
app.Status.PK = resp.Pk
return c.updateApplicationStatus(ctx, app)
}
func (c *ApplicationController) reconcileCreate(ctx context.Context, app *v1alpha1.Application) error {
applicationRequest := &authentikapi.ApplicationRequest{
Name: app.Spec.Name,
Slug: app.Spec.Slug,
Provider: *authentikapi.NewNullableInt32(&app.Spec.Provider),
}
resp, r, err := c.authentik.CoreApi.CoreApplicationsCreate(ctx).ApplicationRequest(*applicationRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `CoreAPI.CoreApplicationsCreate`: %w with response %v", err, r)
}
app.Status.PK = resp.Pk
return c.updateApplicationStatus(ctx, app)
}
func (c *ApplicationController) updateApplicationStatus(ctx context.Context, app *v1alpha1.Application) error {
appCopy := app.DeepCopy()
_, err := c.applicationClientset.ApplicationV1alpha1().Applications(appCopy.Namespace).UpdateStatus(ctx, appCopy, metav1.UpdateOptions{FieldManager: FieldManager})
return err
}
// Update metadata, spec, etc. of the Application object.
func (c *ApplicationController) updateApplication(ctx context.Context, app *v1alpha1.Application) error {
appCopy := app.DeepCopy()
_, err := c.applicationClientset.ApplicationV1alpha1().Applications(appCopy.Namespace).Update(ctx, appCopy, metav1.UpdateOptions{FieldManager: FieldManager})
if err != nil {
return fmt.Errorf("error updating Application metadata: %v", err)
}
return nil
}
@@ -0,0 +1,377 @@
// AI generated tests and not yet reviewed.
package application
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/application/v1alpha1"
operatorfake "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/fake"
operatorinformers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions"
authentikapi "goauthentik.io/api/v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/cache"
)
func TestController_syncHandler_create(t *testing.T) {
const wantPK = "42"
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationCreate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusCreated, map[string]any{"pk": wantPK})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, testApplication(), server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "test-app"})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getApplication(t, ctrl, "default", "test-app")
if got.Status.PK != wantPK {
t.Fatalf("status.pk = %q, want %q", got.Status.PK, wantPK)
}
}
func TestController_syncHandler_ensureFinalizers(t *testing.T) {
app := testApplication()
app.Status.PK = "42"
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getApplication(t, ctrl, app.Namespace, app.Name)
if !slices.Contains(got.Finalizers, DeleteAuthentikApplicationFinalizer) {
t.Fatalf("finalizers = %v, want %q", got.Finalizers, DeleteAuthentikApplicationFinalizer)
}
}
func TestController_syncHandler_update(t *testing.T) {
app := testApplication()
app.Status.PK = "42"
app.Finalizers = []string{DeleteAuthentikApplicationFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationRetrieve: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": "42"})
},
applicationPartialUpdate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": "42"})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getApplication(t, ctrl, app.Namespace, app.Name)
if got.Status.PK != "42" {
t.Fatalf("status.pk = %q, want 42", got.Status.PK)
}
}
func TestController_syncHandler_update_applicationNotFound(t *testing.T) {
app := testApplication()
app.Status.PK = "42"
app.Finalizers = []string{DeleteAuthentikApplicationFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationRetrieve: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getApplication(t, ctrl, app.Namespace, app.Name)
if got.Status.PK != "" {
t.Fatalf("status.pk = %q, want empty after application not found", got.Status.PK)
}
}
func TestController_syncHandler_delete(t *testing.T) {
now := metav1.Now()
app := testApplication()
app.Status.PK = "42"
app.DeletionTimestamp = &now
app.Finalizers = []string{DeleteAuthentikApplicationFinalizer}
var destroyCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationDestroy: func(w http.ResponseWriter, r *http.Request) {
destroyCalled = true
if r.Method != http.MethodDelete {
t.Errorf("destroy method = %s, want DELETE", r.Method)
}
w.WriteHeader(http.StatusNoContent)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if !destroyCalled {
t.Fatal("expected Authentik destroy call")
}
got := getApplication(t, ctrl, app.Namespace, app.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikApplicationFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed", got.Finalizers)
}
}
func TestController_syncHandler_delete_providerAlreadyGone(t *testing.T) {
now := metav1.Now()
app := testApplication()
app.Status.PK = "42"
app.DeletionTimestamp = &now
app.Finalizers = []string{DeleteAuthentikApplicationFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationDestroy: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getApplication(t, ctrl, app.Namespace, app.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikApplicationFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed after 404", got.Finalizers)
}
}
func TestController_syncHandler_notFound(t *testing.T) {
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, nil, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "missing"})
if err != nil {
t.Fatalf("syncHandler() error = %v, want nil for missing object", err)
}
}
func TestController_syncHandler_delete_usesSlugNotPK(t *testing.T) {
now := metav1.Now()
app := testApplication()
app.Status.PK = "not-a-number"
app.DeletionTimestamp = &now
app.Finalizers = []string{DeleteAuthentikApplicationFinalizer}
var destroySlug string
server := newAuthentikTestServer(t, authentikTestHandlers{
applicationDestroy: func(w http.ResponseWriter, r *http.Request) {
destroySlug = strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v3/core/applications/"), "/")
w.WriteHeader(http.StatusNoContent)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, app, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: app.Namespace, Name: app.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if destroySlug != app.Spec.Slug {
t.Fatalf("destroy slug = %q, want %q (delete must use spec.slug, not status.pk)", destroySlug, app.Spec.Slug)
}
}
// --- test helpers ---
func testApplication() *v1alpha1.Application {
return &v1alpha1.Application{
TypeMeta: metav1.TypeMeta{
APIVersion: v1alpha1.SchemeGroupVersion.String(),
Kind: "Application",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Namespace: "default",
},
Spec: v1alpha1.ApplicationSpec{
Name: "My Application",
Slug: "my-app",
Provider: 7,
},
}
}
func newTestController(t *testing.T, app *v1alpha1.Application, authentikURL string) (*ApplicationController, context.Context, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
ctrl, _, stop := newTestControllerWithContext(t, ctx, app, authentikURL)
return ctrl, ctx, func() {
cancel()
stop()
}
}
func newTestControllerWithContext(t *testing.T, ctx context.Context, app *v1alpha1.Application, authentikURL string) (*ApplicationController, context.Context, func()) {
t.Helper()
authentikClient := newAuthentikAPIClientForTest(t, authentikURL)
var objects []runtime.Object
if app != nil {
objects = append(objects, app)
}
applicationClient := operatorfake.NewSimpleClientset(objects...)
informerFactory := operatorinformers.NewSharedInformerFactory(applicationClient, 0)
applicationInformer := informerFactory.Application().V1alpha1().Applications()
ctrl := NewController(ctx, fake.NewClientset(), applicationClient, authentikClient, applicationInformer)
informerFactory.Start(ctx.Done())
for informerType, synced := range informerFactory.WaitForCacheSync(ctx.Done()) {
if !synced {
t.Fatalf("informer %v failed to sync", informerType)
}
}
return ctrl, ctx, func() {}
}
func newAuthentikAPIClientForTest(t *testing.T, serverURL string) *authentikapi.APIClient {
t.Helper()
u, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
cfg := authentikapi.NewConfiguration()
cfg.Scheme = u.Scheme
cfg.Host = u.Host
return authentikapi.NewAPIClient(cfg)
}
type authentikTestHandlers struct {
applicationCreate http.HandlerFunc
applicationRetrieve http.HandlerFunc
applicationPartialUpdate http.HandlerFunc
applicationDestroy http.HandlerFunc
}
func newAuthentikTestServer(t *testing.T, handlers authentikTestHandlers) *httptest.Server {
t.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/api/v3/core/applications/" && r.Method == http.MethodPost:
if handlers.applicationCreate != nil {
handlers.applicationCreate(w, r)
return
}
http.NotFound(w, r)
case strings.HasPrefix(path, "/api/v3/core/applications/") && strings.HasSuffix(path, "/"):
slugPath := strings.TrimPrefix(path, "/api/v3/core/applications/")
if slugPath == "" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
if handlers.applicationRetrieve != nil {
handlers.applicationRetrieve(w, r)
return
}
http.NotFound(w, r)
case http.MethodPatch:
if handlers.applicationPartialUpdate != nil {
handlers.applicationPartialUpdate(w, r)
return
}
http.NotFound(w, r)
case http.MethodDelete:
if handlers.applicationDestroy != nil {
handlers.applicationDestroy(w, r)
return
}
http.NotFound(w, r)
default:
http.Error(w, "unexpected method on application instance", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
})
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("write JSON response: %v", err)
}
}
func getApplication(t *testing.T, ctrl *ApplicationController, namespace, name string) *v1alpha1.Application {
t.Helper()
got, err := ctrl.applicationClientset.ApplicationV1alpha1().Applications(namespace).Get(
context.Background(), name, metav1.GetOptions{},
)
if err != nil {
t.Fatalf("get Application: %v", err)
}
return got
}
+245
View File
@@ -0,0 +1,245 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package policybinding
import (
"context"
"fmt"
"net/http"
"slices"
"time"
"golang.org/x/time/rate"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
"gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/internal/baseController"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/policybinding/v1alpha1"
clientset "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned"
operatorscheme "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/scheme"
informers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions/policybinding/v1alpha1"
listers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/listers/policybinding/v1alpha1"
authentikapi "goauthentik.io/api/v3"
)
const controllerAgentName = "policybinding-controller"
const (
SuccessSynced = "Synced"
ErrResourceExists = "ErrResourceExists"
MessageResourceExists = "Resource %q already exists and is not managed by PolicyBinding"
MessageResourceSynced = "PolicyBinding synced successfully"
FieldManager = controllerAgentName
)
// Finalizers
const (
DeleteAuthentikPolicyBindingFinalizer = "policybinding.t000-n.de/delete-authentik-policybinding"
)
type PolicyBindingController struct {
kubeclientset kubernetes.Interface
policyBindingClientset clientset.Interface
authentik *authentikapi.APIClient
policyBindingListener listers.PolicyBindingLister
controller *baseController.Controller
}
func NewController(
ctx context.Context,
kubeclientset kubernetes.Interface,
policyBindingClientset clientset.Interface,
authentik *authentikapi.APIClient,
policyBindingInformer informers.PolicyBindingInformer,
) *PolicyBindingController {
logger := klog.FromContext(ctx)
utilruntime.Must(operatorscheme.AddToScheme(scheme.Scheme))
logger.V(4).Info("Creating event broadcaster")
eventBroadcaster := record.NewBroadcaster(record.WithContext(ctx))
eventBroadcaster.StartStructuredLogging(0)
eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeclientset.CoreV1().Events("")})
recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: controllerAgentName})
ratelimiter := workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[cache.ObjectName](5*time.Millisecond, 1000*time.Second),
&workqueue.TypedBucketRateLimiter[cache.ObjectName]{Limiter: rate.NewLimiter(rate.Limit(50), 300)},
)
c := &PolicyBindingController{
kubeclientset: kubeclientset,
policyBindingClientset: policyBindingClientset,
authentik: authentik,
policyBindingListener: policyBindingInformer.Lister(),
}
c.controller = baseController.NewController(
ctx,
workqueue.NewTypedRateLimitingQueue(ratelimiter),
recorder,
policyBindingInformer.Informer().HasSynced,
c.syncHandler,
)
logger.Info("Setting up event handlers")
policyBindingInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: c.controller.Enqueue,
UpdateFunc: func(_, newObj interface{}) {
c.controller.Enqueue(newObj)
},
})
return c
}
func (c *PolicyBindingController) Run(ctx context.Context, workers int) error {
return c.controller.Run(ctx, workers)
}
func (c *PolicyBindingController) syncHandler(ctx context.Context, objectRef cache.ObjectName) error {
logger := klog.LoggerWithValues(klog.FromContext(ctx), "objectRef", objectRef)
pb, err := c.policyBindingListener.PolicyBindings(objectRef.Namespace).Get(objectRef.Name)
if err != nil {
if errors.IsNotFound(err) {
logger.V(4).Info("PolicyBinding no longer exists")
return nil
}
return err
}
logger.V(4).Info("sync PolicyBinding", "name", pb.Name)
if !pb.ObjectMeta.DeletionTimestamp.IsZero() {
logger.Info("Reconciling deletion of PolicyBinding", "name", pb.Name)
return c.reconcileDelete(ctx, pb)
}
if pb.Status.PK == "" {
logger.Info("Reconciling creation of PolicyBinding", "name", pb.Name)
return c.reconcileCreate(ctx, pb)
}
// Check if all finalizers are present. If not, we add them. Same pattern as above, just needs a helper function to check for presence of a finalizer.
if !slices.Contains(pb.ObjectMeta.Finalizers, DeleteAuthentikPolicyBindingFinalizer) {
logger.Info("Ensuring finalizers are present", "name", pb.Name)
return c.ensureFinalizers(ctx, pb)
}
logger.Info("Reconciling update of PolicyBinding", "name", pb.Name)
return c.reconcileUpdate(ctx, pb)
}
func (c *PolicyBindingController) ensureFinalizers(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
pb.ObjectMeta.Finalizers = append(pb.ObjectMeta.Finalizers, DeleteAuthentikPolicyBindingFinalizer)
return c.updatePolicyBinding(ctx, pb)
}
func (c *PolicyBindingController) reconcileDelete(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
r, err := c.authentik.PoliciesApi.PoliciesBindingsDestroy(ctx, pb.Status.PK).Execute()
if err != nil {
// This handles an edge-case, where when the PolicyBinding on Authentik has already been deleted, but the finalizer is still present. We just remove the finalizer and return.
if r != nil && r.StatusCode != http.StatusNotFound {
return fmt.Errorf("error when calling `PoliciesAPI.PoliciesBindingsDestroy`: %w with response %v", err, r)
}
}
pb.ObjectMeta.Finalizers = slices.Delete(pb.ObjectMeta.Finalizers, slices.Index(pb.ObjectMeta.Finalizers, DeleteAuthentikPolicyBindingFinalizer), 1)
return c.updatePolicyBinding(ctx, pb)
}
func (c *PolicyBindingController) reconcileUpdate(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
_, r, err := c.authentik.PoliciesApi.PoliciesBindingsRetrieve(ctx, pb.Status.PK).Execute()
if err != nil {
if r != nil && r.StatusCode == http.StatusNotFound {
// This handles an edge-case, where when the PolicyBinding on Authentik has been deleted, e.g. by mistake. We just remove the PK and return.
// During the next reconciliation, the PolicyBinding will be re-created.
pb.Status.PK = ""
return c.updatePolicyBindingStatus(ctx, pb)
}
return fmt.Errorf("error retrieving existing PolicyBinding: %v with response %v", err, r)
}
patchedPolicyBindingRequest := &authentikapi.PatchedPolicyBindingRequest{
Target: &pb.Spec.Target,
Order: &pb.Spec.Order,
}
if pb.Spec.Policy != "" {
patchedPolicyBindingRequest.SetPolicy(pb.Spec.Policy)
}
if pb.Spec.Group != "" {
patchedPolicyBindingRequest.SetGroup(pb.Spec.Group)
}
if pb.Spec.User != 0 {
patchedPolicyBindingRequest.SetUser(pb.Spec.User)
}
resp, r, err := c.authentik.PoliciesApi.PoliciesBindingsPartialUpdate(ctx, pb.Status.PK).PatchedPolicyBindingRequest(*patchedPolicyBindingRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `PoliciesAPI.PoliciesBindingsPartialUpdate`: %w with response %v", err, r)
}
pb.Status.PK = resp.Pk
return c.updatePolicyBindingStatus(ctx, pb)
}
func (c *PolicyBindingController) reconcileCreate(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
policyBindingRequest := &authentikapi.PolicyBindingRequest{
Target: pb.Spec.Target,
Order: pb.Spec.Order,
}
if pb.Spec.Policy != "" {
policyBindingRequest.SetPolicy(pb.Spec.Policy)
}
if pb.Spec.Group != "" {
policyBindingRequest.SetGroup(pb.Spec.Group)
}
if pb.Spec.User != 0 {
policyBindingRequest.SetUser(pb.Spec.User)
}
resp, r, err := c.authentik.PoliciesApi.PoliciesBindingsCreate(ctx).PolicyBindingRequest(*policyBindingRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `PoliciesAPI.PoliciesBindingsCreate`: %w with response %v", err, r)
}
pb.Status.PK = resp.Pk
return c.updatePolicyBindingStatus(ctx, pb)
}
func (c *PolicyBindingController) updatePolicyBindingStatus(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
pbCopy := pb.DeepCopy()
_, err := c.policyBindingClientset.PolicyBindingV1alpha1().PolicyBindings(pbCopy.Namespace).UpdateStatus(ctx, pbCopy, metav1.UpdateOptions{FieldManager: FieldManager})
return err
}
// Update metadata, spec, etc. of the PolicyBinding object.
func (c *PolicyBindingController) updatePolicyBinding(ctx context.Context, pb *v1alpha1.PolicyBinding) error {
pbCopy := pb.DeepCopy()
_, err := c.policyBindingClientset.PolicyBindingV1alpha1().PolicyBindings(pbCopy.Namespace).Update(ctx, pbCopy, metav1.UpdateOptions{FieldManager: FieldManager})
return err
}
@@ -0,0 +1,349 @@
// AI generated tests and not yet reviewed.
package policybinding
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/policybinding/v1alpha1"
operatorfake "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/fake"
operatorinformers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions"
authentikapi "goauthentik.io/api/v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/cache"
)
func TestController_syncHandler_create(t *testing.T) {
const wantPK = "42"
server := newAuthentikTestServer(t, authentikTestHandlers{
policyBindingCreate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusCreated, map[string]any{"pk": wantPK})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, testPolicyBinding(), server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "test-pb"})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getPolicyBinding(t, ctrl, "default", "test-pb")
if got.Status.PK != wantPK {
t.Fatalf("status.pk = %q, want %q", got.Status.PK, wantPK)
}
}
func TestController_syncHandler_ensureFinalizers(t *testing.T) {
pb := testPolicyBinding()
pb.Status.PK = "42"
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pb, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pb.Namespace, Name: pb.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getPolicyBinding(t, ctrl, pb.Namespace, pb.Name)
if !slices.Contains(got.Finalizers, DeleteAuthentikPolicyBindingFinalizer) {
t.Fatalf("finalizers = %v, want %q", got.Finalizers, DeleteAuthentikPolicyBindingFinalizer)
}
}
func TestController_syncHandler_update(t *testing.T) {
pb := testPolicyBinding()
pb.Status.PK = "42"
pb.Finalizers = []string{DeleteAuthentikPolicyBindingFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
policyBindingRetrieve: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": "42"})
},
policyBindingPartialUpdate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": "42"})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pb, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pb.Namespace, Name: pb.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getPolicyBinding(t, ctrl, pb.Namespace, pb.Name)
if got.Status.PK != "42" {
t.Fatalf("status.pk = %q, want 42", got.Status.PK)
}
}
func TestController_syncHandler_update_policyBindingNotFound(t *testing.T) {
pb := testPolicyBinding()
pb.Status.PK = "42"
pb.Finalizers = []string{DeleteAuthentikPolicyBindingFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
policyBindingRetrieve: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pb, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pb.Namespace, Name: pb.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getPolicyBinding(t, ctrl, pb.Namespace, pb.Name)
if got.Status.PK != "" {
t.Fatalf("status.pk = %q, want empty after policy binding not found", got.Status.PK)
}
}
func TestController_syncHandler_delete(t *testing.T) {
now := metav1.Now()
pb := testPolicyBinding()
pb.Status.PK = "42"
pb.DeletionTimestamp = &now
pb.Finalizers = []string{DeleteAuthentikPolicyBindingFinalizer}
var destroyCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
policyBindingDestroy: func(w http.ResponseWriter, r *http.Request) {
destroyCalled = true
if r.Method != http.MethodDelete {
t.Errorf("destroy method = %s, want DELETE", r.Method)
}
w.WriteHeader(http.StatusNoContent)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pb, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pb.Namespace, Name: pb.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if !destroyCalled {
t.Fatal("expected Authentik destroy call")
}
got := getPolicyBinding(t, ctrl, pb.Namespace, pb.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikPolicyBindingFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed", got.Finalizers)
}
}
func TestController_syncHandler_delete_policyBindingAlreadyGone(t *testing.T) {
now := metav1.Now()
pb := testPolicyBinding()
pb.Status.PK = "42"
pb.DeletionTimestamp = &now
pb.Finalizers = []string{DeleteAuthentikPolicyBindingFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
policyBindingDestroy: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pb, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pb.Namespace, Name: pb.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getPolicyBinding(t, ctrl, pb.Namespace, pb.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikPolicyBindingFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed after 404", got.Finalizers)
}
}
func TestController_syncHandler_notFound(t *testing.T) {
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, nil, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "missing"})
if err != nil {
t.Fatalf("syncHandler() error = %v, want nil for missing object", err)
}
}
// --- test helpers ---
func testPolicyBinding() *v1alpha1.PolicyBinding {
return &v1alpha1.PolicyBinding{
TypeMeta: metav1.TypeMeta{
APIVersion: v1alpha1.SchemeGroupVersion.String(),
Kind: "PolicyBinding",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-pb",
Namespace: "default",
},
Spec: v1alpha1.PolicyBindingSpec{
Group: "14ab813f-a7f9-481b-9b08-781953ae9ebf",
Target: "8dd85627-9c48-49c2-8afc-d73dd122ffc2",
Order: 1,
},
}
}
func newTestController(t *testing.T, pb *v1alpha1.PolicyBinding, authentikURL string) (*PolicyBindingController, context.Context, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
ctrl, _, stop := newTestControllerWithContext(t, ctx, pb, authentikURL)
return ctrl, ctx, func() {
cancel()
stop()
}
}
func newTestControllerWithContext(t *testing.T, ctx context.Context, pb *v1alpha1.PolicyBinding, authentikURL string) (*PolicyBindingController, context.Context, func()) {
t.Helper()
authentikClient := newAuthentikAPIClientForTest(t, authentikURL)
var objects []runtime.Object
if pb != nil {
objects = append(objects, pb)
}
policyBindingClient := operatorfake.NewSimpleClientset(objects...)
informerFactory := operatorinformers.NewSharedInformerFactory(policyBindingClient, 0)
policyBindingInformer := informerFactory.PolicyBinding().V1alpha1().PolicyBindings()
ctrl := NewController(ctx, fake.NewClientset(), policyBindingClient, authentikClient, policyBindingInformer)
informerFactory.Start(ctx.Done())
for informerType, synced := range informerFactory.WaitForCacheSync(ctx.Done()) {
if !synced {
t.Fatalf("informer %v failed to sync", informerType)
}
}
return ctrl, ctx, func() {}
}
func newAuthentikAPIClientForTest(t *testing.T, serverURL string) *authentikapi.APIClient {
t.Helper()
u, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
cfg := authentikapi.NewConfiguration()
cfg.Scheme = u.Scheme
cfg.Host = u.Host
return authentikapi.NewAPIClient(cfg)
}
type authentikTestHandlers struct {
policyBindingCreate http.HandlerFunc
policyBindingRetrieve http.HandlerFunc
policyBindingPartialUpdate http.HandlerFunc
policyBindingDestroy http.HandlerFunc
}
func newAuthentikTestServer(t *testing.T, handlers authentikTestHandlers) *httptest.Server {
t.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/api/v3/policies/bindings/" && r.Method == http.MethodPost:
if handlers.policyBindingCreate != nil {
handlers.policyBindingCreate(w, r)
return
}
http.NotFound(w, r)
case strings.HasPrefix(path, "/api/v3/policies/bindings/") && strings.HasSuffix(path, "/"):
idPath := strings.TrimPrefix(path, "/api/v3/policies/bindings/")
if idPath == "" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
if handlers.policyBindingRetrieve != nil {
handlers.policyBindingRetrieve(w, r)
return
}
http.NotFound(w, r)
case http.MethodPatch:
if handlers.policyBindingPartialUpdate != nil {
handlers.policyBindingPartialUpdate(w, r)
return
}
http.NotFound(w, r)
case http.MethodDelete:
if handlers.policyBindingDestroy != nil {
handlers.policyBindingDestroy(w, r)
return
}
http.NotFound(w, r)
default:
http.Error(w, "unexpected method on policy binding instance", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
})
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("write JSON response: %v", err)
}
}
func getPolicyBinding(t *testing.T, ctrl *PolicyBindingController, namespace, name string) *v1alpha1.PolicyBinding {
t.Helper()
got, err := ctrl.policyBindingClientset.PolicyBindingV1alpha1().PolicyBindings(namespace).Get(
context.Background(), name, metav1.GetOptions{},
)
if err != nil {
t.Fatalf("get PolicyBinding: %v", err)
}
return got
}
+309
View File
@@ -0,0 +1,309 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package proxyprovider
import (
"context"
"fmt"
"net/http"
"slices"
"strconv"
"time"
"golang.org/x/time/rate"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
"gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/internal/baseController"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/proxyprovider/v1alpha1"
clientset "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned"
operatorscheme "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/scheme"
informers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions/proxyprovider/v1alpha1"
listers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/listers/proxyprovider/v1alpha1"
authentikapi "goauthentik.io/api/v3"
)
const controllerAgentName = "proxy-provider-controller"
const (
SuccessSynced = "Synced"
ErrResourceExists = "ErrResourceExists"
MessageResourceExists = "Resource %q already exists and is not managed by ProxyProvider"
MessageResourceSynced = "ProxyProvider synced successfully"
FieldManager = controllerAgentName
)
// Finalizers
const (
DeleteAuthentikProxyProviderFinalizer = "proxyprovider.t000-n.de/delete-authentik-proxyprovider"
)
type ProxyProviderController struct {
kubeclientset kubernetes.Interface
proxyProviderClientset clientset.Interface
authentik *authentikapi.APIClient
proxyLister listers.ProxyProviderLister
controller *baseController.Controller
}
func NewController(
ctx context.Context,
kubeclientset kubernetes.Interface,
proxyProviderClientset clientset.Interface,
authentik *authentikapi.APIClient,
proxyInformer informers.ProxyProviderInformer,
) *ProxyProviderController {
logger := klog.FromContext(ctx)
utilruntime.Must(operatorscheme.AddToScheme(scheme.Scheme))
logger.V(4).Info("Creating event broadcaster")
eventBroadcaster := record.NewBroadcaster(record.WithContext(ctx))
eventBroadcaster.StartStructuredLogging(0)
eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeclientset.CoreV1().Events("")})
recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: controllerAgentName})
ratelimiter := workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[cache.ObjectName](5*time.Millisecond, 1000*time.Second),
&workqueue.TypedBucketRateLimiter[cache.ObjectName]{Limiter: rate.NewLimiter(rate.Limit(50), 300)},
)
c := &ProxyProviderController{
kubeclientset: kubeclientset,
proxyProviderClientset: proxyProviderClientset,
authentik: authentik,
proxyLister: proxyInformer.Lister(),
}
c.controller = baseController.NewController(
ctx,
workqueue.NewTypedRateLimitingQueue(ratelimiter),
recorder,
proxyInformer.Informer().HasSynced,
c.syncHandler,
)
logger.Info("Setting up event handlers")
proxyInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: c.controller.Enqueue,
UpdateFunc: func(_, newObj interface{}) {
c.controller.Enqueue(newObj)
},
})
return c
}
func (c *ProxyProviderController) Run(ctx context.Context, workers int) error {
return c.controller.Run(ctx, workers)
}
func (c *ProxyProviderController) syncHandler(ctx context.Context, objectRef cache.ObjectName) error {
logger := klog.LoggerWithValues(klog.FromContext(ctx), "objectRef", objectRef)
pp, err := c.proxyLister.ProxyProviders(objectRef.Namespace).Get(objectRef.Name)
if err != nil {
if errors.IsNotFound(err) {
logger.V(4).Info("ProxyProvider no longer exists")
return nil
}
return err
}
logger.V(4).Info("sync ProxyProvider", "name", pp.Name)
if !pp.ObjectMeta.DeletionTimestamp.IsZero() {
logger.Info("Reconciling deletion of ProxyProvider", "name", pp.Name)
return c.reconcileDelete(ctx, pp)
}
if pp.Status.PK == "" {
logger.Info("Reconciling creation of ProxyProvider", "name", pp.Name)
return c.reconcileCreate(ctx, pp)
}
// Check if all finalizers are present. If not, we add them. Same pattern as above, just needs a helper function to check for presence of a finalizer.
if !slices.Contains(pp.ObjectMeta.Finalizers, DeleteAuthentikProxyProviderFinalizer) {
logger.Info("Ensuring finalizers are present", "name", pp.Name)
return c.ensureFinalizers(ctx, pp)
}
logger.Info("Reconciling update of ProxyProvider", "name", pp.Name)
return c.reconcileUpdate(ctx, pp)
}
func (c *ProxyProviderController) ensureFinalizers(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
pp.ObjectMeta.Finalizers = append(pp.ObjectMeta.Finalizers, DeleteAuthentikProxyProviderFinalizer)
return c.updateProxyProvider(ctx, pp)
}
func (c *ProxyProviderController) reconcileDelete(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
pk, err := strconv.ParseInt(pp.Status.PK, 10, 32)
if err != nil {
return fmt.Errorf("error parsing PK: %v", err)
}
err = c.reconcileOutpost(ctx, pp.Spec.Outpost, int32(pk), ReconcileOutpostModeRemove)
if err != nil {
return fmt.Errorf("error when calling `reconcileOutpost`: %w", err)
}
// Delete ProxyProvider
r, err := c.authentik.ProvidersApi.ProvidersProxyDestroy(ctx, int32(pk)).Execute()
if err != nil {
// This handles an edge-case, where when the ProxyProvider on Authentik has already been deleted, but the finalizer is still present. We just remove the finalizer and return.
if r != nil && r.StatusCode != http.StatusNotFound {
return fmt.Errorf("error when calling `ProvidersAPI.ProvidersProxyDestroy`: %w with response %v", err, r)
}
}
pp.ObjectMeta.Finalizers = slices.Delete(pp.ObjectMeta.Finalizers, slices.Index(pp.ObjectMeta.Finalizers, DeleteAuthentikProxyProviderFinalizer), 1)
return c.updateProxyProvider(ctx, pp)
}
func (c *ProxyProviderController) reconcileUpdate(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
// We retrieve the existing PP from the API by slug.
pk, err := strconv.ParseInt(pp.Status.PK, 10, 32)
if err != nil {
return fmt.Errorf("error parsing PK: %v", err)
}
_, r, err := c.authentik.ProvidersApi.ProvidersAllRetrieve(ctx, int32(pk)).Execute()
if err != nil {
if r != nil && r.StatusCode == http.StatusNotFound {
// This handles an edge-case, where when the PorxyProvider on Authentik has been deleted, e.g. by mistake. We just remove the PK and return.
// During the next reconciliation, the ProxyProvider will be re-created.
pp.Status.PK = ""
return c.updateProxyProviderStatus(ctx, pp)
}
return fmt.Errorf("error retrieving existing ProxyProvider: %v with response %v", err, r)
}
proxyProviderRequest := &authentikapi.PatchedProxyProviderRequest{
Name: &pp.Spec.Name,
AuthorizationFlow: &pp.Spec.AuthorizationFlow,
InvalidationFlow: &pp.Spec.InvalidationFlow,
ExternalHost: &pp.Spec.ExternalHost,
Mode: authentikapi.PROXYMODE_FORWARD_SINGLE.Ptr(),
}
resp, r, err := c.authentik.ProvidersApi.ProvidersProxyPartialUpdate(ctx, int32(pk)).PatchedProxyProviderRequest(*proxyProviderRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `ProvidersAPI.ProvidersProxyPartialUpdate`: %w with response %v", err, r)
}
pp.Status.PK = strconv.Itoa(int(resp.Pk))
err = c.reconcileOutpost(ctx, pp.Spec.Outpost, int32(pk), ReconcileOutpostModeAdd)
if err != nil {
return fmt.Errorf("error when calling `reconcileOutpost`: %w", err)
}
return c.updateProxyProviderStatus(ctx, pp)
}
func (c *ProxyProviderController) reconcileCreate(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
proxyProviderRequest := &authentikapi.ProxyProviderRequest{
Name: pp.Spec.Name,
AuthorizationFlow: pp.Spec.AuthorizationFlow,
InvalidationFlow: pp.Spec.InvalidationFlow,
ExternalHost: pp.Spec.ExternalHost,
Mode: authentikapi.PROXYMODE_FORWARD_SINGLE.Ptr(),
}
resp, r, err := c.authentik.ProvidersApi.ProvidersProxyCreate(ctx).ProxyProviderRequest(*proxyProviderRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `ProvidersAPI.ProvidersProxyCreate`: %w with response %v", err, r)
}
err = c.reconcileOutpost(ctx, pp.Spec.Outpost, resp.Pk, ReconcileOutpostModeAdd)
if err != nil {
return fmt.Errorf("error when calling `reconcileOutpost`: %w", err)
}
pp.Status.PK = strconv.Itoa(int(resp.Pk))
return c.updateProxyProviderStatus(ctx, pp)
}
func (c *ProxyProviderController) updateProxyProviderStatus(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
ppCopy := pp.DeepCopy()
_, err := c.proxyProviderClientset.ProxyproviderV1alpha1().ProxyProviders(ppCopy.Namespace).UpdateStatus(ctx, ppCopy, metav1.UpdateOptions{FieldManager: FieldManager})
return err
}
// Update metadata, spec, etc. of the ProxyProvider object.
func (c *ProxyProviderController) updateProxyProvider(ctx context.Context, pp *v1alpha1.ProxyProvider) error {
ppCopy := pp.DeepCopy()
_, err := c.proxyProviderClientset.ProxyproviderV1alpha1().ProxyProviders(ppCopy.Namespace).Update(ctx, ppCopy, metav1.UpdateOptions{FieldManager: FieldManager})
if err != nil {
return fmt.Errorf("error updating ProxyProvider metadata: %v", err)
}
return nil
}
type ReconcileOutpostMode string
const (
ReconcileOutpostModeAdd ReconcileOutpostMode = "add"
ReconcileOutpostModeRemove ReconcileOutpostMode = "remove"
)
func (c *ProxyProviderController) reconcileOutpost(ctx context.Context, outpostId string, providerPk int32, mode ReconcileOutpostMode) error {
logger := klog.LoggerWithValues(klog.FromContext(ctx), "outpostId", outpostId, "providerPk", providerPk, "mode", mode)
outpost, r, err := c.authentik.OutpostsApi.OutpostsInstancesRetrieve(ctx, outpostId).Execute()
if err != nil {
return fmt.Errorf("error when calling `OutpostsAPI.OutpostsInstancesRetrieve`: %w with response %v", err, r)
}
updated := false
switch mode {
case ReconcileOutpostModeAdd:
if !slices.Contains(outpost.Providers, providerPk) {
outpost.Providers = append(outpost.Providers, providerPk)
updated = true
} else {
logger.V(4).Info("Provider already in outpost")
}
case ReconcileOutpostModeRemove:
if slices.Contains(outpost.Providers, providerPk) {
outpost.Providers = slices.Delete(outpost.Providers, slices.Index(outpost.Providers, providerPk), 1)
updated = true
}
default:
return fmt.Errorf("invalid mode: %s", mode)
}
if !updated {
return nil
}
outpostPartialUpdateRequest := &authentikapi.PatchedOutpostRequest{
Providers: outpost.Providers,
}
_, r, err = c.authentik.OutpostsApi.OutpostsInstancesPartialUpdate(ctx, outpostId).PatchedOutpostRequest(*outpostPartialUpdateRequest).Execute()
if err != nil {
return fmt.Errorf("error when calling `OutpostsAPI.OutpostsInstancesPartialUpdate`: %w with response %v", err, r)
}
return nil
}
@@ -0,0 +1,547 @@
// AI generated tests and not yet reviewed.
package proxyprovider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
v1alpha1 "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/apis/proxyprovider/v1alpha1"
operatorfake "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/clientset/versioned/fake"
operatorinformers "gitea.t000-n.de/t.behrendt/authentik-kubernetes-operator/pkg/generated/informers/externalversions"
authentikapi "goauthentik.io/api/v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/cache"
)
const testOutpostID = "550e8400-e29b-41d4-a716-446655440000"
func TestController_syncHandler_create(t *testing.T) {
const wantPK = 42
var outpostPartialUpdateCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
proxyCreate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusCreated, map[string]any{"pk": wantPK})
},
outpostRetrieve: outpostRetrieveHandler(t, nil),
outpostPartialUpdate: func(w http.ResponseWriter, r *http.Request) {
outpostPartialUpdateCalled = true
var body struct {
Providers []int32 `json:"providers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode outpost patch body: %v", err)
}
if !slices.Contains(body.Providers, wantPK) {
t.Fatalf("patched providers = %v, want to contain %d", body.Providers, wantPK)
}
writeJSON(t, w, http.StatusOK, map[string]any{"pk": testOutpostID, "providers": body.Providers})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, testProxyProvider(), server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "test-pp"})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if !outpostPartialUpdateCalled {
t.Fatal("expected Authentik outpost partial update call")
}
got := getProxyProvider(t, ctrl, "default", "test-pp")
if got.Status.PK != "42" {
t.Fatalf("status.pk = %q, want 42", got.Status.PK)
}
}
func TestController_syncHandler_create_providerAlreadyInOutpost(t *testing.T) {
const wantPK = 42
var outpostPartialUpdateCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
proxyCreate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusCreated, map[string]any{"pk": wantPK})
},
outpostRetrieve: outpostRetrieveHandler(t, []int32{wantPK}),
outpostPartialUpdate: func(w http.ResponseWriter, _ *http.Request) {
outpostPartialUpdateCalled = true
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, testProxyProvider(), server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "test-pp"})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if outpostPartialUpdateCalled {
t.Fatal("did not expect Authentik outpost partial update when provider is already present")
}
got := getProxyProvider(t, ctrl, "default", "test-pp")
if got.Status.PK != "42" {
t.Fatalf("status.pk = %q, want 42", got.Status.PK)
}
}
func TestController_syncHandler_ensureFinalizers(t *testing.T) {
pp := testProxyProvider()
pp.Status.PK = "42"
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if !slices.Contains(got.Finalizers, DeleteAuthentikProxyProviderFinalizer) {
t.Fatalf("finalizers = %v, want %q", got.Finalizers, DeleteAuthentikProxyProviderFinalizer)
}
}
func TestController_syncHandler_update(t *testing.T) {
pp := testProxyProvider()
pp.Status.PK = "42"
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
var outpostPartialUpdateCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
allRetrieve: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": 42})
},
proxyPartialUpdate: func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{"pk": 42})
},
outpostRetrieve: outpostRetrieveHandler(t, nil),
outpostPartialUpdate: func(w http.ResponseWriter, r *http.Request) {
outpostPartialUpdateCalled = true
var body struct {
Providers []int32 `json:"providers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode outpost patch body: %v", err)
}
if !slices.Contains(body.Providers, 42) {
t.Fatalf("patched providers = %v, want to contain 42", body.Providers)
}
writeJSON(t, w, http.StatusOK, map[string]any{"pk": testOutpostID, "providers": body.Providers})
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if !outpostPartialUpdateCalled {
t.Fatal("expected Authentik outpost partial update call")
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if got.Status.PK != "42" {
t.Fatalf("status.pk = %q, want 42", got.Status.PK)
}
}
func TestController_syncHandler_update_providerNotFound(t *testing.T) {
pp := testProxyProvider()
pp.Status.PK = "42"
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
allRetrieve: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if got.Status.PK != "" {
t.Fatalf("status.pk = %q, want empty after provider not found", got.Status.PK)
}
}
func TestController_syncHandler_delete(t *testing.T) {
const wantPK int32 = 42
now := metav1.Now()
pp := testProxyProvider()
pp.Status.PK = "42"
pp.DeletionTimestamp = &now
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
var outpostPartialUpdateCalled, destroyCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
outpostRetrieve: outpostRetrieveHandler(t, []int32{wantPK}),
outpostPartialUpdate: func(w http.ResponseWriter, r *http.Request) {
outpostPartialUpdateCalled = true
var body struct {
Providers []int32 `json:"providers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode outpost patch body: %v", err)
}
if slices.Contains(body.Providers, wantPK) {
t.Fatalf("patched providers = %v, want provider %d removed", body.Providers, wantPK)
}
writeJSON(t, w, http.StatusOK, map[string]any{"pk": testOutpostID, "providers": body.Providers})
},
proxyDestroy: func(w http.ResponseWriter, r *http.Request) {
destroyCalled = true
if r.Method != http.MethodDelete {
t.Errorf("destroy method = %s, want DELETE", r.Method)
}
w.WriteHeader(http.StatusNoContent)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if !outpostPartialUpdateCalled {
t.Fatal("expected Authentik outpost partial update call")
}
if !destroyCalled {
t.Fatal("expected Authentik destroy call")
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikProxyProviderFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed", got.Finalizers)
}
}
func TestController_syncHandler_delete_providerNotInOutpost(t *testing.T) {
now := metav1.Now()
pp := testProxyProvider()
pp.Status.PK = "42"
pp.DeletionTimestamp = &now
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
var outpostPartialUpdateCalled, destroyCalled bool
server := newAuthentikTestServer(t, authentikTestHandlers{
outpostRetrieve: outpostRetrieveHandler(t, nil),
outpostPartialUpdate: func(w http.ResponseWriter, _ *http.Request) {
outpostPartialUpdateCalled = true
},
proxyDestroy: func(w http.ResponseWriter, _ *http.Request) {
destroyCalled = true
w.WriteHeader(http.StatusNoContent)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
if outpostPartialUpdateCalled {
t.Fatal("did not expect Authentik outpost partial update when provider is not in outpost")
}
if !destroyCalled {
t.Fatal("expected Authentik destroy call")
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikProxyProviderFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed", got.Finalizers)
}
}
func TestController_syncHandler_delete_providerAlreadyGone(t *testing.T) {
const wantPK int32 = 42
now := metav1.Now()
pp := testProxyProvider()
pp.Status.PK = "42"
pp.DeletionTimestamp = &now
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{
outpostRetrieve: outpostRetrieveHandler(t, []int32{wantPK}),
outpostPartialUpdate: func(w http.ResponseWriter, r *http.Request) {
var body struct {
Providers []int32 `json:"providers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode outpost patch body: %v", err)
}
writeJSON(t, w, http.StatusOK, map[string]any{"pk": testOutpostID, "providers": body.Providers})
},
proxyDestroy: func(w http.ResponseWriter, _ *http.Request) {
http.NotFound(w, nil)
},
})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err != nil {
t.Fatalf("syncHandler() error = %v", err)
}
got := getProxyProvider(t, ctrl, pp.Namespace, pp.Name)
if slices.Contains(got.Finalizers, DeleteAuthentikProxyProviderFinalizer) {
t.Fatalf("finalizers = %v, want finalizer removed after 404", got.Finalizers)
}
}
func TestController_syncHandler_notFound(t *testing.T) {
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, nil, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: "default", Name: "missing"})
if err != nil {
t.Fatalf("syncHandler() error = %v, want nil for missing object", err)
}
}
func TestController_syncHandler_invalidPK(t *testing.T) {
pp := testProxyProvider()
pp.Status.PK = "not-a-number"
pp.Finalizers = []string{DeleteAuthentikProxyProviderFinalizer}
server := newAuthentikTestServer(t, authentikTestHandlers{})
t.Cleanup(server.Close)
ctrl, ctx, cancel := newTestController(t, pp, server.URL)
t.Cleanup(cancel)
err := ctrl.syncHandler(ctx, cache.ObjectName{Namespace: pp.Namespace, Name: pp.Name})
if err == nil {
t.Fatal("syncHandler() error = nil, want parse error")
}
if !strings.Contains(err.Error(), "error parsing PK") {
t.Fatalf("syncHandler() error = %v, want PK parse error", err)
}
}
// --- test helpers ---
func testProxyProvider() *v1alpha1.ProxyProvider {
return &v1alpha1.ProxyProvider{
TypeMeta: metav1.TypeMeta{
APIVersion: v1alpha1.SchemeGroupVersion.String(),
Kind: "ProxyProvider",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test-pp",
Namespace: "default",
},
Spec: v1alpha1.ProxyProviderSpec{
Name: "my-app",
AuthorizationFlow: "flow-auth",
InvalidationFlow: "flow-invalidate",
ExternalHost: "https://app.example.com",
Outpost: testOutpostID,
},
}
}
func newTestController(t *testing.T, pp *v1alpha1.ProxyProvider, authentikURL string) (*ProxyProviderController, context.Context, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
ctrl, _, stop := newTestControllerWithContext(t, ctx, pp, authentikURL)
return ctrl, ctx, func() {
cancel()
stop()
}
}
func newTestControllerWithContext(t *testing.T, ctx context.Context, pp *v1alpha1.ProxyProvider, authentikURL string) (*ProxyProviderController, context.Context, func()) {
t.Helper()
authentikClient := newAuthentikAPIClientForTest(t, authentikURL)
var objects []runtime.Object
if pp != nil {
objects = append(objects, pp)
}
proxyClient := operatorfake.NewSimpleClientset(objects...)
informerFactory := operatorinformers.NewSharedInformerFactory(proxyClient, 0)
proxyInformer := informerFactory.Proxyprovider().V1alpha1().ProxyProviders()
ctrl := NewController(ctx, fake.NewClientset(), proxyClient, authentikClient, proxyInformer)
informerFactory.Start(ctx.Done())
for informerType, synced := range informerFactory.WaitForCacheSync(ctx.Done()) {
if !synced {
t.Fatalf("informer %v failed to sync", informerType)
}
}
return ctrl, ctx, func() {}
}
func newAuthentikAPIClientForTest(t *testing.T, serverURL string) *authentikapi.APIClient {
t.Helper()
u, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
cfg := authentikapi.NewConfiguration()
cfg.Scheme = u.Scheme
cfg.Host = u.Host
return authentikapi.NewAPIClient(cfg)
}
type authentikTestHandlers struct {
proxyCreate http.HandlerFunc
proxyDestroy http.HandlerFunc
proxyPartialUpdate http.HandlerFunc
allRetrieve http.HandlerFunc
outpostRetrieve http.HandlerFunc
outpostPartialUpdate http.HandlerFunc
}
func outpostRetrieveHandler(t *testing.T, providers []int32) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]any{
"pk": testOutpostID,
"providers": providers,
})
}
}
func newAuthentikTestServer(t *testing.T, handlers authentikTestHandlers) *httptest.Server {
t.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/api/v3/providers/proxy/" && r.Method == http.MethodPost:
if handlers.proxyCreate != nil {
handlers.proxyCreate(w, r)
return
}
http.NotFound(w, r)
case strings.HasPrefix(path, "/api/v3/providers/proxy/") && strings.HasSuffix(path, "/"):
idPath := strings.TrimPrefix(path, "/api/v3/providers/proxy/")
if idPath == "" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
if handlers.proxyDestroy != nil {
handlers.proxyDestroy(w, r)
return
}
http.NotFound(w, r)
case http.MethodPatch:
if handlers.proxyPartialUpdate != nil {
handlers.proxyPartialUpdate(w, r)
return
}
http.NotFound(w, r)
default:
http.Error(w, "unexpected method on proxy instance", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/api/v3/providers/all/") && strings.HasSuffix(path, "/"):
if r.Method == http.MethodGet && handlers.allRetrieve != nil {
handlers.allRetrieve(w, r)
return
}
http.NotFound(w, r)
case strings.HasPrefix(path, "/api/v3/outposts/instances/") && strings.HasSuffix(path, "/"):
idPath := strings.TrimPrefix(path, "/api/v3/outposts/instances/")
idPath = strings.TrimSuffix(idPath, "/")
if idPath == "" || strings.Contains(idPath, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
if handlers.outpostRetrieve != nil {
handlers.outpostRetrieve(w, r)
return
}
http.NotFound(w, r)
case http.MethodPatch:
if handlers.outpostPartialUpdate != nil {
handlers.outpostPartialUpdate(w, r)
return
}
http.NotFound(w, r)
default:
http.Error(w, "unexpected method on outpost instance", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
})
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("write JSON response: %v", err)
}
}
func getProxyProvider(t *testing.T, ctrl *ProxyProviderController, namespace, name string) *v1alpha1.ProxyProvider {
t.Helper()
got, err := ctrl.proxyProviderClientset.ProxyproviderV1alpha1().ProxyProviders(namespace).Get(
context.Background(), name, metav1.GetOptions{},
)
if err != nil {
t.Fatalf("get ProxyProvider: %v", err)
}
return got
}