Skip to content

Commit 49bd251

Browse files
committed
add unit tests for the mcp controller (part 3)
1 parent 6838eb8 commit 49bd251

File tree

14 files changed

+146
-31
lines changed

14 files changed

+146
-31
lines changed

api/clusters/v1alpha1/cluster_types.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ func (c *Cluster) GetTenancyCount() int {
130130
func (c *Cluster) GetRequestUIDs() sets.Set[string] {
131131
res := sets.New[string]()
132132
for _, fin := range c.Finalizers {
133-
if strings.HasPrefix(fin, RequestFinalizerOnClusterPrefix) {
134-
res.Insert(strings.TrimPrefix(fin, RequestFinalizerOnClusterPrefix))
133+
if uid, ok := strings.CutPrefix(fin, RequestFinalizerOnClusterPrefix); ok {
134+
res.Insert(uid)
135135
}
136136
}
137137
return res

api/clusters/v1alpha1/constants/reasons.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ const (
1313
ReasonInternalError = "InternalError"
1414
// ReasonWaitingForClusterRequest indicates that something is waiting for a ClusterRequest to become ready.
1515
ReasonWaitingForClusterRequest = "WaitingForClusterRequest"
16+
// ReasonWaitingForClusterRequestDeletion indicates that something is waiting for a ClusterRequest to be deleted.
17+
ReasonWaitingForClusterRequestDeletion = "WaitingForClusterRequestDeletion"
1618
// ReasonWaitingForAccessRequest indicates that something is waiting for an AccessRequest to become ready.
1719
ReasonWaitingForAccessRequest = "WaitingForAccessRequest"
20+
// ReasonWaitingForAccessRequestDeletion indicates that something is waiting for an AccessRequest to be deleted.
21+
ReasonWaitingForAccessRequestDeletion = "WaitingForAccessRequestDeletion"
1822
// ReasonWaitingForServices indicates that something is waiting for one or more service providers to do something.
1923
ReasonWaitingForServices = "WaitingForServices"
24+
// ReasonWaitingForServiceDeletion indicates that something is waiting for a service to be deleted.
25+
ReasonWaitingForServiceDeletion = "WaitingForServiceDeletion"
2026
)

api/core/v2alpha1/constants.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const (
2121
ConditionMeta = "Meta"
2222

2323
ConditionClusterRequestReady = "ClusterRequestReady"
24-
ConditionPrefixOIDCAccessReady = "OIDCAccessReady_"
24+
ConditionPrefixOIDCAccessReady = "OIDCAccessReady:"
2525
ConditionAllAccessReady = "AllAccessReady"
2626
ConditionAllServicesDeleted = "AllServicesDeleted"
2727
ConditionAllClusterRequestsDeleted = "AllClusterRequestsDeleted"

internal/controllers/managedcontrolplane/access.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ func (r *ManagedControlPlaneReconciler) manageAccessRequests(ctx context.Context
6969
if everythingReady {
7070
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionTrue, "", "All accesses are ready")
7171
} else {
72-
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "Not all accesses are ready")
72+
reason := cconst.ReasonWaitingForAccessRequest
73+
if allAccessReady {
74+
reason = cconst.ReasonWaitingForAccessRequestDeletion
75+
}
76+
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, reason, "Not all accesses are ready")
7377
}
7478

7579
return everythingReady, removeConditions, nil
@@ -163,7 +167,7 @@ func (r *ManagedControlPlaneReconciler) deleteUndesiredAccessRequests(ctx contex
163167
accessRequestsInDeletion.Insert(providerName)
164168
if !ar.DeletionTimestamp.IsZero() {
165169
log.Debug("Waiting for deletion of AccessRequest that is no longer required", "accessRequestName", ar.Name, "accessRequestNamespace", ar.Namespace, "oidcProviderName", providerName)
166-
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "AccessRequest is being deleted")
170+
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "AccessRequest is being deleted")
167171
continue
168172
}
169173
log.Debug("Deleting AccessRequest that is no longer needed", "accessRequestName", ar.Name, "accessRequestNamespace", ar.Namespace, "oidcProviderName", providerName)
@@ -172,10 +176,10 @@ func (r *ManagedControlPlaneReconciler) deleteUndesiredAccessRequests(ctx contex
172176
errs.Append(rerr)
173177
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, rerr.Reason(), rerr.Error())
174178
}
175-
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "AccessRequest is being deleted")
179+
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "AccessRequest is being deleted")
176180
}
177181
if rerr := errs.Aggregate(); rerr != nil {
178-
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "Error deleting AccessRequests that are no longer needed")
182+
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "Error deleting AccessRequests that are no longer needed")
179183
return accessRequestsInDeletion, rerr
180184
}
181185

