Skip to content
Merged
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
2 changes: 1 addition & 1 deletion deploy/operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ func main() {
if err := (&jumpstarter.JumpstarterReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
EndpointReconciler: endpoints.NewReconciler(mgr.GetClient(), mgr.GetScheme()),
EndpointReconciler: endpoints.NewReconciler(mgr.GetClient(), mgr.GetScheme(), mgr.GetConfig()),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Jumpstarter")
os.Exit(1)
Expand Down
1 change: 1 addition & 0 deletions deploy/operator/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/jumpstarter-dev/jumpstarter-controller v0.7.1
github.com/onsi/ginkgo/v2 v2.22.2
github.com/onsi/gomega v1.36.2
github.com/openshift/api v0.0.0-20251023135607-98e18dae8c7a
github.com/pmezard/go-difflib v1.0.0
k8s.io/api v0.33.0
k8s.io/apimachinery v0.33.0
Expand Down
2 changes: 2 additions & 0 deletions deploy/operator/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/openshift/api v0.0.0-20251023135607-98e18dae8c7a h1:Xi0/4kyXnyvnml1FG7q4xNGsXOBLGMIadZg7SxS8PNk=
github.com/openshift/api v0.0.0-20251023135607-98e18dae8c7a/go.mod h1:Shkl4HanLwDiiBzakv+con/aMGnVE2MAGvoKp5oyYUo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
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 (
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/log"
)

// discoverAPIResource checks if a specific API resource is available in the cluster
// groupVersion should be in the format "group/version" (e.g., "networking.k8s.io/v1", "route.openshift.io/v1")
// kind is the resource kind to look for (e.g., "Ingress", "Route")
func discoverAPIResource(config *rest.Config, groupVersion, kind string) bool {
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
log.Log.Error(err, "Failed to create discovery client",
"groupVersion", groupVersion,
"kind", kind)
return false
}

apiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(groupVersion)
if err != nil {
// API group not found - resource not available
return false
}

for _, resource := range apiResourceList.APIResources {
if resource.Kind == kind {
return true
}
}

return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
Expand All @@ -33,15 +34,29 @@ import (

// Reconciler provides endpoint reconciliation functionality
type Reconciler struct {
Client client.Client
Scheme *runtime.Scheme
Client client.Client
Scheme *runtime.Scheme
IngressAvailable bool
RouteAvailable bool
}

// NewReconciler creates a new endpoint reconciler
func NewReconciler(client client.Client, scheme *runtime.Scheme) *Reconciler {
func NewReconciler(client client.Client, scheme *runtime.Scheme, config *rest.Config) *Reconciler {
log := logf.Log.WithName("endpoints-reconciler")

// Discover API availability at initialization
ingressAvailable := discoverAPIResource(config, "networking.k8s.io/v1", "Ingress")
routeAvailable := discoverAPIResource(config, "route.openshift.io/v1", "Route")

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

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

Expand Down Expand Up @@ -137,6 +152,15 @@ func (r *Reconciler) ReconcileControllerEndpoint(ctx context.Context, owner meta
}
}

// Route resource (uses ClusterIP service)
if endpoint.Route != nil && endpoint.Route.Enabled {
serviceName := servicePort.Name
// Create the Route resource pointing to the ClusterIP service
if err := r.createRouteForEndpoint(ctx, owner, serviceName, servicePort.Port, endpoint, baseLabels); err != nil {
return err
}
}

// LoadBalancer service
if endpoint.LoadBalancer != nil && endpoint.LoadBalancer.Enabled {
if err := r.createService(ctx, owner, servicePort, "-lb", corev1.ServiceTypeLoadBalancer,
Expand Down Expand Up @@ -220,10 +244,11 @@ func (r *Reconciler) ReconcileRouterReplicaEndpoint(ctx context.Context, owner m
}
}

// Route service
// Route resource (uses ClusterIP service)
if endpoint.Route != nil && endpoint.Route.Enabled {
if err := r.createService(ctx, owner, servicePort, "-route", corev1.ServiceTypeClusterIP,
podSelector, baseLabels, endpoint.Route.Annotations, endpoint.Route.Labels); err != nil {
serviceName := servicePort.Name
// Create the Route resource pointing to the ClusterIP service
if err := r.createRouteForEndpoint(ctx, owner, serviceName, servicePort.Port, endpoint, baseLabels); err != nil {
return err
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ var _ = Describe("Endpoints Reconciler", func() {
var owner *corev1.ConfigMap // Use ConfigMap as a simple owner object for testing

BeforeEach(func() {
reconciler = NewReconciler(k8sClient, k8sClient.Scheme())
reconciler = NewReconciler(k8sClient, k8sClient.Scheme(), cfg)

// Create the test namespace
ns := &corev1.Namespace{
Expand Down Expand Up @@ -478,7 +478,7 @@ var _ = Describe("Endpoints Reconciler", func() {
var owner *corev1.ConfigMap

BeforeEach(func() {
reconciler = NewReconciler(k8sClient, k8sClient.Scheme())
reconciler = NewReconciler(k8sClient, k8sClient.Scheme(), cfg)

// Create the test namespace
ns := &corev1.Namespace{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,18 @@ func extractHostname(address string) string {
func (r *Reconciler) createIngressForEndpoint(ctx context.Context, owner metav1.Object, serviceName string, servicePort int32,
endpoint *operatorv1alpha1.Endpoint, baseLabels map[string]string) error {

log := logf.FromContext(ctx)

// Check if Ingress API is available in the cluster
if !r.IngressAvailable {
log.Info("Skipping ingress creation: Ingress API not available in cluster")
// TODO: update status of the jumpstarter object to indicate that the ingress is not available
return nil
}

// Extract hostname from address
hostname := extractHostname(endpoint.Address)
if hostname == "" {
log := logf.FromContext(ctx)
log.Info("Skipping ingress creation: no hostname in endpoint address",
"address", endpoint.Address)
return nil
Expand Down
147 changes: 147 additions & 0 deletions deploy/operator/internal/controller/jumpstarter/endpoints/route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
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 (
"context"
"errors"
"strings"

routev1 "github.com/openshift/api/route/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"

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

// createOrUpdateRoute creates or updates a route with proper handling of mutable fields
// and owner references. This follows the same pattern as createOrUpdateService and createOrUpdateIngress.
func (r *Reconciler) createOrUpdateRoute(ctx context.Context, route *routev1.Route, owner metav1.Object) error {
log := logf.FromContext(ctx)

existingRoute := &routev1.Route{}
existingRoute.Name = route.Name
existingRoute.Namespace = route.Namespace

op, err := controllerutil.CreateOrUpdate(ctx, r.Client, existingRoute, func() error {
// Update all mutable fields
existingRoute.Labels = route.Labels
existingRoute.Annotations = route.Annotations
existingRoute.Spec.Host = route.Spec.Host
existingRoute.Spec.Path = route.Spec.Path
existingRoute.Spec.Port = route.Spec.Port
existingRoute.Spec.TLS = route.Spec.TLS
existingRoute.Spec.To = route.Spec.To
existingRoute.Spec.WildcardPolicy = route.Spec.WildcardPolicy

return controllerutil.SetControllerReference(owner, existingRoute, r.Scheme)
})

if err != nil {
log.Error(err, "Failed to reconcile route",
"name", route.Name,
"namespace", route.Namespace)
return err
}

log.Info("Route reconciled",
"name", route.Name,
"namespace", route.Namespace,
"operation", op)

return nil
}

// createRouteForEndpoint creates an OpenShift Route for a specific endpoint.
// The route points to the ClusterIP service (serviceName with no suffix).
func (r *Reconciler) createRouteForEndpoint(ctx context.Context, owner metav1.Object, serviceName string, servicePort int32,
endpoint *operatorv1alpha1.Endpoint, baseLabels map[string]string) error {

log := logf.FromContext(ctx)

// Check if Route API is available in the cluster
if !r.RouteAvailable {
log.Info("Skipping route creation: Route API not available in cluster")
// TODO: update status of the jumpstarter object to indicate that the route is not available
return nil
}

// Extract hostname from address
hostname := extractHostname(endpoint.Address)
if hostname == "" {
log.Info("Skipping route creation: no hostname in endpoint address",
"address", endpoint.Address)
return nil
}

if errs := validation.IsDNS1123Subdomain(hostname); errs != nil {
log := logf.FromContext(ctx)
log.Error(errors.New(strings.Join(errs, ", ")), "Skipping ingress creation: invalid hostname",
"address", endpoint.Address,
"hostname", hostname)
// TODO: propagate error to status conditions
return nil
}

// Build default annotations for OpenShift HAProxy router with longer timeouts for gRPC
defaultAnnotations := map[string]string{
"haproxy.router.openshift.io/timeout": "2d",
"haproxy.router.openshift.io/timeout-tunnel": "2d",
}

// Merge with user-provided annotations (user annotations take precedence)
annotations := utils.MergeMaps(defaultAnnotations, endpoint.Route.Annotations)

// Merge labels (user labels take precedence)
routeLabels := utils.MergeMaps(baseLabels, endpoint.Route.Labels)

// Use passthrough TLS termination (TLS is handled by the backend service)
// This is consistent with the Ingress configuration which uses ssl-passthrough
tlsTermination := routev1.TLSTerminationPassthrough

route := &routev1.Route{
ObjectMeta: metav1.ObjectMeta{
Name: serviceName + "-route",
Namespace: owner.GetNamespace(),
Labels: routeLabels,
Annotations: annotations,
},
Spec: routev1.RouteSpec{
Host: hostname,
Port: &routev1.RoutePort{
TargetPort: intstr.FromInt(int(servicePort)),
},
To: routev1.RouteTargetReference{
Kind: "Service",
Name: serviceName,
Weight: ptr.To(int32(100)),
},
TLS: &routev1.TLSConfig{
Termination: tlsTermination,
InsecureEdgeTerminationPolicy: routev1.InsecureEdgeTerminationPolicyNone,
},
WildcardPolicy: routev1.WildcardPolicyNone,
},
}

return r.createOrUpdateRoute(ctx, route, owner)
}
Loading
Loading