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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@
for `Gateway`s configured in different namespaces with `GatewayConfiguration` that
has field `spec.controlPlaneOptions.watchNamespaces.type` set to `own`.
[#2717](https://github.com/Kong/kong-operator/pull/2717)
- Gateway controllers now watch changes on Secrets referenced by
`spec.listeners.tls.certificateRef`, ensuring Gateway status conditions
are updated when referenced certificates change.
[#2661](https://github.com/Kong/kong-operator/pull/2661)

## [v2.0.5]

Expand Down
31 changes: 31 additions & 0 deletions controller/gateway/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
Expand All @@ -44,6 +46,7 @@ import (
operatorerrors "github.com/kong/kong-operator/internal/errors"
gwtypes "github.com/kong/kong-operator/internal/types"
"github.com/kong/kong-operator/internal/utils/gatewayclass"
idx "github.com/kong/kong-operator/internal/utils/index"
"github.com/kong/kong-operator/modules/manager/logging"
"github.com/kong/kong-operator/pkg/consts"
gatewayutils "github.com/kong/kong-operator/pkg/utils/gateway"
Expand Down Expand Up @@ -136,6 +139,34 @@ func (r *Reconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) err
),
)
}

// Watch Secrets to requeue Gateways that reference them via listeners.tls.certificateRefs.
builder.WatchesRawSource(
source.Kind(
mgr.GetCache(),
&corev1.Secret{},
handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, s *corev1.Secret) []reconcile.Request {
// Use field index to list only Gateways that reference this Secret.
var gwList gwtypes.GatewayList
nn := client.ObjectKeyFromObject(s)
if err := r.List(ctx, &gwList, client.MatchingFields{idx.TLSCertificateSecretsOnGatewayIndex: nn.String()}); err != nil {
ctrllog.FromContext(ctx).Error(err, "failed to list indexed gateways for Secret watch", "secret", nn)
return nil
}
recs := make([]reconcile.Request, 0, len(gwList.Items))
for i := range gwList.Items {
gw := gwList.Items[i]
// Optional pre-filter: only enqueue Gateways managed by this controller.
if !r.gatewayHasMatchingGatewayClass(&gw) {
continue
}
recs = append(recs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&gw)})
}
return recs
}),
),
Comment on lines +148 to +167
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about extracting this into a function and adding a test for it?

)

return builder.Complete(r)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"

"github.com/kong/kong-operator/ingress-controller/internal/annotations"
Expand Down Expand Up @@ -133,6 +134,15 @@ func (r *GatewayReconciler) SetupWithManager(mgr ctrl.Manager) error {
)
}

// Watch Secrets to immediately reconcile Gateways when referenced certificate Secrets change.
blder.WatchesRawSource(
source.Kind(
mgr.GetCache(),
&corev1.Secret{},
handler.TypedEnqueueRequestsFromMapFunc(r.listGatewaysForSecret),
),
)

if err := blder.Complete(r); err != nil {
return err
}
Expand Down Expand Up @@ -342,6 +352,25 @@ func (r *GatewayReconciler) listGatewaysForHTTPRoute(_ context.Context, obj clie
return recs
}

// listGatewaysForSecret returns reconcile requests for all Gateways that reference the given Secret
// via TLS certificateRefs.
func (r *GatewayReconciler) listGatewaysForSecret(_ context.Context, s *corev1.Secret) []reconcile.Request {
referrers, err := r.ReferenceIndexers.ListReferrerObjectsByReferent(s)
if err != nil {
r.Log.Error(err, "Failed to list referrers for Secret", "secret", client.ObjectKeyFromObject(s))
return nil
}
recs := make([]reconcile.Request, 0, len(referrers))
for _, obj := range referrers {
nn := client.ObjectKeyFromObject(obj)
if !r.GatewayNN.MatchesNN(nn) {
continue
}
recs = append(recs, reconcile.Request{NamespacedName: nn})
}
return recs
}

