Skip to content

Commit 9527fce

Browse files
Merge pull request #2198 from stuggi/fix-route-cert-secret-watch
Reconcile when custom route cert secret content changes
2 parents dd6e27f + 6388035 commit 9527fce

4 files changed

Lines changed: 346 additions & 45 deletions

File tree

‎internal/controller/core/openstackcontrolplane_controller.go‎

Lines changed: 74 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ package core
2020
import (
2121
"context"
2222
"fmt"
23+
"maps"
24+
"slices"
2325
"strings"
2426
"time"
2527

@@ -790,77 +792,59 @@ const (
790792
tlsIngressCACustomIssuer = ".spec.tls.ingress.ca.customIssuer"
791793
tlsPodLevelInternalCACustomIssuer = ".spec.tls.podLevel.internal.ca.customIssuer"
792794
tlsPodLevelOvnCACustomIssuer = ".spec.tls.podLevel.ovn.ca.customIssuer"
795+
apiOverrideTLSSecretField = ".spec.apiOverride.tls.secretName"
793796
)
794797

795-
var allWatchFields = []string{
796-
passwordSecretField,
797-
tlsCABundleSecretNameField,
798-
tlsIngressCACustomIssuer,
799-
tlsPodLevelInternalCACustomIssuer,
800-
tlsPodLevelOvnCACustomIssuer,
801-
}
802-
803-
// SetupWithManager sets up the controller with the Manager.
804-
func (r *OpenStackControlPlaneReconciler) SetupWithManager(
805-
ctx context.Context, mgr ctrl.Manager) error {
806-
// index passwordSecretField
807-
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, passwordSecretField, func(rawObj client.Object) []string {
808-
// Extract the secret name from the spec, if one is provided
798+
// fieldIndexers maps each indexed field to the func extracting its value(s)
799+
// from the OpenStackControlPlane. The fields are used to reconcile when a
800+
// referenced secret/issuer changes, see findObjectsForSrc.
801+
var fieldIndexers = map[string]client.IndexerFunc{
802+
passwordSecretField: func(rawObj client.Object) []string {
809803
cr := rawObj.(*corev1beta1.OpenStackControlPlane)
810804
if cr.Spec.Secret == "" {
811805
return nil
812806
}
813807
return []string{cr.Spec.Secret}
814-
}); err != nil {
815-
return err
816-
}
817-
818-
// index caBundleSecretNameField
819-
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, tlsCABundleSecretNameField, func(rawObj client.Object) []string {
820-
// Extract the secret name from the spec, if one is provided
808+
},
809+
tlsCABundleSecretNameField: func(rawObj client.Object) []string {
821810
cr := rawObj.(*corev1beta1.OpenStackControlPlane)
822811
if cr.Spec.TLS.CaBundleSecretName == "" {
823812
return nil
824813
}
825814
return []string{cr.Spec.TLS.CaBundleSecretName}
826-
}); err != nil {
827-
return err
828-
}
829-
830-
// index tlsIngressCACustomIssuer
831-
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, tlsIngressCACustomIssuer, func(rawObj client.Object) []string {
832-
// Extract the secret name from the spec, if one is provided
815+
},
816+
tlsIngressCACustomIssuer: func(rawObj client.Object) []string {
833817
cr := rawObj.(*corev1beta1.OpenStackControlPlane)
834818
if cr.Spec.TLS.Ingress.Ca.CustomIssuer == nil {
835819
return nil
836820
}
837821
return []string{*cr.Spec.TLS.Ingress.Ca.CustomIssuer}
838-
}); err != nil {
839-
return err
840-
}
841-
842-
// index tlsPodLevelInternalCACustomIssuer
843-
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, tlsPodLevelInternalCACustomIssuer, func(rawObj client.Object) []string {
844-
// Extract the secret name from the spec, if one is provided
822+
},
823+
tlsPodLevelInternalCACustomIssuer: func(rawObj client.Object) []string {
845824
cr := rawObj.(*corev1beta1.OpenStackControlPlane)
846825
if cr.Spec.TLS.PodLevel.Internal.Ca.CustomIssuer == nil {
847826
return nil
848827
}
849828
return []string{*cr.Spec.TLS.PodLevel.Internal.Ca.CustomIssuer}
850-
}); err != nil {
851-
return err
852-
}
853-
854-
// index tlsPodLevelOvnCACustomIssuer
855-
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, tlsPodLevelOvnCACustomIssuer, func(rawObj client.Object) []string {
856-
// Extract the secret name from the spec, if one is provided
829+
},
830+
tlsPodLevelOvnCACustomIssuer: func(rawObj client.Object) []string {
857831
cr := rawObj.(*corev1beta1.OpenStackControlPlane)
858832
if cr.Spec.TLS.PodLevel.Ovn.Ca.CustomIssuer == nil {
859833
return nil
860834
}
861835
return []string{*cr.Spec.TLS.PodLevel.Ovn.Ca.CustomIssuer}
862-
}); err != nil {
863-
return err
836+
},
837+
// the custom public endpoint cert secrets of all services
838+
apiOverrideTLSSecretField: indexAPIOverrideTLSSecret,
839+
}
840+
841+
// SetupWithManager sets up the controller with the Manager.
842+
func (r *OpenStackControlPlaneReconciler) SetupWithManager(
843+
ctx context.Context, mgr ctrl.Manager) error {
844+
for field, indexer := range fieldIndexers {
845+
if err := mgr.GetFieldIndexer().IndexField(ctx, &corev1beta1.OpenStackControlPlane{}, field, indexer); err != nil {
846+
return err
847+
}
864848
}
865849

866850
return ctrl.NewControllerManagedBy(mgr).
@@ -919,12 +903,57 @@ func (r *OpenStackControlPlaneReconciler) SetupWithManager(
919903
Complete(r)
920904
}
921905

906+
// indexAPIOverrideTLSSecret is the field index func for apiOverrideTLSSecretField
907+
func indexAPIOverrideTLSSecret(rawObj client.Object) []string {
908+
return apiOverrideTLSSecretNames(&rawObj.(*corev1beta1.OpenStackControlPlane).Spec)
909+
}
910+
911+
// apiOverrideTLSSecretNames returns the custom cert secret names set via
912+
// apiOverride.tls.secretName (or the service specific override variants) of
913+
// all services.
914+
func apiOverrideTLSSecretNames(spec *corev1beta1.OpenStackControlPlaneSpec) []string {
915+
overrides := []corev1beta1.Override{
916+
spec.Keystone.APIOverride,
917+
spec.Placement.APIOverride,
918+
spec.Cinder.APIOverride,
919+
spec.Neutron.APIOverride,
920+
spec.Nova.APIOverride,
921+
spec.Heat.APIOverride,
922+
spec.Heat.CnfAPIOverride,
923+
spec.Ironic.APIOverride,
924+
spec.Ironic.InspectorOverride,
925+
spec.Manila.APIOverride,
926+
spec.Horizon.APIOverride,
927+
spec.Telemetry.AodhAPIOverride,
928+
spec.Telemetry.CloudKittyAPIOverride,
929+
spec.Telemetry.PrometheusOverride,
930+
spec.Telemetry.AlertmanagerOverride,
931+
spec.Swift.ProxyOverride,
932+
spec.Octavia.APIOverride,
933+
spec.Designate.APIOverride,
934+
spec.Barbican.APIOverride,
935+
spec.Watcher.APIOverride,
936+
}
937+
overrides = slices.AppendSeq(overrides, maps.Values(spec.Glance.APIOverride))
938+
for _, c := range spec.Nova.CellOverride {
939+
overrides = append(overrides, c.NoVNCProxy)
940+
}
941+
942+
names := []string{}
943+
for _, o := range overrides {
944+
if o.TLS != nil && o.TLS.SecretName != "" {
945+
names = append(names, o.TLS.SecretName)
946+
}
947+
}
948+
return names
949+
}
950+
922951
func (r *OpenStackControlPlaneReconciler) findObjectsForSrc(ctx context.Context, src client.Object) []reconcile.Request {
923952
requests := []reconcile.Request{}
924953

925954
Log := r.GetLogger(ctx)
926955

927-
for _, field := range allWatchFields {
956+
for field := range fieldIndexers {
928957
crList := &corev1beta1.OpenStackControlPlaneList{}
929958
listOps := &client.ListOptions{
930959
FieldSelector: fields.OneTermEqualSelector(field, src.GetName()),
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"reflect"
7+
"slices"
8+
"testing"
9+
10+
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/runtime"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
15+
16+
corev1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/core/v1beta1"
17+
)
18+
19+
var overrideType = reflect.TypeFor[corev1beta1.Override]()
20+
21+
// containsOverride returns true if t is, or holds, a corev1beta1.Override.
22+
func containsOverride(t reflect.Type) bool {
23+
switch t.Kind() {
24+
case reflect.Struct:
25+
if t == overrideType {
26+
return true
27+
}
28+
if t.PkgPath() != overrideType.PkgPath() {
29+
return false
30+
}
31+
for f := range t.Fields() {
32+
if f.IsExported() && containsOverride(f.Type) {
33+
return true
34+
}
35+
}
36+
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
37+
return containsOverride(t.Elem())
38+
}
39+
return false
40+
}
41+
42+
// setOverrideSecrets walks v and sets a unique TLS secretName on every
43+
// corev1beta1.Override found, including map values, and collects the names.
44+
// It fails on shapes holding an Override that it can't handle, so a new shape
45+
// in the spec can't be silently skipped.
46+
func setOverrideSecrets(t *testing.T, v reflect.Value, names *[]string) {
47+
t.Helper()
48+
switch {
49+
case v.Type() == overrideType:
50+
name := fmt.Sprintf("secret-%d", len(*names))
51+
v.Set(reflect.ValueOf(corev1beta1.Override{TLS: &corev1beta1.TLSServiceOverride{SecretName: name}}))
52+
*names = append(*names, name)
53+
case v.Kind() == reflect.Struct && v.Type().PkgPath() == overrideType.PkgPath():
54+
for i := range v.NumField() {
55+
if v.Type().Field(i).IsExported() {
56+
setOverrideSecrets(t, v.Field(i), names)
57+
}
58+
}
59+
case v.Kind() == reflect.Map && v.Type().Elem().Kind() == reflect.Struct && containsOverride(v.Type().Elem()):
60+
v.Set(reflect.MakeMap(v.Type()))
61+
elem := reflect.New(v.Type().Elem()).Elem()
62+
setOverrideSecrets(t, elem, names)
63+
v.SetMapIndex(reflect.ValueOf("key").Convert(v.Type().Key()), elem)
64+
case containsOverride(v.Type()):
65+
t.Fatalf("unsupported shape %s holding an Override, extend setOverrideSecrets and apiOverrideTLSSecretNames", v.Type())
66+
}
67+
}
68+
69+
// TestAPIOverrideTLSSecretNames guards against Override fields being added to
70+
// the spec without being added to apiOverrideTLSSecretNames.
71+
func TestAPIOverrideTLSSecretNames(t *testing.T) {
72+
spec := &corev1beta1.OpenStackControlPlaneSpec{}
73+
if got := apiOverrideTLSSecretNames(spec); len(got) != 0 {
74+
t.Fatalf("expected no secrets for empty spec, got %v", got)
75+
}
76+
77+
var want []string
78+
setOverrideSecrets(t, reflect.ValueOf(spec).Elem(), &want)
79+
if len(want) == 0 {
80+
t.Fatal("found no Override fields in the spec")
81+
}
82+
got := apiOverrideTLSSecretNames(spec)
83+
slices.Sort(got)
84+
slices.Sort(want)
85+
if !slices.Equal(got, want) {
86+
t.Errorf("secret names not collected for all Override fields\nwant: %v\ngot: %v", want, got)
87+
}
88+
89+
// empty secretName and nil TLS are ignored
90+
spec = &corev1beta1.OpenStackControlPlaneSpec{}
91+
spec.Keystone.APIOverride.TLS = &corev1beta1.TLSServiceOverride{}
92+
if got := apiOverrideTLSSecretNames(spec); len(got) != 0 {
93+
t.Errorf("expected empty secretName to be ignored, got %v", got)
94+
}
95+
}
96+
97+
// TestFindObjectsForSrcAPIOverrideTLSSecret verifies a change of a custom
98+
// route cert secret enqueues the control plane referencing it.
99+
func TestFindObjectsForSrcAPIOverrideTLSSecret(t *testing.T) {
100+
scheme := runtime.NewScheme()
101+
if err := corev1beta1.AddToScheme(scheme); err != nil {
102+
t.Fatal(err)
103+
}
104+
105+
cp := &corev1beta1.OpenStackControlPlane{ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "ns"}}
106+
cp.Spec.Keystone.APIOverride.TLS = &corev1beta1.TLSServiceOverride{SecretName: "custom-cert"}
107+
108+
builder := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cp)
109+
for field, indexer := range fieldIndexers {
110+
builder = builder.WithIndex(&corev1beta1.OpenStackControlPlane{}, field, indexer)
111+
}
112+
r := &OpenStackControlPlaneReconciler{Client: builder.Build()}
113+
114+
secret := func(name, ns string) client.Object {
115+
return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}}
116+
}
117+
118+
if got := r.findObjectsForSrc(context.Background(), secret("custom-cert", "ns")); len(got) != 1 || got[0].Name != "cp" {
119+
t.Errorf("expected control plane to be enqueued, got %v", got)
120+
}
121+
if got := r.findObjectsForSrc(context.Background(), secret("other", "ns")); len(got) != 0 {
122+
t.Errorf("expected no requests for unrelated secret, got %v", got)
123+
}
124+
if got := r.findObjectsForSrc(context.Background(), secret("custom-cert", "other-ns")); len(got) != 0 {
125+
t.Errorf("expected no requests for secret in another namespace, got %v", got)
126+
}
127+
}

