Skip to content
Draft
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: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,5 @@ require (
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

replace github.com/kubeslice/apis => github.com/Shreesha001/apis v0.0.0-20260716162233-4dfda414c6d2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shreesha001/apis v0.0.0-20260716162233-4dfda414c6d2 h1:GraUvpBfFWegugw7Pbxtuv4pC3z4rv67uTTZWrmpmb4=
github.com/Shreesha001/apis v0.0.0-20260716162233-4dfda414c6d2/go.mod h1:F1hXnAt3Dk4Sto5yQDoMnqgXX5ImL1bRBiAmrW6TG00=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
Expand Down Expand Up @@ -264,8 +266,6 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kubeslice/apis v0.4.0 h1:nU66JoA2OQx48bZnXDWH8iHG+5R1ELX5ikc3l/fn5II=
github.com/kubeslice/apis v0.4.0/go.mod h1:F1hXnAt3Dk4Sto5yQDoMnqgXX5ImL1bRBiAmrW6TG00=
github.com/kubeslice/gateway-sidecar v0.2.0 h1:Ja3fIUivuSjUFQ4lPCt79ATq99BxslvAFYUwV9Urpy4=
github.com/kubeslice/gateway-sidecar v0.2.0/go.mod h1:nM1+Wjud2vk44cUg+9iwBbWTpqI+2Ecbn9NuaHEs9aY=
github.com/kubeslice/kubeslice-monitoring v0.2.1 h1:wtmIEigpQoKzuckof7QRqdsaa4lV/rqxd/FcmOj5N5Q=
Expand Down
11 changes: 10 additions & 1 deletion pkg/hub/controllers/slicegateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,16 @@ func (r *SliceGwReconciler) Reconcile(ctx context.Context, req reconcile.Request
}
}

return reconcile.Result{}, nil
// Report this gateway's tunnel connectivity up to the hub WorkerSliceGateway
// so the controller can aggregate slice-level topology convergence.
if err := r.reconcileGatewayConnectionStatus(ctx, sliceGw, meshSliceGw); err != nil {
log.Error(err, "unable to update gateway connection status on hub", "sliceGw", sliceGwName)
return reconcile.Result{}, err
}

// The hub reconciler does not watch the mesh cluster's SliceGateway, so
// periodically re-reconcile to pick up tunnel connectivity changes.
return reconcile.Result{RequeueAfter: gatewayStatusRefreshInterval}, nil
}

