Skip to content

Commit d2b2b58

Browse files
committed
test: add ResourceGroup assertions to WatchForSync
This adds validation for the ResourceGroup status in the WatchForSync function, which is used extensively in the test framework to validate RSyncs and wait for them to finish syncing. This will cause WatchForSync to now also wait for all synced objects to reconcile and for the ResourceGroup to be current. This is intended to provide test coverage for the resource-group-controller when integrated with Config Sync.
1 parent fd371c7 commit d2b2b58

12 files changed

+176
-63
lines changed

e2e/nomostest/sync.go

+57
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import (
2121
"kpt.dev/configsync/e2e/nomostest/testpredicates"
2222
"kpt.dev/configsync/pkg/api/configsync"
2323
"kpt.dev/configsync/pkg/api/configsync/v1beta1"
24+
"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
2425
"kpt.dev/configsync/pkg/parse"
2526
"kpt.dev/configsync/pkg/reposync"
27+
"kpt.dev/configsync/pkg/resourcegroup"
2628
"kpt.dev/configsync/pkg/rootsync"
2729
"kpt.dev/configsync/pkg/util/log"
2830
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -263,3 +265,58 @@ func statusHasSyncDirAndNoErrors(status v1beta1.Status, sourceType configsync.So
263265
}
264266
return nil
265267
}
268+
269+
func resourceGroupHasReconciled(sourceHash string) testpredicates.Predicate {
270+
return func(o client.Object) error {
271+
if o == nil {
272+
return testpredicates.ErrObjectNotFound
273+
}
274+
rg, ok := o.(*v1alpha1.ResourceGroup)
275+
if !ok {
276+
return testpredicates.WrongTypeErr(o, &v1alpha1.ResourceGroup{})
277+
}
278+
279+
// Status is disabled for this ResourceGroup, skip all status checks
280+
if resourcegroup.IsStatusDisabled(rg) {
281+
return testpredicates.ResourceGroupHasNoStatus()(o)
282+
}
283+
284+
if len(rg.Spec.Resources) != len(rg.Status.ResourceStatuses) {
285+
return fmt.Errorf("length of spec.resources (%d) does not equal length of status.resourceStatuses (%d)",
286+
len(rg.Spec.Resources), len(rg.Status.ResourceStatuses))
287+
}
288+
289+
resourceCount := make(map[v1alpha1.ObjMetadata]int)
290+
for _, s := range rg.Status.ResourceStatuses {
291+
if err := resourceStatusIsCurrent(s, sourceHash); err != nil {
292+
return fmt.Errorf("object %s is not current: %w", s.ObjMetadata, err)
293+
}
294+
resourceCount[s.ObjMetadata]++
295+
}
296+
297+
for _, o := range rg.Spec.Resources {
298+
if count, ok := resourceCount[o]; ok && count > 0 {
299+
resourceCount[o]--
300+
} else {
301+
return fmt.Errorf("spec.resources does not equal status.resourceStatuses")
302+
}
303+
}
304+
return nil
305+
}
306+
}
307+
308+
func resourceStatusIsCurrent(rs v1alpha1.ResourceStatus, sourceHash string) error {
309+
if rs.Status != v1alpha1.Current {
310+
return fmt.Errorf("resourceStatus.status is not %s. Got %s", v1alpha1.Current, rs.Status)
311+
}
312+
if rs.Actuation != v1alpha1.ActuationSucceeded {
313+
return fmt.Errorf("resourceStatus.actuation is not %s. Got %s", v1alpha1.ActuationSucceeded, rs.Actuation)
314+
}
315+
if rs.Reconcile != v1alpha1.ReconcileSucceeded {
316+
return fmt.Errorf("resourceStatus.reconcile is not %s. Got %s", v1alpha1.ReconcileSucceeded, rs.Reconcile)
317+
}
318+
if rs.SourceHash != resourcegroup.TruncateSourceHash(sourceHash) {
319+
return fmt.Errorf("resourceStatus.sourceHash is not %s. Got %s", sourceHash, rs.SourceHash)
320+
}
321+
return nil
322+
}

e2e/nomostest/testpredicates/predicates.go