// isGatewayService is a watch predicate that filters out events for objects that aren't
// the gateway service referenced by --publish-service or --publish-service-udp.
func (r *GatewayReconciler) isGatewayService(obj client.Object) bool {
Expand Down
63 changes: 63 additions & 0 deletions internal/utils/index/gateway_tls_secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package index

import (
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

gwtypes "github.com/kong/kong-operator/internal/types"
)

const (
// TLSCertificateSecretsOnGatewayIndex maps Secret "namespace/name" to Gateway objects that
// reference them in listeners.tls.certificateRefs.
TLSCertificateSecretsOnGatewayIndex = "TLSCertificateSecretsOnGateway"
)

// OptionsForGatewayTLSSecret returns index options for Gateways referencing Secrets via TLS certificateRefs.
func OptionsForGatewayTLSSecret() []Option {
return []Option{
{
Object: &gwtypes.Gateway{},
Field: TLSCertificateSecretsOnGatewayIndex,
ExtractValueFn: tlsCertificateSecretsOnGateway,
},
}
}

// tlsCertificateSecretsOnGateway extracts Secret references (namespace/name) from a Gateway's listeners.tls.certificateRefs.
func tlsCertificateSecretsOnGateway(o client.Object) []string {
gw, ok := o.(*gwtypes.Gateway)
if !ok {
return nil
}

seen := make(map[string]struct{})
for _, l := range gw.Spec.Listeners {
if l.TLS == nil {
continue
}
for _, ref := range l.TLS.CertificateRefs {
// Only index core/v1 Secret references (or defaulted ones when group/kind are nil).
if ref.Group != nil && string(*ref.Group) != corev1.GroupName {
continue
}
if ref.Kind != nil && string(*ref.Kind) != "Secret" {
continue
}
ns := gw.Namespace
if ref.Namespace != nil {
ns = string(*ref.Namespace)
}
if ref.Name == "" {
continue
}
seen[ns+"/"+string(ref.Name)] = struct{}{}
}
}

out := make([]string, 0, len(seen))
for key := range seen {
out = append(out, key)
}
return out
}
3 changes: 3 additions & 0 deletions modules/manager/controller_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ func SetupCacheIndexes(ctx context.Context, mgr manager.Manager, cfg Config) err

if cfg.GatewayControllerEnabled {
indexOptions = append(indexOptions, index.OptionsForGatewayClass()...)
// Index Gateways by referenced TLS Secrets so the Gateway controller can
// efficiently list Gateways for a Secret event.
indexOptions = append(indexOptions, index.OptionsForGatewayTLSSecret()...)
}

if cfg.KonnectControllersEnabled {
Expand Down
155 changes: 155 additions & 0 deletions test/envtest/gateway_secret_watch_envtest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package envtest

import (
"context"
"testing"

"github.com/samber/lo"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

kogateway "github.com/kong/kong-operator/controller/gateway"
certhelper "github.com/kong/kong-operator/ingress-controller/test/helpers/certificate"
gwcdecor "github.com/kong/kong-operator/internal/utils/gatewayclass"
"github.com/kong/kong-operator/modules/manager/logging"
managerscheme "github.com/kong/kong-operator/modules/manager/scheme"
k8sutils "github.com/kong/kong-operator/pkg/utils/kubernetes"
"github.com/kong/kong-operator/pkg/vars"
)

func TestGatewaySecretWatch_UpdatesResolvedRefsOnSecretRotation(t *testing.T) {
t.Parallel()

// Prepare scheme, envtest, manager and KO Gateway reconciler.
scheme := managerscheme.Get()
ctx := t.Context()

cfg, ns := Setup(t, ctx, scheme)
mgr, logs := NewManager(t, ctx, cfg, scheme)

r := &kogateway.Reconciler{
Client: mgr.GetClient(),
Scheme: scheme,
Namespace: ns.Name,
DefaultDataPlaneImage: "kong:latest",
LoggingMode: logging.DevelopmentMode,
}
StartReconcilers(ctx, t, mgr, logs, r)

c := mgr.GetClient()

// Create a GatewayClass accepted by the controller.
gc := &gatewayv1.GatewayClass{
ObjectMeta: metav1.ObjectMeta{Name: "gc-ko"},
Spec: gatewayv1.GatewayClassSpec{
ControllerName: gatewayv1.GatewayController(vars.ControllerName()),
},
}
require.NoError(t, c.Create(ctx, gc))

t.Log("patching GatewayClass status to Accepted=True")
require.Eventually(t, func() bool {
var cur gatewayv1.GatewayClass
if err := c.Get(ctx, types.NamespacedName{Name: gc.Name}, &cur); err != nil {
return false
}
cond := metav1.Condition{
Type: string(gatewayv1.GatewayClassConditionStatusAccepted),
Status: metav1.ConditionTrue,
Reason: string(gatewayv1.GatewayClassReasonAccepted),
ObservedGeneration: cur.Generation,
LastTransitionTime: metav1.Now(),
}
// Use shared helper to set/merge the condition.
gwcd := gwcdecor.DecorateGatewayClass(&cur)
k8sutils.SetCondition(cond, gwcd)
if err := c.Status().Update(ctx, &cur); err != nil {
return false
}
return true
Comment on lines +55 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like something that we could reuse. We already have a bunch of similar helpers in https://github.com/Kong/kong-operator/blob/e90fd984547cd60e3ce8c481f368514dd1936411/pkg/utils/test/predicates.go, e.g.

// DataPlaneUpdateEventually is a helper function for tests that returns a function
// that can be used to update the DataPlane.
// Should be used in conjunction with require.Eventually or assert.Eventually.
func DataPlaneUpdateEventually(t *testing.T, ctx context.Context, dataplaneNN types.NamespacedName, clients K8sClients, updateFunc func(*operatorv1beta1.DataPlane)) func() bool {
return func() bool {
cl := clients.OperatorClient.GatewayOperatorV1beta1().DataPlanes(dataplaneNN.Namespace)
dp, err := cl.Get(ctx, dataplaneNN.Name, metav1.GetOptions{})
if err != nil {
t.Logf("error getting dataplane: %v", err)
return false
}
updateFunc(dp)
_, err = cl.Update(ctx, dp, metav1.UpdateOptions{})
if err != nil {
t.Logf("error updating dataplane: %v", err)
return false
}
return true
}
}
.

I think it would make sense to put it there and use it here. WDYT?

}, waitTime, tickTime)

// Create an initial INVALID TLS Secret referenced by the Gateway listener.
secretName := "test-cert"
bad := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: ns.Name, Name: secretName},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{
corev1.TLSCertKey: []byte("not-a-cert"),
corev1.TLSPrivateKeyKey: []byte("not-a-key"),
},
}
require.NoError(t, c.Create(ctx, bad))

// Create a Gateway with a TLS listener referencing the Secret.
gw := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Namespace: ns.Name, Name: "gw"},
Spec: gatewayv1.GatewaySpec{
GatewayClassName: gatewayv1.ObjectName(gc.Name),
Listeners: []gatewayv1.Listener{
{
Name: "https",
Port: 443,
Protocol: gatewayv1.HTTPSProtocolType,
TLS: &gatewayv1.ListenerTLSConfig{
Mode: lo.ToPtr(gatewayv1.TLSModeTerminate),
CertificateRefs: []gatewayv1.SecretObjectReference{{
Name: gatewayv1.ObjectName(secretName),
}},
},
},
},
},
}
require.NoError(t, c.Create(ctx, gw))