func (r *SliceGwReconciler) InjectClient(c client.Client) error {
Expand Down
105 changes: 105 additions & 0 deletions pkg/hub/controllers/slicegateway_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2026 Avesha, Inc. All rights reserved.
*
* SPDX-License-Identifier: Apache-2.0
*
* 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 controllers

import (
"context"
"time"

spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1"
kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// tunnelStateUp is the value gateway-sidecar reports (via getTunnelState) on a
// gateway pod whose tunnel is established. It mirrors the "UP" string set on
// SliceGateway.Status.GatewayPodStatus[].TunnelStatus.TunnelState.
const tunnelStateUp = "UP"

// gatewayStatusRefreshInterval is how often the hub reconciler re-checks the
// local SliceGateway tunnel status and reports it up, since it does not watch
// the mesh cluster directly.
const gatewayStatusRefreshInterval = 30 * time.Second

// deriveGatewayConnectionState aggregates the per-pod tunnel states of a local
// SliceGateway into a single WorkerSliceGateway connection state. It is HA-aware:
// the gateway is Connected when at least one pod's tunnel is up, NotConnected
// when all pods are down, and Pending when no pod status has been reported yet.
func deriveGatewayConnectionState(pods []*kubeslicev1beta1.GwPodInfo) string {
if len(pods) == 0 {
return spokev1alpha1.GatewayConnectionStatePending
}
for _, pod := range pods {
if pod != nil && pod.TunnelStatus.TunnelState == tunnelStateUp {
return spokev1alpha1.GatewayConnectionStateConnected
}
}
return spokev1alpha1.GatewayConnectionStateNotConnected
}

// reconcileGatewayConnectionStatus derives the gateway's connection state from
// the local SliceGateway's pod tunnel status and, when it has changed, writes it
// to the WorkerSliceGateway.status on the hub so the controller can aggregate
// slice-level topology convergence. The write is guarded against conflicts by
// re-fetching the latest object and retrying.
// reasonMessageForState returns a short machine-readable reason and a
// human-readable message for a connection state. The worker only observes
// tunnel up/down, so the reasons are coarse (it cannot distinguish e.g. a dial
// timeout from a not-yet-ready peer); they give operators a stable, honest
// signal without over-claiming precision.
func reasonMessageForState(state string) (reason, message string) {
switch state {
case spokev1alpha1.GatewayConnectionStateConnected:
return "TunnelEstablished", "gateway tunnel is up"
case spokev1alpha1.GatewayConnectionStateNotConnected:
return "TunnelDown", "all gateway pods report their tunnel is down"
default: // Pending / empty
return "Reconciling", "waiting for gateway tunnel connectivity to be reported"
}
}

func (r *SliceGwReconciler) reconcileGatewayConnectionStatus(ctx context.Context, sliceGw *spokev1alpha1.WorkerSliceGateway, meshSliceGw *kubeslicev1beta1.SliceGateway) error {
state := deriveGatewayConnectionState(meshSliceGw.Status.GatewayPodStatus)
reason, message := reasonMessageForState(state)
// Nothing to do when neither the state nor its reason/message has drifted.
if sliceGw.Status.ConnectionState == state && sliceGw.Status.Reason == reason && sliceGw.Status.Message == message {
return nil
}
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
latest := &spokev1alpha1.WorkerSliceGateway{}
if err := r.Get(ctx, client.ObjectKey{Name: sliceGw.Name, Namespace: sliceGw.Namespace}, latest); err != nil {
return err
}
if latest.Status.ConnectionState == state && latest.Status.Reason == reason && latest.Status.Message == message {
return nil
}
// LastTransitionTime marks connection-state changes; don't churn it on a
// reason/message-only correction.
if latest.Status.ConnectionState != state {
now := metav1.Now()
latest.Status.LastTransitionTime = &now
}
latest.Status.ConnectionState = state
latest.Status.Reason = reason
latest.Status.Message = message
return r.Status().Update(ctx, latest)
})
}
155 changes: 155 additions & 0 deletions pkg/hub/controllers/slicegateway_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2026 Avesha, Inc. All rights reserved.
*
* SPDX-License-Identifier: Apache-2.0
*
* 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 controllers

import (
"context"
"testing"

spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1"
kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func pod(state string) *kubeslicev1beta1.GwPodInfo {
return &kubeslicev1beta1.GwPodInfo{TunnelStatus: kubeslicev1beta1.TunnelStatus{TunnelState: state}}
}

func TestDeriveGatewayConnectionState(t *testing.T) {
cases := []struct {
name string
pods []*kubeslicev1beta1.GwPodInfo
want string
}{
{
name: "no pod status is Pending",
pods: nil,
want: spokev1alpha1.GatewayConnectionStatePending,
},
{
name: "all pods up is Connected",
pods: []*kubeslicev1beta1.GwPodInfo{pod("UP"), pod("UP")},
want: spokev1alpha1.GatewayConnectionStateConnected,
},
{
name: "at least one pod up is Connected (HA)",
pods: []*kubeslicev1beta1.GwPodInfo{pod("DOWN"), pod("UP")},
want: spokev1alpha1.GatewayConnectionStateConnected,
},
{
name: "all pods down is NotConnected",
pods: []*kubeslicev1beta1.GwPodInfo{pod("DOWN"), pod("DOWN")},
want: spokev1alpha1.GatewayConnectionStateNotConnected,
},
{
name: "unknown/empty pod states are not up",
pods: []*kubeslicev1beta1.GwPodInfo{pod("UNKNOWN"), pod("")},
want: spokev1alpha1.GatewayConnectionStateNotConnected,
},
{
name: "nil pod entries are ignored",
pods: []*kubeslicev1beta1.GwPodInfo{nil, pod("UP")},
want: spokev1alpha1.GatewayConnectionStateConnected,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := deriveGatewayConnectionState(tc.pods)
if got != tc.want {
t.Fatalf("%s: got %q, want %q", tc.name, got, tc.want)
}
})
}
}