+19
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,25 @@ func containerArgsContains(container *corev1.Container, expectedArg string) erro
13191319
return fmt.Errorf("expected arg not found: %s", expectedArg)
13201320
}
13211321

1322+
// ResourceGroupHasNoStatus verifies the status of the ResourceGroup is empty.
1323+
func ResourceGroupHasNoStatus() Predicate {
1324+
return func(obj client.Object) error {
1325+
if obj == nil {
1326+
return ErrObjectNotFound
1327+
}
1328+
rg, ok := obj.(*v1alpha1.ResourceGroup)
1329+
if !ok {
1330+
return WrongTypeErr(obj, &v1alpha1.ResourceGroup{})
1331+
}
1332+
emptyStatus := v1alpha1.ResourceGroupStatus{}
1333+
if !equality.Semantic.DeepEqual(emptyStatus, rg.Status) {
1334+
return fmt.Errorf("found non-empty status in %s:\nDiff (- Expected, + Found)\n%s",
1335+
kinds.ObjectSummary(rg), log.AsYAMLDiff(emptyStatus, rg.Status))
1336+
}
1337+
return nil
1338+
}
1339+
}
1340+
13221341
// ResourceGroupStatusEquals checks that the RootSync's spec.override matches
13231342
// the specified RootSyncOverrideSpec.
13241343
func ResourceGroupStatusEquals(expected v1alpha1.ResourceGroupStatus) Predicate {

e2e/nomostest/testresourcegroup/resourcegroup.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import (
2626
"kpt.dev/configsync/pkg/api/configmanagement"
2727
"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
2828
"kpt.dev/configsync/pkg/metadata"
29+
resourcegroup2 "kpt.dev/configsync/pkg/resourcegroup"
2930
"kpt.dev/configsync/pkg/resourcegroup/controllers/resourcegroup"
30-
"kpt.dev/configsync/pkg/resourcegroup/controllers/status"
3131
"sigs.k8s.io/cli-utils/pkg/common"
3232
"sigs.k8s.io/controller-runtime/pkg/client"
3333
)
@@ -112,8 +112,8 @@ func CreateOrUpdateResources(kubeClient *testkubeclient.KubeClient, resources []
112112
Kind: r.Kind,
113113
})
114114
u.SetAnnotations(map[string]string{
115-
"config.k8s.io/owning-inventory": id,
116-
status.SourceHashAnnotationKey: "1234567890",
115+
"config.k8s.io/owning-inventory": id,
116+
resourcegroup2.SourceHashAnnotationKey: "1234567890",
117117
})
118118

119119
err := kubeClient.Get(r.Name, r.Namespace, u.DeepCopy())

e2e/nomostest/wait_for_sync.go

+42-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strings"
2020
"time"
2121

22+
corev1 "k8s.io/api/core/v1"
2223
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2324
"k8s.io/apimachinery/pkg/runtime/schema"
2425
"k8s.io/apimachinery/pkg/types"
@@ -29,6 +30,7 @@ import (
2930
"kpt.dev/configsync/e2e/nomostest/testwatcher"
3031
"kpt.dev/configsync/pkg/api/configsync"
3132
"kpt.dev/configsync/pkg/api/configsync/v1beta1"
33+
"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
3234
"kpt.dev/configsync/pkg/core/k8sobjects"
3335
"kpt.dev/configsync/pkg/kinds"
3436
"kpt.dev/configsync/pkg/reposync"
@@ -100,6 +102,14 @@ func SkipReadyCheck() WatchForAllSyncsOptions {
100102
}
101103
}
102104

105+
// SkipAllResourceGroupChecks is an optional parameter which specifies to skip
106+
// ResourceGroup checks for all RSyncs after they finish syncing.
107+
func SkipAllResourceGroupChecks() WatchForAllSyncsOptions {
108+
return func(options *watchForAllSyncsOptions) {
109+
options.watchForSyncOpts = append(options.watchForSyncOpts, SkipResourceGroupCheck())
110+
}
111+
}
112+
103113
// WatchForAllSyncs calls WatchForSync on all Syncs in nt.SyncSources.
104114
//
105115
// If you want to validate specific fields of a Sync object, use
@@ -159,7 +169,8 @@ func (nt *NT) WatchForAllSyncs(options ...WatchForAllSyncsOptions) error {
159169
}
160170