@@ -214,7 +218,7 @@ func (r *ManagedControlPlaneReconciler) deleteUndesiredAccessSecrets(ctx context
214218
accessSecretsInDeletion.Insert(providerName)
215219
if !mcpSecret.DeletionTimestamp.IsZero() {
216220
log.Debug("Waiting for deletion of access secret that is no longer required", "secretName", mcpSecret.Name, "secretNamespace", mcpSecret.Namespace, "oidcProviderName", providerName)
217-
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "AccessRequest secret is being deleted")
221+
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "AccessRequest secret is being deleted")
218222
continue
219223
}
220224
log.Debug("Deleting access secret that is no longer required", "secretName", mcpSecret.Name, "secretNamespace", mcpSecret.Namespace, "oidcProviderName", providerName)
@@ -223,10 +227,10 @@ func (r *ManagedControlPlaneReconciler) deleteUndesiredAccessSecrets(ctx context
223227
errs.Append(rerr)
224228
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, rerr.Reason(), rerr.Error())
225229
}
226-
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "access secret is being deleted")
230+
createCon(corev2alpha1.ConditionPrefixOIDCAccessReady+providerName, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "access secret is being deleted")
227231
}
228232
if rerr := errs.Aggregate(); rerr != nil {
229-
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "Error deleting access secrets that are no longer needed")
233+
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "Error deleting access secrets that are no longer needed")
230234
return accessSecretsInDeletion, rerr
231235
}
232236

internal/controllers/managedcontrolplane/clusters.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ func (r *ManagedControlPlaneReconciler) deleteRelatedClusterRequests(ctx context
3131

3232
// identify cluster request finalizers
3333
for _, fin := range mcp.Finalizers {
34-
if strings.HasPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix) {
35-
crNames.Insert(strings.TrimPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix))
34+
if crName, ok := strings.CutPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix); ok {
35+
crNames.Insert(crName)
3636
}
3737
}
3838

