Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 69 additions & 14 deletions cmd/team-operator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
package main

import (
"context"
"flag"
"os"
"strconv"

"github.com/posit-dev/team-operator/api/keycloak/v2alpha1"
"github.com/posit-dev/team-operator/api/product"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1"
"k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
Expand Down Expand Up @@ -73,6 +78,29 @@ func init() {
LoadSchemes(scheme)
}

// isCRDPresent checks if a Custom Resource Definition exists on the cluster
func isCRDPresent(ctx context.Context, config *rest.Config, crdName string) (bool, error) {
// Create a clientset for CRD operations
crdClient, err := clientset.NewForConfig(config)
if err != nil {
return false, err
}

// Try to get the CRD
_, err = crdClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crdName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
// CRD doesn't exist, which is okay
return false, nil
}
// Some other error occurred
return false, err
}

// CRD exists
return true, nil
}

func main() {
var (
metricsAddr string
Expand Down Expand Up @@ -132,13 +160,27 @@ func main() {
os.Exit(1)
}

if err = (&corecontroller.SiteReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Log: setupLog,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Site")
os.Exit(1)
// Check if Site CRD exists before setting up Site controller
ctx := context.Background()
siteCRDExists, err := isCRDPresent(ctx, mgr.GetConfig(), "sites.core.posit.team")
if err != nil {
setupLog.Error(err, "unable to check if Site CRD exists")
// Continue without Site controller rather than exiting
siteCRDExists = false
}

if siteCRDExists {
setupLog.Info("Site CRD found, setting up Site controller")
if err = (&corecontroller.SiteReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Log: setupLog,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Site")
os.Exit(1)
}
} else {
setupLog.Info("Site CRD not found, skipping Site controller setup")
}

if err = (&corecontroller.PostgresDatabaseReconciler{
Expand Down Expand Up @@ -185,13 +227,26 @@ func main() {
os.Exit(1)
}

if err = (&corecontroller.FlightdeckReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Log: setupLog,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Flightdeck")
os.Exit(1)
// Check if Flightdeck CRD exists before setting up Flightdeck controller
flightdeckCRDExists, err := isCRDPresent(ctx, mgr.GetConfig(), "flightdecks.core.posit.team")
if err != nil {
setupLog.Error(err, "unable to check if Flightdeck CRD exists")
// Continue without Flightdeck controller rather than exiting
flightdeckCRDExists = false
}

if flightdeckCRDExists {
setupLog.Info("Flightdeck CRD found, setting up Flightdeck controller")
if err = (&corecontroller.FlightdeckReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Log: setupLog,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Flightdeck")
os.Exit(1)
}
} else {
setupLog.Info("Flightdeck CRD not found, skipping Flightdeck controller setup")
}

//+kubebuilder:scaffold:builder
Expand Down
15 changes: 15 additions & 0 deletions flightdeck/internal/kube.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package internal
import (
"context"
"fmt"
"strings"

positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -109,6 +111,19 @@ func (c *siteClient) Get(name string, namespace string, opts metav1.GetOptions,
Into(&result)

if err != nil {
// Check if the error is because Sites CRD doesn't exist
// This can happen on clusters without sites
// The error will typically be "the server could not find the requested resource"
// when the CRD is not installed
errStr := err.Error()
if strings.Contains(errStr, "the server could not find the requested resource") ||
strings.Contains(errStr, "no matches for kind") {
slog.Info("Sites CRD not found on cluster, returning empty site", "name", name, "namespace", namespace)
// Return an empty Site with minimal info for display
result.Name = name
result.Namespace = namespace
return &result, nil
}
slog.Error("failed to fetch site", "name", name, "namespace", namespace, "error", err)
return &result, err
}
Expand Down
109 changes: 109 additions & 0 deletions flightdeck/internal/kube_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package internal

import (
"context"
"errors"
"testing"

positcov1beta1 "github.com/posit-dev/team-operator/api/core/v1beta1"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"k8s.io/client-go/rest/fake"
)

func TestSiteClient_Get_HandlesNoCRD(t *testing.T) {
tests := []struct {
name string
setupREST func() *fake.RESTClient
wantError bool
wantEmpty bool
errMsg string
}{
{
name: "CRD not found - returns empty site",
setupREST: func() *fake.RESTClient {
client := &fake.RESTClient{
NegotiatedSerializer: runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{}),
}
client.Err = errors.New("the server could not find the requested resource")
return client
},
wantError: false,
wantEmpty: true,
},
{
name: "No matches for kind - returns empty site",
setupREST: func() *fake.RESTClient {
client := &fake.RESTClient{
NegotiatedSerializer: runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{}),
}
client.Err = errors.New("no matches for kind \"Site\" in version \"core.posit.team/v1beta1\"")
return client
},
wantError: false,
wantEmpty: true,
},
{
name: "Other error - returns error",
setupREST: func() *fake.RESTClient {
client := &fake.RESTClient{
NegotiatedSerializer: runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{}),
}
client.Err = errors.New("connection refused")
return client
},
wantError: true,
wantEmpty: false,
errMsg: "connection refused",
},
{
name: "Site found - returns site",
setupREST: func() *fake.RESTClient {
site := &positcov1beta1.Site{
ObjectMeta: metav1.ObjectMeta{
Name: "test-site",
Namespace: "posit-team",
},
}
client := &fake.RESTClient{
NegotiatedSerializer: runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{}),
Resp: &rest.Response{
Response: nil,
},
}
// In a real test, we'd properly mock the response
// For now, we're testing the error handling logic
return client
},
wantError: false,
wantEmpty: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &siteClient{
restClient: tt.setupREST(),
}

ctx := context.Background()
result, err := client.Get("test-site", "posit-team", metav1.GetOptions{}, ctx)

if tt.wantError {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
if tt.wantEmpty {
// When CRD doesn't exist, we return an empty site with just name/namespace
assert.Equal(t, "test-site", result.Name)
assert.Equal(t, "posit-team", result.Namespace)
}
}
})
}
}
Loading