t.Log("verifying that the invalid Secret results in ResolvedRefs=False (InvalidCertificateRef)")
waitForGatewayResolvedRefsStatus(t, ctx, c, ns.Name, gw.Name, metav1.ConditionFalse)

t.Log("rotating the Secret with a valid TLS certificate and key")
certPEM, keyPEM := certhelper.MustGenerateCertPEMFormat()
require.NoError(t, c.Patch(ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: ns.Name, Name: secretName},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{
corev1.TLSCertKey: certPEM,
corev1.TLSPrivateKeyKey: keyPEM,
},
}, client.MergeFrom(bad)))

t.Log("verifying that after rotation the Secret watch enqueues the Gateway and ResolvedRefs becomes True")
waitForGatewayResolvedRefsStatus(t, ctx, c, ns.Name, gw.Name, metav1.ConditionTrue)
}

// waitForGatewayResolvedRefsStatus waits until the Gateway's listener ResolvedRefs condition
// matches the expected status, or fails after the default timeout.
func waitForGatewayResolvedRefsStatus(
t *testing.T,
ctx context.Context,
c client.Client,
namespace, name string,
status metav1.ConditionStatus,
) {
t.Helper()
require.Eventually(t, func() bool {
var cur gatewayv1.Gateway
if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &cur); err != nil {
return false
}
if len(cur.Status.Listeners) == 0 {
return false
}
for _, ls := range cur.Status.Listeners {
for _, cond := range ls.Conditions {
if cond.Type == string(gatewayv1.ListenerConditionResolvedRefs) && cond.Status == status {
return true
}
}
}
return false
}, waitTime, tickTime)
}
Comment on lines +128 to +155
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto about defining a reusable test assertion helper.

Loading