internal/controllers/managedcontrolplane/controller.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,6 @@ func (r *ManagedControlPlaneReconciler) Reconcile(ctx context.Context, req recon
7373
log.Info("Starting reconcile")
7474
rr := r.reconcile(ctx, req)
7575

76-
if rr.Result.IsZero() && r.Config.ReconcileMCPEveryXDays > 0 {
77-
// requeue the MCP for periodic reconciliation
78-
rr.Result.RequeueAfter = time.Duration(r.Config.ReconcileMCPEveryXDays) * 24 * time.Hour
79-
}
80-
8176
// status update
8277
return ctrlutils.NewOpenMCPStatusUpdaterBuilder[*corev2alpha1.ManagedControlPlane]().
8378
WithNestedStruct("Status").
@@ -278,7 +273,7 @@ func (r *ManagedControlPlaneReconciler) handleDelete(ctx context.Context, mcp *c
278273
msg.WriteString(fmt.Sprintf("[%s]%s.%s, ", providerName, res.GetKind(), res.GetAPIVersion()))
279274
}
280275
}
281-
createCon(corev2alpha1.ConditionAllServicesDeleted, metav1.ConditionFalse, cconst.ReasonWaitingForServices, strings.TrimSuffix(msg.String(), ", "))
276+
createCon(corev2alpha1.ConditionAllServicesDeleted, metav1.ConditionFalse, cconst.ReasonWaitingForServiceDeletion, strings.TrimSuffix(msg.String(), ", "))
282277
rr.SmartRequeue = ctrlutils.SR_BACKOFF
283278
return rr
284279
}
@@ -295,7 +290,7 @@ func (r *ManagedControlPlaneReconciler) handleDelete(ctx context.Context, mcp *c
295290
}
296291
if !accessReady {
297292
log.Info("Waiting for AccessRequests to be deleted")
298-
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequest, "Waiting for AccessRequests to be deleted")
293+
createCon(corev2alpha1.ConditionAllAccessReady, metav1.ConditionFalse, cconst.ReasonWaitingForAccessRequestDeletion, "Waiting for AccessRequests to be deleted")
299294
rr.SmartRequeue = ctrlutils.SR_BACKOFF
300295
return rr
301296
}
@@ -314,7 +309,8 @@ func (r *ManagedControlPlaneReconciler) handleDelete(ctx context.Context, mcp *c
314309
if !ok {
315310
return false
316311
}
317-
return strings.HasPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix) && !remainingCRs.Has(strings.TrimPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix))
312+
crName, ok := strings.CutPrefix(fin, corev2alpha1.ClusterRequestFinalizerPrefix)
313+
return ok && !remainingCRs.Has(crName)
318314
})...)
319315
if len(finalizersToRemove) > 0 {
320316
log.Debug("Removing ClusterRequest finalizers for deleted ClusterRequests from MCP", "finalizers", strings.Join(sets.List(finalizersToRemove), ", "))
@@ -336,7 +332,7 @@ func (r *ManagedControlPlaneReconciler) handleDelete(ctx context.Context, mcp *c
336332
if remainingCRs.Len() > 0 {
337333
tmp := strings.Join(sets.List(remainingCRs), ", ")
338334
log.Info("Waiting for ClusterRequests to be deleted", "remainingClusterRequests", tmp)
339-
createCon(corev2alpha1.ConditionAllClusterRequestsDeleted, metav1.ConditionFalse, cconst.ReasonWaitingForClusterRequest, fmt.Sprintf("Waiting for the following ClusterRequests to be deleted: %s", tmp))
335+
createCon(corev2alpha1.ConditionAllClusterRequestsDeleted, metav1.ConditionFalse, cconst.ReasonWaitingForClusterRequestDeletion, fmt.Sprintf("Waiting for the following ClusterRequests to be deleted: %s", tmp))
340336
rr.SmartRequeue = ctrlutils.SR_BACKOFF
341337
return rr
342338
}
@@ -353,6 +349,7 @@ func (r *ManagedControlPlaneReconciler) handleDelete(ctx context.Context, mcp *c
353349
}
354350
}
355351
createCon(corev2alpha1.ConditionMeta, metav1.ConditionTrue, "", "MCP finalizer removed")
352+
rr.Result.RequeueAfter = 0
356353
if len(mcp.Finalizers) == 0 {
357354
// if we just removed the last finalizer on the MCP
358355
// (which should usually be the case, unless something external added one)

internal/controllers/managedcontrolplane/controller_test.go

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package managedcontrolplane_test
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"time"
78

89
. "github.com/onsi/ginkgo/v2"
@@ -104,7 +105,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
104105
Expect(env.Client(onboarding).Get(env.Ctx, client.ObjectKeyFromObject(mcp), mcp)).To(Succeed())
105106
Expect(res.RequeueAfter).To(BeNumerically(">", 0))
106107
Expect(res.RequeueAfter).To(BeNumerically("<", 1*time.Minute))
107-
Expect(mcp.Finalizers).To(ConsistOf(
108+
Expect(mcp.Finalizers).To(ContainElements(
108109
corev2alpha1.MCPFinalizer,
109110
corev2alpha1.ClusterRequestFinalizerPrefix+mcp.Name,
110111
))
@@ -265,7 +266,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
265266
MatchCondition(TestCondition().
266267
WithType(corev2alpha1.ConditionAllAccessReady).
267268
WithStatus(metav1.ConditionFalse).
268-
WithReason(cconst.ReasonWaitingForAccessRequest)),
269+
WithReason(cconst.ReasonWaitingForAccessRequestDeletion)),
269270
))
270271
removedOIDCIdx := -1
271272
for i, oidc := range oidcProviders {
@@ -276,7 +277,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
276277
MatchCondition(TestCondition().
277278
WithType(corev2alpha1.ConditionPrefixOIDCAccessReady + oidc.Name).
278279
WithStatus(metav1.ConditionFalse).
279-
WithReason(cconst.ReasonWaitingForAccessRequest),
280+
WithReason(cconst.ReasonWaitingForAccessRequestDeletion),
280281
)))
281282
Expect(mcp.Status.Access).ToNot(HaveKey(oidc.Name))
282283
} else {
@@ -374,11 +375,53 @@ var _ = Describe("ManagedControlPlane Controller", func() {
374375
Expect(env.Client(onboarding).Get(env.Ctx, client.ObjectKeyFromObject(mcp), mcp)).To(Succeed())
375376
Expect(res.RequeueAfter).To(BeNumerically(">", 0))
376377
Expect(res.RequeueAfter).To(BeNumerically("<", 1*time.Minute))
377-
// TODO
378+
serviceResources := []client.Object{
379+
&corev1.ConfigMap{},
380+
&corev1.ServiceAccount{},
381+
&corev1.Secret{},
382+
}
383+
for _, obj := range serviceResources {
384+
obj.SetName(mcp.Name)
385+
obj.SetNamespace(mcp.Namespace)
386+
Expect(env.Client(onboarding).Get(env.Ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
387+
Expect(obj.GetDeletionTimestamp().IsZero()).To(BeFalse())
388+
}
389+
Expect(mcp.Status.Conditions).To(ContainElements(
390+
MatchCondition(TestCondition().
391+
WithType(corev2alpha1.ConditionAllServicesDeleted).
392+
WithStatus(metav1.ConditionFalse).
393+
WithReason(cconst.ReasonWaitingForServiceDeletion)),
394+
))
395+
for _, oidc := range oidcProviders {
396+
By("verifying AccessRequest does not have a deletion timestamp for oidc provider: " + oidc.Name)
397+
ar := &clustersv1alpha1.AccessRequest{}
398+
ar.SetName(ctrlutils.K8sNameHash(mcp.Name, oidc.Name))
399+
ar.SetNamespace(platformNamespace)
400+
Expect(env.Client(platform).Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)).To(Succeed())
401+
Expect(ar.DeletionTimestamp.IsZero()).To(BeTrue())
402+
}
403+
for _, obj := range []client.Object{cr, cr2, cr3} {
404+
By("verifying ClusterRequest does not have a deletion timestamp: " + obj.GetName())
405+
Expect(env.Client(platform).Get(env.Ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
406+
Expect(obj.GetDeletionTimestamp().IsZero()).To(BeTrue())
407+
}
378408

379409
// remove service finalizers
380410
By("fake: removing service finalizers")
381-
// TODO
411+
for _, obj := range serviceResources {
412+
By("fake: removing finalizer from service resource: " + obj.GetObjectKind().GroupVersionKind().Kind)
413+
Expect(env.Client(onboarding).Get(env.Ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
414+
controllerutil.RemoveFinalizer(obj, "dummy")
415+
Expect(env.Client(onboarding).Update(env.Ctx, obj)).To(Succeed())
416+
}
417+
newFins := []string{}
418+
for _, fin := range mcp.Finalizers {
419+
if !strings.HasPrefix(fin, corev2alpha1.ServiceDependencyFinalizerPrefix) {
420+
newFins = append(newFins, fin)
421+
}
422+
}
423+
mcp.Finalizers = newFins
424+
Expect(env.Client(onboarding).Update(env.Ctx, mcp)).To(Succeed())
382425

383426
// reconcile the MCP again
384427
// expected outcome:
@@ -399,7 +442,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
399442
MatchCondition(TestCondition().
400443
WithType(corev2alpha1.ConditionAllAccessReady).
401444
WithStatus(metav1.ConditionFalse).
402-
WithReason(cconst.ReasonWaitingForAccessRequest)),
445+
WithReason(cconst.ReasonWaitingForAccessRequestDeletion)),
403446
))
404447
for _, oidc := range oidcProviders {
405448
By("verifying AccessRequest and access secret deletion status for oidc provider: " + oidc.Name)
@@ -412,7 +455,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
412455
MatchCondition(TestCondition().
413456
WithType(corev2alpha1.ConditionPrefixOIDCAccessReady + oidc.Name).
414457
WithStatus(metav1.ConditionFalse).
415-
WithReason(cconst.ReasonWaitingForAccessRequest),
458+
WithReason(cconst.ReasonWaitingForAccessRequestDeletion),
416459
),
417460
))
418461
}
@@ -464,6 +507,12 @@ var _ = Describe("ManagedControlPlane Controller", func() {
464507
ar.SetNamespace(platformNamespace)
465508
Expect(env.Client(platform).Get(env.Ctx, client.ObjectKeyFromObject(ar), ar)).To(MatchError(apierrors.IsNotFound, "IsNotFound"))
466509
}
510+
Expect(mcp.Status.Conditions).To(ContainElements(
511+
MatchCondition(TestCondition().
512+
WithType(corev2alpha1.ConditionAllClusterRequestsDeleted).
513+
WithStatus(metav1.ConditionFalse).
514+
WithReason(cconst.ReasonWaitingForClusterRequestDeletion)),
515+
))
467516

468517
// remove finalizers from cr2 and cr3
469518
By("fake: removing finalizers from additional ClusterRequests")
@@ -486,6 +535,12 @@ var _ = Describe("ManagedControlPlane Controller", func() {
486535
By("verifying ClusterRequest deletion status")
487536
Expect(env.Client(platform).Get(env.Ctx, client.ObjectKeyFromObject(cr), cr)).To(Succeed())
488537
Expect(cr.GetDeletionTimestamp().IsZero()).To(BeFalse(), "ClusterRequest should be marked for deletion")
538+
Expect(mcp.Status.Conditions).To(ContainElements(
539+
MatchCondition(TestCondition().
540+
WithType(corev2alpha1.ConditionAllClusterRequestsDeleted).
541+
WithStatus(metav1.ConditionFalse).
542+
WithReason(cconst.ReasonWaitingForClusterRequestDeletion)),
543+
))
489544

490545
// remove finalizer from cr
491546
By("fake: removing finalizer from primary ClusterRequest")
@@ -500,7 +555,7 @@ var _ = Describe("ManagedControlPlane Controller", func() {
500555
By("fifth MCP reconciliation after delete")
501556
res = env.ShouldReconcile(mcpRec, testutils.RequestFromObject(mcp))
502557
Expect(env.Client(onboarding).Get(env.Ctx, client.ObjectKeyFromObject(mcp), mcp)).To(MatchError(apierrors.IsNotFound, "IsNotFound"))
503-
Expect(res.RequeueAfter).To(BeNumerically("~", int64(rec.Config.ReconcileMCPEveryXDays)*24*int64(time.Hour), int64(time.Second)))
558+
Expect(res.IsZero()).To(BeTrue())
504559
Expect(env.Client(platform).Get(env.Ctx, client.ObjectKeyFromObject(cr), cr)).To(MatchError(apierrors.IsNotFound, "IsNotFound"))
505560
})
506561

internal/controllers/managedcontrolplane/services.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ func (r *ManagedControlPlaneReconciler) deleteDependingServices(ctx context.Cont
3434

3535
// identify service finalizers
3636
for _, fin := range mcp.Finalizers {
37-
if strings.HasPrefix(fin, corev2alpha1.ServiceDependencyFinalizerPrefix) {
38-
serviceProviderNames.Insert(strings.TrimPrefix(fin, corev2alpha1.ServiceDependencyFinalizerPrefix))
37+
if service, ok := strings.CutPrefix(fin, corev2alpha1.ServiceDependencyFinalizerPrefix); ok {
38+
serviceProviderNames.Insert(service)
3939
}
4040
}
4141

internal/controllers/managedcontrolplane/testdata/test-01/onboarding/mcp-01.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ kind: ManagedControlPlane
33
metadata:
44
name: mcp-01
55
namespace: test
6+
finalizers:
7+
- services.openmcp.cloud/sp-01
8+
- services.openmcp.cloud/sp-02
69
spec:
710
iam:
811
roleBindings:
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# this is a fake service resource
2+
# we use standard k8s resources for testing purposes, so we don't have to add a custom resource definition or schema
3+
apiVersion: v1
4+
kind: ConfigMap
5+
metadata:
6+
name: mcp-01
7+
namespace: test
8+
finalizers:
9+
- dummy

0 commit comments

Comments
 (0)