func meshGwWithPods(states ...string) *kubeslicev1beta1.SliceGateway {
mesh := &kubeslicev1beta1.SliceGateway{}
for _, s := range states {
mesh.Status.GatewayPodStatus = append(mesh.Status.GatewayPodStatus, pod(s))
}
return mesh
}

func TestReconcileGatewayConnectionStatus(t *testing.T) {
scheme := runtime.NewScheme()
if err := spokev1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("add scheme: %v", err)
}
key := types.NamespacedName{Name: "slice-hub-spoke1", Namespace: "kubeslice-project"}

t.Run("writes Connected when a tunnel is up", func(t *testing.T) {
gw := &spokev1alpha1.WorkerSliceGateway{}
gw.Name, gw.Namespace = key.Name, key.Namespace
c := fake.NewClientBuilder().WithScheme(scheme).
WithObjects(gw).WithStatusSubresource(gw).Build()
r := &SliceGwReconciler{Client: c}

if err := r.reconcileGatewayConnectionStatus(context.Background(), gw, meshGwWithPods("UP")); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &spokev1alpha1.WorkerSliceGateway{}
if err := c.Get(context.Background(), key, got); err != nil {
t.Fatalf("get: %v", err)
}
if got.Status.ConnectionState != spokev1alpha1.GatewayConnectionStateConnected {
t.Fatalf("connectionState = %q, want Connected", got.Status.ConnectionState)
}
if got.Status.LastTransitionTime == nil {
t.Fatal("expected LastTransitionTime to be set on transition")
}
})

t.Run("no write when state is unchanged", func(t *testing.T) {
gw := &spokev1alpha1.WorkerSliceGateway{}
gw.Name, gw.Namespace = key.Name, key.Namespace
gw.Status.ConnectionState = spokev1alpha1.GatewayConnectionStateNotConnected
c := fake.NewClientBuilder().WithScheme(scheme).
WithObjects(gw).WithStatusSubresource(gw).Build()
r := &SliceGwReconciler{Client: c}

// all pods down -> NotConnected, same as current -> no update, no timestamp.
if err := r.reconcileGatewayConnectionStatus(context.Background(), gw, meshGwWithPods("DOWN")); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := &spokev1alpha1.WorkerSliceGateway{}
if err := c.Get(context.Background(), key, got); err != nil {
t.Fatalf("get: %v", err)
}
if got.Status.LastTransitionTime != nil {
t.Fatal("expected no LastTransitionTime when state is unchanged")
}
})
}

func TestReasonMessageForState(t *testing.T) {
cases := map[string]struct{ reason, msg string }{
spokev1alpha1.GatewayConnectionStateConnected: {"TunnelEstablished", "gateway tunnel is up"},
spokev1alpha1.GatewayConnectionStateNotConnected: {"TunnelDown", "all gateway pods report their tunnel is down"},
spokev1alpha1.GatewayConnectionStatePending: {"Reconciling", "waiting for gateway tunnel connectivity to be reported"},
"": {"Reconciling", "waiting for gateway tunnel connectivity to be reported"},
}
for state, want := range cases {
r, m := reasonMessageForState(state)
if r != want.reason || m != want.msg {
t.Errorf("state %q: got (%q,%q), want (%q,%q)", state, r, m, want.reason, want.msg)
}
}
}

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

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

Loading