161171
type watchForSyncOptions struct {
162-
watchOptions []testwatcher.WatchOption
172+
watchOptions []testwatcher.WatchOption
173+
skipResourceGroupCheck bool
163174
}
164175

165176
// WatchForSyncOption is an optional parameter for WatchForSync.
@@ -173,6 +184,14 @@ func WithWatchOptions(watchOpts ...testwatcher.WatchOption) WatchForSyncOption {
173184
}
174185
}
175186

187+
// SkipResourceGroupCheck is an optional parameter to skip the ResourceGroup
188+
// watch after the RSync finishes syncing.
189+
func SkipResourceGroupCheck() WatchForSyncOption {
190+
return func(options *watchForSyncOptions) {
191+
options.skipResourceGroupCheck = true
192+
}
193+
}
194+
176195
// WatchForSync watches the specified sync object until it's synced.
177196
//
178197
// - gvk (required) is the sync object GroupVersionKind
@@ -188,7 +207,8 @@ func (nt *NT) WatchForSync(
188207
) error {
189208
nt.T.Helper()
190209
opts := watchForSyncOptions{
191-
watchOptions: []testwatcher.WatchOption{},
210+
skipResourceGroupCheck: false,
211+
watchOptions: []testwatcher.WatchOption{},
192212
}
193213
// Override defaults with specified options
194214
for _, option := range options {
@@ -239,6 +259,26 @@ func (nt *NT) WatchForSync(
239259
return fmt.Errorf("waiting for sync: %w", err)
240260
}
241261
nt.T.Logf("%s %s/%s is synced", gvk.Kind, namespace, name)
262+
if opts.skipResourceGroupCheck {
263+
return nil
264+
}
265+
rgWatchOptions := []testwatcher.WatchOption{
266+
testwatcher.WatchPredicates([]testpredicates.Predicate{
267+
// Wait until status.observedGeneration matches metadata.generation
268+
testpredicates.HasObservedLatestGeneration(nt.Scheme),
269+
// Wait until metadata.deletionTimestamp is missing, and conditions do not include Reconciling=True or Stalled=True
270+
testpredicates.StatusEquals(nt.Scheme, kstatus.CurrentStatus),
271+
// Make sure the ResourceGroup has the Stalled condition and it is "False"
272+
// This ensures the condition exists and wasn't removed by the applier
273+
testpredicates.HasConditionStatus(nt.Scheme, string(v1alpha1.Stalled), corev1.ConditionFalse),
274+
// Wait until all resourceStatuses are consistent with spec and current/reconciled
275+
resourceGroupHasReconciled(expectedCommit),
276+
}...),
277+
}
278+
rgWatchOptions = append(rgWatchOptions, opts.watchOptions...)
279+
if err := nt.Watcher.WatchObject(kinds.ResourceGroup(), name, namespace, rgWatchOptions...); err != nil {
280+
return fmt.Errorf("waiting for ResourceGroup: %w", err)
281+
}
242282
return nil
243283
}
244284

e2e/testcases/basic_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func TestSyncDeploymentAndReplicaSet(t *testing.T) {
111111
nt.T.Log("Add a corresponding deployment")
112112
nt.Must(rootSyncGitRepo.Copy(fmt.Sprintf("%s/deployment-helloworld.yaml", yamlDir), "acme/namespaces/dir/deployment.yaml"))
113113
nt.Must(rootSyncGitRepo.CommitAndPush("Add corresponding deployment"))
114-
nt.Must(nt.WatchForAllSyncs())
114+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
115115

116116
nt.T.Log("check that the deployment was created")
117117
if err := nt.Validate("hello-world", "dir", &appsv1.Deployment{}); err != nil {

e2e/testcases/multi_sync_test.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,9 @@ func TestConflictingDefinitions_NamespaceToRoot(t *testing.T) {
486486
nt.T.Logf("Remove the Role from the Namespace repo %s", repoSyncKey)
487487
nt.Must(repoSyncGitRepo.Remove(podRoleFilePath))
488488
nt.Must(repoSyncGitRepo.CommitAndPush("remove conflicting pod role from Namespace repo"))
489-
nt.Must(nt.WatchForAllSyncs())
489+
// rs-test tries to delete the Role, but since it is also being managed by root-sync
490+
// it may Timeout waiting for the deletion to reconcile.
491+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
490492

491493
nt.T.Logf("Ensure the Role still matches the one in the Root repo %s", rootSyncKey.Name)
492494
err = nt.Validate("pods", testNs, &rbacv1.Role{},
@@ -620,7 +622,9 @@ func TestConflictingDefinitions_RootToRoot(t *testing.T) {
620622
nt.T.Logf("Remove the declaration from RootSync %s", rootSyncID.Name)
621623
nt.Must(rootSyncGitRepo.Remove(podRoleFilePath))
622624
nt.Must(rootSyncGitRepo.CommitAndPush("remove conflicting pod role"))
623-
nt.Must(nt.WatchForAllSyncs())
625+
// root-sync tries to delete the Role, but since it is also being managed by root-test
626+
// it may Timeout waiting for the deletion to reconcile.
627+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
624628

625629
nt.T.Logf("Ensure the Role is managed by RootSync %s", rootSync2ID.Name)
626630
// The pod role may be deleted from the cluster after it was removed from the `root-sync` Root repo.

e2e/testcases/override_reconcile_timeout_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func TestOverrideReconcileTimeout(t *testing.T) {
9696
nt.Must(rootSyncGitRepo.Add("acme/pod-1.yaml", pod1))
9797
nt.Must(rootSyncGitRepo.Add("acme/ns-1.yaml", k8sobjects.NamespaceObject(namespaceName)))
9898
nt.Must(rootSyncGitRepo.CommitAndPush(fmt.Sprintf("Add namespace/%s & pod/%s (never ready)", namespaceName, pod1Name)))
99-
nt.Must(nt.WatchForAllSyncs())
99+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
100100
expectActuationStatus := "Succeeded"
101101
expectReconcileStatus := "Timeout"
102102
nt.Must(nt.Watcher.WatchObject(kinds.ResourceGroup(), "root-sync", "config-management-system",

e2e/testcases/status_enablement_test.go

+3-22
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package e2e
1616

1717
import (
1818
"fmt"
19-
"reflect"
2019
"testing"
2120

2221
"kpt.dev/configsync/e2e/nomostest"
@@ -46,18 +45,18 @@ func TestStatusEnabledAndDisabled(t *testing.T) {
4645
rootSync := k8sobjects.RootSyncObjectV1Alpha1(configsync.RootSyncName)
4746
// Override the statusMode for root-reconciler
4847
nt.MustMergePatch(rootSync, `{"spec": {"override": {"statusMode": "disabled"}}}`)
49-
nt.Must(nt.WatchForAllSyncs())
48+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
5049

5150
namespaceName := "status-test"
5251
nt.Must(rootSyncGitRepo.Add("acme/ns.yaml", namespaceObject(namespaceName, nil)))
5352
nt.Must(rootSyncGitRepo.Add("acme/cm1.yaml", k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(namespaceName))))
5453
nt.Must(rootSyncGitRepo.CommitAndPush("Add a namespace and a configmap"))
55-
nt.Must(nt.WatchForAllSyncs())
54+
nt.Must(nt.WatchForAllSyncs(nomostest.SkipAllResourceGroupChecks()))
5655

5756
nt.Must(nt.Watcher.WatchObject(kinds.ResourceGroup(),
5857
configsync.RootSyncName, configsync.ControllerNamespace,
5958
testwatcher.WatchPredicates(
60-
resourceGroupHasNoStatus,
59+
testpredicates.ResourceGroupHasNoStatus(),
6160
testpredicates.HasLabel(common.InventoryLabel, id),
6261
),
6362
testwatcher.WatchTimeout(nt.DefaultWaitTimeout)))
@@ -75,24 +74,6 @@ func TestStatusEnabledAndDisabled(t *testing.T) {
7574
testwatcher.WatchTimeout(nt.DefaultWaitTimeout)))
7675
}
7776

78-
func resourceGroupHasNoStatus(obj client.Object) error {
79-
if obj == nil {
80-
return testpredicates.ErrObjectNotFound
81-
}
82-
rg, ok := obj.(*resourcegroupv1alpha1.ResourceGroup)
83-
if !ok {
84-
return testpredicates.WrongTypeErr(obj, &resourcegroupv1alpha1.ResourceGroup{})
85-
}
86-
// We can't check that the status field is missing, because the
87-
// ResourceGroup object doesn't use a pointer for status.
88-
// But we can check that the status is empty, which is what we really care
89-
// about, to reduce the size of the object in etcd.
90-
if !reflect.ValueOf(rg.Status).IsZero() {
91-
return fmt.Errorf("found non-empty status in %s", core.IDOf(obj))
92-
}
93-
return nil
94-
}
95-
9677
func resourceGroupHasStatus(obj client.Object) error {
9778
if obj == nil {
9879
return testpredicates.ErrObjectNotFound

pkg/resourcegroup/controllers/root/root_controller.go

+5-15
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ import (
2828
"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
2929
"kpt.dev/configsync/pkg/metadata"
3030
"kpt.dev/configsync/pkg/reconcilermanager/controllers"
31+
"kpt.dev/configsync/pkg/resourcegroup"
3132
"kpt.dev/configsync/pkg/resourcegroup/controllers/handler"
32-
"kpt.dev/configsync/pkg/resourcegroup/controllers/resourcegroup"
33+
resourcegroupcontroller "kpt.dev/configsync/pkg/resourcegroup/controllers/resourcegroup"
3334
"kpt.dev/configsync/pkg/resourcegroup/controllers/resourcemap"
3435
"kpt.dev/configsync/pkg/resourcegroup/controllers/typeresolver"
3536
"kpt.dev/configsync/pkg/resourcegroup/controllers/watch"
@@ -42,9 +43,7 @@ import (
4243

4344
//nolint:revive // TODO: add comments for public constants and enable linting
4445
const (
45-
KptGroup = "kpt"
46-
DisableStatusKey = "configsync.gke.io/status"
47-
DisableStatusValue = "disabled"
46+
KptGroup = "kpt"
4847
)
4948

5049
// Reconciler reconciles a ResourceGroup object
@@ -100,7 +99,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
10099

101100
// Skip ResourceGroup status updates if the status is disabled and has
102101
// already been removed.
103-
if isStatusDisabled(resgroup) {
102+
if resourcegroup.IsStatusDisabled(resgroup) {
104103
r.Logger(ctx).V(3).Info("Skipping update event: ResourceGroup status disabled")
105104
return r.reconcileDisabledResourceGroup(ctx, req, resgroup)
106105
}
@@ -155,23 +154,14 @@ func (r *Reconciler) reconcileDisabledResourceGroup(ctx context.Context, req ctr
155154
}
156155
resgroup.Status = emptyStatus
157156
// Use `r.Status().Update()` here instead of `r.Update()` to update only resgroup.Status.
158-
err := r.client.Status().Update(ctx, resgroup, client.FieldOwner(resourcegroup.FieldManager))
157+
err := r.client.Status().Update(ctx, resgroup, client.FieldOwner(resourcegroupcontroller.FieldManager))
159158
if err != nil {
160159
return ctrl.Result{}, err
161160
}
162161
// update the resMap
163162
return r.reconcile(ctx, req.NamespacedName, []v1alpha1.ObjMetadata{}, true)
164163
}
165164

166-
func isStatusDisabled(resgroup *v1alpha1.ResourceGroup) bool {
167-
annotations := resgroup.GetAnnotations()
168-
if annotations == nil {
169-
return false
170-
}
171-
val, found := annotations[DisableStatusKey]
172-
return found && val == DisableStatusValue
173-
}
174-
175165
// NewController creates a new Reconciler and registers it with the provided manager
176166
func NewController(mgr manager.Manager, channel chan event.GenericEvent,
177167
logger logr.Logger, resolver *typeresolver.TypeResolver, group string, resMap *resourcemap.ResourceMap) error {

0 commit comments

Comments
 (0)