Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ metadata:
}
]
capabilities: Basic Install
createdAt: "2025-12-17T15:29:25Z"
createdAt: "2025-12-22T07:09:32Z"
operators.operatorframework.io/builder: operator-sdk-v1.41.1
operators.operatorframework.io/project_layout: go.kubebuilder.io/v4
name: jumpstarter-operator.v0.8.0
Expand Down Expand Up @@ -108,6 +108,14 @@ spec:
- get
- patch
- update
- apiGroups:
- config.openshift.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
Expand Down
8 changes: 8 additions & 0 deletions deploy/operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ rules:
- get
- patch
- update
- apiGroups:
- config.openshift.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
Expand Down
8 changes: 8 additions & 0 deletions deploy/operator/dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2725,6 +2725,14 @@ rules:
- get
- patch
- update
- apiGroups:
- config.openshift.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ limitations under the License.
package endpoints

import (
"context"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/log"
)
Expand Down Expand Up @@ -48,3 +54,41 @@ func discoverAPIResource(config *rest.Config, groupVersion, kind string) bool {

return false
}

// detectOpenShiftBaseDomain attempts to detect the cluster's base domain from OpenShift's
// ingresses.config.openshift.io/cluster resource. Returns empty string if not available.
func detectOpenShiftBaseDomain(config *rest.Config) string {
logger := log.Log.WithName("basedomain-detection")

// Create dynamic client for unstructured access to OpenShift config API
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
logger.Error(err, "Failed to create dynamic client for baseDomain detection")
return ""
}

// Define the GVR for ingresses.config.openshift.io
ingressGVR := schema.GroupVersionResource{
Group: "config.openshift.io",
Version: "v1",
Resource: "ingresses",
}

// Get the cluster-scoped "cluster" ingress config
ingressConfig, err := dynamicClient.Resource(ingressGVR).Get(context.Background(), "cluster", metav1.GetOptions{})
if err != nil {
// This is expected on non-OpenShift clusters, log at debug level
logger.V(1).Info("Could not fetch OpenShift ingress config (expected on non-OpenShift clusters)", "error", err.Error())
return ""
}

// Extract spec.domain from the unstructured object
domain, found, err := unstructured.NestedString(ingressConfig.Object, "spec", "domain")
if err != nil || !found || domain == "" {
logger.Info("OpenShift ingress config found but spec.domain not available")
return ""
}

logger.Info("Auto-detected OpenShift cluster domain", "domain", domain)
return domain
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
Copyright 2025.

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 endpoints

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"

operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/deploy/operator/api/v1alpha1"
)

// createTestJumpstarterSpec creates a JumpstarterSpec with the given baseDomain for testing
func createTestJumpstarterSpec(baseDomain string) *operatorv1alpha1.JumpstarterSpec {
return &operatorv1alpha1.JumpstarterSpec{
BaseDomain: baseDomain,
}
}

// createOpenShiftIngressConfig creates an OpenShift Ingress cluster config for testing
func createOpenShiftIngressConfig(domain string) *unstructured.Unstructured {
ingress := &unstructured.Unstructured{}
ingress.SetGroupVersionKind(schema.GroupVersionKind{
Group: "config.openshift.io",
Version: "v1",
Kind: "Ingress",
})
ingress.SetName("cluster")
ingress.Object["spec"] = map[string]interface{}{
"domain": domain,
}
return ingress
}

var _ = Describe("detectOpenShiftBaseDomain", func() {
// Note: These tests require OpenShift CRDs to be available in the test environment.
// They will be skipped if the CRDs are not present, which is expected in non-OpenShift environments.

Context("when OpenShift is available", func() {
BeforeEach(func() {
// Check if OpenShift CRDs are available
ingress := createOpenShiftIngressConfig("test-check.apps.example.com")
err := k8sClient.Create(ctx, ingress)
if err != nil {
Skip("Skipping OpenShift baseDomain auto-detection tests: OpenShift CRDs not available in test environment")
}
Expect(k8sClient.Delete(ctx, ingress)).To(Succeed())
})

Context("when OpenShift Ingress cluster config exists", func() {
It("should successfully auto-detect baseDomain", func() {
ingress := createOpenShiftIngressConfig("apps.example.com")
Expect(k8sClient.Create(ctx, ingress)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, ingress) })

Expect(detectOpenShiftBaseDomain(cfg)).To(Equal("apps.example.com"))
})
})

Context("when OpenShift Ingress cluster config has empty domain", func() {
It("should return empty string", func() {
ingress := createOpenShiftIngressConfig("")
Expect(k8sClient.Create(ctx, ingress)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, ingress) })

Expect(detectOpenShiftBaseDomain(cfg)).To(Equal(""))
})
})

Context("when OpenShift Ingress cluster config has no spec.domain", func() {
It("should return empty string", func() {
// Create a mock OpenShift Ingress cluster config without domain field
ingress := &unstructured.Unstructured{}
ingress.SetGroupVersionKind(schema.GroupVersionKind{
Group: "config.openshift.io",
Version: "v1",
Kind: "Ingress",
})
ingress.SetName("cluster")
ingress.Object["spec"] = map[string]interface{}{}

Expect(k8sClient.Create(ctx, ingress)).To(Succeed())
DeferCleanup(func() { _ = k8sClient.Delete(ctx, ingress) })

Expect(detectOpenShiftBaseDomain(cfg)).To(Equal(""))
})
})
})