‎test/functional/ctlplane/openstackoperator_controller_test.go‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,6 +1592,69 @@ var _ = Describe("OpenStackOperator controller", func() {
15921592

15931593
})
15941594

1595+
// End-to-end smoke test. It does not isolate the secret watch, a
1596+
// periodic requeue also picks up the change; the registered watch is
1597+
// asserted by the "should enqueue the control plane" test below.
1598+
It("should update the route when the custom cert secret content changes", func() {
1599+
certSecret := CreateCertSecret(names.CustomServiceCertSecretName)
1600+
DeferCleanup(k8sClient.Delete, ctx, certSecret)
1601+
1602+
keystoneRouteName := types.NamespacedName{Name: "keystone-public", Namespace: namespace}
1603+
1604+
Eventually(func(g Gomega) {
1605+
g.Expect(keystone.GetKeystoneAPI(names.KeystoneAPIName)).Should(Not(BeNil()))
1606+
}, timeout, interval).Should(Succeed())
1607+
keystone.SimulateKeystoneAPIReady(names.KeystoneAPIName)
1608+
1609+
Eventually(func(g Gomega) {
1610+
route := &routev1.Route{}
1611+
g.Expect(th.K8sClient.Get(th.Ctx, keystoneRouteName, route)).Should(Succeed())
1612+
g.Expect(route.Spec.TLS).Should(Not(BeNil()))
1613+
g.Expect(route.Spec.TLS.Certificate).Should(Equal(string(certSecret.Data["tls.crt"])))
1614+
}, timeout, interval).Should(Succeed())
1615+
1616+
// update the secret content, name stays the same
1617+
Eventually(func(g Gomega) {
1618+
s := &k8s_corev1.Secret{}
1619+
g.Expect(th.K8sClient.Get(th.Ctx, names.CustomServiceCertSecretName, s)).Should(Succeed())
1620+
s.Data["tls.crt"] = []byte("updated-cert")
1621+
g.Expect(th.K8sClient.Update(th.Ctx, s)).Should(Succeed())
1622+
}, timeout, interval).Should(Succeed())
1623+
1624+
Eventually(func(g Gomega) {
1625+
route := &routev1.Route{}
1626+
g.Expect(th.K8sClient.Get(th.Ctx, keystoneRouteName, route)).Should(Succeed())
1627+
g.Expect(route.Spec.TLS).Should(Not(BeNil()))
1628+
g.Expect(route.Spec.TLS.Certificate).Should(Equal("updated-cert"))
1629+
}, timeout, interval).Should(Succeed())
1630+
})
1631+
1632+
// Asserts the Secret watch registered by SetupWithManager enqueues the
1633+
// control plane when the custom cert secret changes. A second manager
1634+
// runs the real controller setup with a recording no-op reconcile, so
1635+
// after the control plane events settled only the watch can enqueue.
1636+
It("should enqueue the control plane when the custom cert secret content changes", func() {
1637+
DeferCleanup(k8sClient.Delete, ctx, CreateCertSecret(names.CustomServiceCertSecretName))
1638+
rec := startReconcileRecorder()
1639+
1640+
// the control plane is enqueued initially, wait for its events to settle
1641+
Eventually(func(g Gomega) {
1642+
count, last := rec.stats()
1643+
g.Expect(count).Should(BeNumerically(">", 0))
1644+
g.Expect(time.Since(last)).Should(BeNumerically(">", 3*time.Second))
1645+
}, timeout, interval).Should(Succeed())
1646+
before, _ := rec.stats()
1647+
1648+
s := &k8s_corev1.Secret{}
1649+
Expect(th.K8sClient.Get(th.Ctx, names.CustomServiceCertSecretName, s)).Should(Succeed())
1650+
s.Data["tls.crt"] = []byte("updated-cert")
1651+
Expect(th.K8sClient.Update(th.Ctx, s)).Should(Succeed())
1652+
1653+
Eventually(func(g Gomega) {
1654+
count, _ := rec.stats()
1655+
g.Expect(count).Should(BeNumerically(">", before))
1656+
}, 2*time.Second, interval).Should(Succeed())
1657+
})
15951658
})
15961659
})
15971660

0 commit comments

Comments
 (0)