mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
A GatewayClass lists the features it serves in status.supportedFeatures, and TLS passthrough is TLSRoute. On a class that leaves it out the passthrough listeners are accepted and serve nothing, so the TenantGateway now reads Ready=False/GatewayClassUnsupported naming the class instead of Ready. A class that lists no features at all is not judged: implementations predating the field leave it empty. The class writes that list after it is accepted and may change it when its controller is upgraded, so a GatewayClass event requeues the TenantGateways on it. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin <f@lex.la>
237 lines
7.9 KiB
Go
237 lines
7.9 KiB
Go
/*
|
|
Copyright 2026 The Cozystack 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 tenantgateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
gatewayv1alpha1 "github.com/cozystack/cozystack/api/gateway/v1alpha1"
|
|
)
|
|
|
|
// reconcileStatus refreshes status.observedGeneration, status.listeners,
|
|
// and the Ready condition on the TenantGateway based on the actual
|
|
// state of the rendered Gateway (Gateway.Status.Listeners +
|
|
// Gateway.Status.Conditions). Operators reading `kubectl get tgw`
|
|
// see real readiness, not a fictional always-True flag.
|
|
func (r *Reconciler) reconcileStatus(ctx context.Context, tgw *gatewayv1alpha1.TenantGateway) error {
|
|
gw := &gatewayv1.Gateway{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: tgw.Namespace, Name: tgw.Name}, gw); err != nil {
|
|
return fmt.Errorf("get Gateway for status: %w", err)
|
|
}
|
|
|
|
gwListenerStatus := indexListenerStatus(gw.Status.Listeners)
|
|
|
|
listeners := make([]gatewayv1alpha1.TenantGatewayListenerStatus, 0, len(gw.Spec.Listeners))
|
|
allReady := true
|
|
for _, l := range gw.Spec.Listeners {
|
|
ready, reason := listenerReadinessFromGatewayStatus(string(l.Name), gwListenerStatus)
|
|
s := gatewayv1alpha1.TenantGatewayListenerStatus{
|
|
Name: string(l.Name),
|
|
Ready: ready,
|
|
Reason: reason,
|
|
}
|
|
if l.Hostname != nil {
|
|
s.Hostname = string(*l.Hostname)
|
|
}
|
|
if l.TLS != nil && len(l.TLS.CertificateRefs) > 0 {
|
|
s.CertificateName = string(l.TLS.CertificateRefs[0].Name)
|
|
}
|
|
listeners = append(listeners, s)
|
|
if !ready {
|
|
allReady = false
|
|
}
|
|
}
|
|
|
|
gwAccepted, gwProgrammed := gatewayConditionStatus(gw.Status.Conditions)
|
|
|
|
unsupported, err := r.classLacksTLSRoute(ctx, tgw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var ready metav1.Condition
|
|
switch {
|
|
case unsupported:
|
|
ready = metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
ObservedGeneration: tgw.Generation,
|
|
Reason: "GatewayClassUnsupported",
|
|
Message: fmt.Sprintf("GatewayClass %s does not list TLSRoute in status.supportedFeatures, so nothing serves the TLS-passthrough listeners this TenantGateway renders", gatewayClassName(tgw)),
|
|
}
|
|
case !gwAccepted:
|
|
ready = metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
ObservedGeneration: tgw.Generation,
|
|
Reason: "GatewayNotAccepted",
|
|
Message: fmt.Sprintf("Underlying Gateway %s/%s has not been accepted by its controller yet", tgw.Namespace, tgw.Name),
|
|
}
|
|
case !gwProgrammed:
|
|
ready = metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
ObservedGeneration: tgw.Generation,
|
|
Reason: "GatewayNotProgrammed",
|
|
Message: fmt.Sprintf("Underlying Gateway %s/%s has not been programmed by its controller yet", tgw.Namespace, tgw.Name),
|
|
}
|
|
case !allReady:
|
|
ready = metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
ObservedGeneration: tgw.Generation,
|
|
Reason: "ListenersNotReady",
|
|
Message: fmt.Sprintf("One or more listeners on Gateway %s/%s are not ready", tgw.Namespace, tgw.Name),
|
|
}
|
|
default:
|
|
ready = metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionTrue,
|
|
ObservedGeneration: tgw.Generation,
|
|
Reason: "Reconciled",
|
|
Message: fmt.Sprintf("Gateway %s/%s programmed with %d listeners", tgw.Namespace, tgw.Name, len(listeners)),
|
|
}
|
|
}
|
|
|
|
stale := tgw.DeepCopy()
|
|
stale.Status.ObservedGeneration = tgw.Generation
|
|
stale.Status.Listeners = listeners
|
|
meta.SetStatusCondition(&stale.Status.Conditions, ready)
|
|
|
|
if statusEqual(tgw.Status, stale.Status) {
|
|
return nil
|
|
}
|
|
tgw.Status = stale.Status
|
|
return r.Status().Update(ctx, tgw)
|
|
}
|
|
|
|
// classLacksTLSRoute reports whether the spec renders TLS-passthrough
|
|
// listeners on a GatewayClass that declares it does not serve TLSRoute.
|
|
// A class that declares no features at all, or that does not exist, is
|
|
// not judged: status.supportedFeatures is how a class says what it
|
|
// serves, and implementations predating the field leave it empty.
|
|
func (r *Reconciler) classLacksTLSRoute(ctx context.Context, tgw *gatewayv1alpha1.TenantGateway) (bool, error) {
|
|
if len(renderedPassthroughServices(tgw)) == 0 && (len(tgw.Spec.TLSPassthroughListeners) == 0 || !rendersPassthroughListeners(tgw.Spec.CertMode)) {
|
|
return false, nil
|
|
}
|
|
class := &gatewayv1.GatewayClass{}
|
|
if err := r.Get(ctx, types.NamespacedName{Name: gatewayClassName(tgw)}, class); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("get GatewayClass %s: %w", gatewayClassName(tgw), err)
|
|
}
|
|
if len(class.Status.SupportedFeatures) == 0 {
|
|
return false, nil
|
|
}
|
|
return !slices.ContainsFunc(class.Status.SupportedFeatures, func(f gatewayv1.SupportedFeature) bool {
|
|
return f.Name == "TLSRoute"
|
|
}), nil
|
|
}
|
|
|
|
// indexListenerStatus turns Gateway.Status.Listeners into a name→status
|
|
// map for O(1) lookup per spec listener.
|
|
func indexListenerStatus(in []gatewayv1.ListenerStatus) map[string]gatewayv1.ListenerStatus {
|
|
out := make(map[string]gatewayv1.ListenerStatus, len(in))
|
|
for _, ls := range in {
|
|
out[string(ls.Name)] = ls
|
|
}
|
|
return out
|
|
}
|
|
|
|
// listenerReadinessFromGatewayStatus maps the Gateway controller's
|
|
// per-listener conditions onto our TenantGatewayListenerStatus.Ready
|
|
// boolean + a human-readable reason. A listener is ready iff both
|
|
// Accepted=True and Programmed=True. If the Gateway controller has
|
|
// not yet reported any condition for the listener, Ready=false with
|
|
// reason "Pending" — operators see "in flight" rather than a green
|
|
// light that does not exist.
|
|
func listenerReadinessFromGatewayStatus(name string, idx map[string]gatewayv1.ListenerStatus) (bool, string) {
|
|
ls, ok := idx[name]
|
|
if !ok {
|
|
return false, "Pending"
|
|
}
|
|
var accepted, programmed bool
|
|
for _, c := range ls.Conditions {
|
|
if c.Type == "Accepted" && c.Status == metav1.ConditionTrue {
|
|
accepted = true
|
|
}
|
|
if c.Type == "Programmed" && c.Status == metav1.ConditionTrue {
|
|
programmed = true
|
|
}
|
|
}
|
|
switch {
|
|
case accepted && programmed:
|
|
return true, ""
|
|
case !accepted:
|
|
return false, "NotAccepted"
|
|
default:
|
|
return false, "NotProgrammed"
|
|
}
|
|
}
|
|
|
|
// gatewayConditionStatus reports (Accepted, Programmed) booleans
|
|
// from the Gateway-level Status.Conditions. Either is false when the
|
|
// condition is missing or its Status is not True.
|
|
func gatewayConditionStatus(conds []metav1.Condition) (accepted, programmed bool) {
|
|
for _, c := range conds {
|
|
if c.Type == "Accepted" && c.Status == metav1.ConditionTrue {
|
|
accepted = true
|
|
}
|
|
if c.Type == "Programmed" && c.Status == metav1.ConditionTrue {
|
|
programmed = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func statusEqual(a, b gatewayv1alpha1.TenantGatewayStatus) bool {
|
|
if a.ObservedGeneration != b.ObservedGeneration {
|
|
return false
|
|
}
|
|
if len(a.Listeners) != len(b.Listeners) {
|
|
return false
|
|
}
|
|
for i := range a.Listeners {
|
|
if a.Listeners[i] != b.Listeners[i] {
|
|
return false
|
|
}
|
|
}
|
|
if len(a.Conditions) != len(b.Conditions) {
|
|
return false
|
|
}
|
|
for i := range a.Conditions {
|
|
ac, bc := a.Conditions[i], b.Conditions[i]
|
|
if ac.Type != bc.Type ||
|
|
ac.Status != bc.Status ||
|
|
ac.Reason != bc.Reason ||
|
|
ac.Message != bc.Message ||
|
|
ac.ObservedGeneration != bc.ObservedGeneration {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|