Context("when OpenShift Ingress cluster config does not exist", func() {
It("should return empty string", func() {
// Try to auto-detect when no Ingress config exists
// This test will work even without OpenShift CRDs because it just checks the fallback behavior
detectedDomain := detectOpenShiftBaseDomain(cfg)
Expect(detectedDomain).To(Equal(""))
})
})
})

var _ = Describe("DefaultBaseDomain in Reconciler", func() {
Context("when baseDomain is auto-detected", func() {
It("should apply default baseDomain in ApplyDefaults when spec.BaseDomain is empty", func() {
reconciler := NewReconciler(k8sClient, k8sClient.Scheme(), cfg)

// Manually set a default baseDomain for testing
reconciler.DefaultBaseDomain = "apps.example.com"

// Create a spec with empty baseDomain
spec := createTestJumpstarterSpec("")

// Apply defaults with a namespace
reconciler.ApplyDefaults(spec, "test-namespace")

// Should use the default baseDomain with namespace prefix
Expect(spec.BaseDomain).To(Equal("jumpstarter.test-namespace.apps.example.com"))
})

It("should not override user-provided baseDomain", func() {
reconciler := NewReconciler(k8sClient, k8sClient.Scheme(), cfg)

// Set a default baseDomain
reconciler.DefaultBaseDomain = "apps.example.com"

// Create a spec with user-provided baseDomain
spec := createTestJumpstarterSpec("user.custom.domain")

// Apply defaults
reconciler.ApplyDefaults(spec, "test-namespace")

// Should keep the user-provided baseDomain
Expect(spec.BaseDomain).To(Equal("user.custom.domain"))
})

It("should not set baseDomain when DefaultBaseDomain is empty", func() {
reconciler := NewReconciler(k8sClient, k8sClient.Scheme(), cfg)

// No default baseDomain set (simulating non-OpenShift cluster)
reconciler.DefaultBaseDomain = ""

// Create a spec with empty baseDomain
spec := createTestJumpstarterSpec("")

// Apply defaults
reconciler.ApplyDefaults(spec, "test-namespace")

// baseDomain should remain empty
Expect(spec.BaseDomain).To(Equal(""))
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ import (

// Reconciler provides endpoint reconciliation functionality
type Reconciler struct {
Client client.Client
Scheme *runtime.Scheme
IngressAvailable bool
RouteAvailable bool
Client client.Client
Scheme *runtime.Scheme
IngressAvailable bool
RouteAvailable bool
DefaultBaseDomain string // Default baseDomain auto-detected from cluster (e.g., OpenShift ingress config)
}

// NewReconciler creates a new endpoint reconciler
Expand All @@ -48,21 +49,34 @@ func NewReconciler(client client.Client, scheme *runtime.Scheme, config *rest.Co
ingressAvailable := discoverAPIResource(config, "networking.k8s.io/v1", "Ingress")
routeAvailable := discoverAPIResource(config, "route.openshift.io/v1", "Route")

// Attempt to auto-detect default baseDomain on OpenShift clusters
var defaultBaseDomain string
if routeAvailable {
defaultBaseDomain = detectOpenShiftBaseDomain(config)
}

log.Info("API discovery completed",
"ingressAvailable", ingressAvailable,
"routeAvailable", routeAvailable)
"routeAvailable", routeAvailable,
"defaultBaseDomain", defaultBaseDomain)

return &Reconciler{
Client: client,
Scheme: scheme,
IngressAvailable: ingressAvailable,
RouteAvailable: routeAvailable,
Client: client,
Scheme: scheme,
IngressAvailable: ingressAvailable,
RouteAvailable: routeAvailable,
DefaultBaseDomain: defaultBaseDomain,
}
}

// ApplyDefaults applies endpoint defaults to a JumpstarterSpec using the
// reconciler's discovered cluster capabilities (Route vs Ingress availability).
func (r *Reconciler) ApplyDefaults(spec *operatorv1alpha1.JumpstarterSpec) {
// If baseDomain is not provided in the spec, it will generate one using the pattern
// jumpstarter.$namespace.$clusterDomain (auto-detected from OpenShift cluster config).
func (r *Reconciler) ApplyDefaults(spec *operatorv1alpha1.JumpstarterSpec, namespace string) {
if spec.BaseDomain == "" && r.DefaultBaseDomain != "" {
spec.BaseDomain = fmt.Sprintf("jumpstarter.%s.%s", namespace, r.DefaultBaseDomain)
}
ApplyEndpointDefaults(spec, r.RouteAvailable, r.IngressAvailable)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ type JumpstarterReconciler struct {
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes/custom-host,verbs=get;create;update;patch

// OpenShift cluster config (for baseDomain auto-detection)
// +kubebuilder:rbac:groups=config.openshift.io,resources=ingresses,verbs=get;list;watch

// Monitoring resources
// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete

Expand Down Expand Up @@ -130,7 +133,7 @@ func (r *JumpstarterReconciler) Reconcile(ctx context.Context, req ctrl.Request)

// Apply runtime-computed defaults (endpoints based on baseDomain and cluster capabilities)
// Static defaults are handled by kubebuilder annotations in the CRD schema
r.EndpointReconciler.ApplyDefaults(&jumpstarter.Spec)
r.EndpointReconciler.ApplyDefaults(&jumpstarter.Spec, jumpstarter.Namespace)

// Reconcile RBAC resources first
if err := r.reconcileRBAC(ctx, &jumpstarter); err != nil {
Expand Down