@@ -1320,6 +1320,282 @@ func TestSyncerProcessingSingleResourceWithStatus(t *testing.T) {
13201320 })
13211321 }
13221322}
1323+ func TestSyncerProcessingRelatedResources (t * testing.T ) {
1324+ type testcase struct {
1325+ name string
1326+ remoteAPIGroup string
1327+ localCRD * apiextensionsv1.CustomResourceDefinition
1328+ pubRes * syncagentv1alpha1.PublishedResource
1329+ remoteObject * unstructured.Unstructured
1330+ localObject * unstructured.Unstructured
1331+ existingState string
1332+ performRequeues bool
1333+ expectedRemoteObject * unstructured.Unstructured
1334+ expectedLocalObject * unstructured.Unstructured
1335+ expectedState string
1336+ customVerification func (t * testing.T , requeue bool , processErr error , finalRemoteObject * unstructured.Unstructured , finalLocalObject * unstructured.Unstructured , testcase testcase )
1337+ }
1338+
1339+ clusterName := logicalcluster .Name ("testcluster" )
1340+
1341+ remoteThingPR := & syncagentv1alpha1.PublishedResource {
1342+ Spec : syncagentv1alpha1.PublishedResourceSpec {
1343+ Resource : syncagentv1alpha1.SourceResourceDescriptor {
1344+ APIGroup : dummyv1alpha1 .GroupName ,
1345+ Version : dummyv1alpha1 .GroupVersion ,
1346+ Kind : "Thing" ,
1347+ },
1348+ Projection : & syncagentv1alpha1.ResourceProjection {
1349+ Kind : "RemoteThing" ,
1350+ },
1351+ // include explicit naming rules to be independent of possible changes to the defaults
1352+ Naming : & syncagentv1alpha1.ResourceNaming {
1353+ Name : "$remoteClusterName-$remoteName" , // Things are Cluster-scoped
1354+ },
1355+ Related : []syncagentv1alpha1.RelatedResourceSpec {
1356+ {
1357+ Identifier : "mandatory-secret" ,
1358+ Origin : "service" ,
1359+ Kind : "Thing" ,
1360+ Reference : syncagentv1alpha1.RelatedResourceReference {
1361+ Name : syncagentv1alpha1.ResourceLocator {
1362+ Path : "spec.otherTest.name" ,
1363+ },
1364+ Namespace : & syncagentv1alpha1.ResourceLocator {
1365+ Path : "spec.otherTest.namespace" ,
1366+ },
1367+ },
1368+ Optional : false ,
1369+ },
1370+ {
1371+ Identifier : "optional-secret" ,
1372+ Origin : "kcp" ,
1373+ Kind : "Thing" ,
1374+ Reference : syncagentv1alpha1.RelatedResourceReference {
1375+ Name : syncagentv1alpha1.ResourceLocator {
1376+ Path : "spec.test.name" ,
1377+ },
1378+ Namespace : & syncagentv1alpha1.ResourceLocator {
1379+ Path : "spec.test.namespace" ,
1380+ },
1381+ },
1382+ Optional : true ,
1383+ },
1384+ },
1385+ },
1386+ }
1387+
1388+ testcases := []testcase {
1389+ {
1390+ name : "optional related resource does not exist" ,
1391+ remoteAPIGroup : "remote.example.corp" ,
1392+ localCRD : loadCRD ("things" ),
1393+ pubRes : remoteThingPR ,
1394+ performRequeues : true ,
1395+
1396+ remoteObject : newUnstructured (& dummyv1alpha1.Thing {
1397+ ObjectMeta : metav1.ObjectMeta {
1398+ Name : "my-test-thing" ,
1399+ },
1400+ Spec : dummyv1alpha1.ThingSpec {
1401+ Username : "Colonel Mustard" ,
1402+ },
1403+ }, withGroupKind ("remote.example.corp" , "RemoteThing" )),
1404+ localObject : nil ,
1405+ existingState : "" ,
1406+
1407+ expectedRemoteObject : newUnstructured (& dummyv1alpha1.Thing {
1408+ ObjectMeta : metav1.ObjectMeta {
1409+ Name : "my-test-thing" ,
1410+ Finalizers : []string {
1411+ deletionFinalizer ,
1412+ },
1413+ },
1414+ Spec : dummyv1alpha1.ThingSpec {
1415+ Username : "Colonel Mustard" ,
1416+ },
1417+ }, withGroupKind ("remote.example.corp" , "RemoteThing" )),
1418+ expectedLocalObject : newUnstructured (& dummyv1alpha1.Thing {
1419+ ObjectMeta : metav1.ObjectMeta {
1420+ Name : "testcluster-my-test-thing" ,
1421+ Labels : map [string ]string {
1422+ agentNameLabel : "textor-the-doctor" ,
1423+ remoteObjectClusterLabel : "testcluster" ,
1424+ remoteObjectNameHashLabel : "c346c8ceb5d104cc783d09b95e8ea7032c190948" ,
1425+ },
1426+ Annotations : map [string ]string {
1427+ remoteObjectNameAnnotation : "my-test-thing" ,
1428+ },
1429+ },
1430+ Spec : dummyv1alpha1.ThingSpec {
1431+ Username : "Colonel Mustard" ,
1432+ },
1433+ }),
1434+ expectedState : `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}` ,
1435+ },
1436+ {
1437+ name : "mandatory related resource does not exist" ,
1438+ remoteAPIGroup : "remote.example.corp" ,
1439+ localCRD : loadCRD ("things" ),
1440+ pubRes : remoteThingPR ,
1441+ performRequeues : true ,
1442+
1443+ remoteObject : newUnstructured (& dummyv1alpha1.Thing {
1444+ ObjectMeta : metav1.ObjectMeta {
1445+ Name : "my-test-thing" ,
1446+ },
1447+ Spec : dummyv1alpha1.ThingSpec {
1448+ Username : "Colonel Mustard" ,
1449+ },
1450+ }, withGroupKind ("remote.example.corp" , "RemoteThing" )),
1451+ localObject : nil ,
1452+ existingState : "" ,
1453+
1454+ expectedRemoteObject : newUnstructured (& dummyv1alpha1.Thing {
1455+ ObjectMeta : metav1.ObjectMeta {
1456+ Name : "my-test-thing" ,
1457+ Finalizers : []string {
1458+ deletionFinalizer ,
1459+ },
1460+ },
1461+ Spec : dummyv1alpha1.ThingSpec {
1462+ Username : "Colonel Mustard" ,
1463+ },
1464+ }, withGroupKind ("remote.example.corp" , "RemoteThing" )),
1465+ expectedLocalObject : newUnstructured (& dummyv1alpha1.Thing {
1466+ ObjectMeta : metav1.ObjectMeta {
1467+ Name : "testcluster-my-test-thing" ,
1468+ Labels : map [string ]string {
1469+ agentNameLabel : "textor-the-doctor" ,
1470+ remoteObjectClusterLabel : "testcluster" ,
1471+ remoteObjectNameHashLabel : "c346c8ceb5d104cc783d09b95e8ea7032c190948" ,
1472+ },
1473+ Annotations : map [string ]string {
1474+ remoteObjectNameAnnotation : "my-test-thing" ,
1475+ },
1476+ },
1477+ Spec : dummyv1alpha1.ThingSpec {
1478+ Username : "Colonel Mustard" ,
1479+ },
1480+ }),
1481+ expectedState : `{"apiVersion":"remote.example.corp/v1alpha1","kind":"RemoteThing","metadata":{"name":"my-test-thing"},"spec":{"username":"Colonel Mustard"}}` ,
1482+ },
1483+ }
1484+
1485+ const stateNamespace = "kcp-system"
1486+
1487+ for _ , testcase := range testcases {
1488+ t .Run (testcase .name , func (t * testing.T ) {
1489+ localClient := buildFakeClient (testcase .localObject )
1490+ remoteClient := buildFakeClient (testcase .remoteObject )
1491+
1492+ syncer , err := NewResourceSyncer (
1493+ // zap.Must(zap.NewDevelopment()).Sugar(),
1494+ zap .NewNop ().Sugar (),
1495+ localClient ,
1496+ remoteClient ,
1497+ testcase .pubRes ,
1498+ testcase .localCRD ,
1499+ testcase .remoteAPIGroup ,
1500+ nil ,
1501+ stateNamespace ,
1502+ "textor-the-doctor" ,
1503+ )
1504+ if err != nil {
1505+ t .Fatalf ("Failed to create syncer: %v" , err )
1506+ }
1507+
1508+ localCtx := context .Background ()
1509+ remoteCtx := kontext .WithCluster (localCtx , clusterName )
1510+ ctx := NewContext (localCtx , remoteCtx )
1511+
1512+ // setup a custom state backend that we can prime
1513+ var backend * kubernetesBackend
1514+ syncer .newObjectStateStore = func (primaryObject , stateCluster syncSide ) ObjectStateStore {
1515+ // .Process() is called multiple times, but we want the state to persist between reconciles.
1516+ if backend == nil {
1517+ backend = newKubernetesBackend (stateNamespace , primaryObject , stateCluster )
1518+ if testcase .existingState != "" {
1519+ if err := backend .Put (testcase .remoteObject , clusterName , []byte (testcase .existingState )); err != nil {
1520+ t .Fatalf ("Failed to prime state store: %v" , err )
1521+ }
1522+ }
1523+ }
1524+
1525+ return & objectStateStore {
1526+ backend : backend ,
1527+ }
1528+ }
1529+
1530+ var requeue bool
1531+
1532+ if testcase .performRequeues {
1533+ target := testcase .remoteObject .DeepCopy ()
1534+
1535+ for i := 0 ; true ; i ++ {
1536+ if i > 20 {
1537+ t .Fatalf ("Detected potential infinite loop, stopping after %d requeues." , i )
1538+ }
1539+
1540+ requeue , err = syncer .Process (ctx , target )
1541+ if err != nil {
1542+ break
1543+ }
1544+
1545+ if ! requeue {
1546+ break
1547+ }
1548+
1549+ if err = remoteClient .Get (remoteCtx , ctrlruntimeclient .ObjectKeyFromObject (target ), target ); err != nil {
1550+ // it's possible for the processing to have deleted the remote object,
1551+ // so a NotFound is valid here
1552+ if apierrors .IsNotFound (err ) {
1553+ break
1554+ }
1555+
1556+ t .Fatalf ("Failed to get updated remote object: %v" , err )
1557+ }
1558+ }
1559+ } else {
1560+ requeue , err = syncer .Process (ctx , testcase .remoteObject )
1561+ }
1562+
1563+ finalRemoteObject , getErr := getFinalObjectVersion (remoteCtx , remoteClient , testcase .remoteObject , testcase .expectedRemoteObject )
1564+ if getErr != nil {
1565+ t .Fatalf ("Failed to get final remote object: %v" , getErr )
1566+ }
1567+
1568+ finalLocalObject , getErr := getFinalObjectVersion (localCtx , localClient , testcase .localObject , testcase .expectedLocalObject )
1569+ if getErr != nil {
1570+ t .Fatalf ("Failed to get final local object: %v" , getErr )
1571+ }
1572+
1573+ if testcase .customVerification != nil {
1574+ testcase .customVerification (t , requeue , err , finalRemoteObject , finalLocalObject , testcase )
1575+ } else {
1576+ if err != nil {
1577+ t .Fatalf ("Processing failed: %v" , err )
1578+ }
1579+
1580+ assertObjectsEqual (t , "local" , testcase .expectedLocalObject , finalLocalObject )
1581+ assertObjectsEqual (t , "remote" , testcase .expectedRemoteObject , finalRemoteObject )
1582+
1583+ if testcase .expectedState != "" {
1584+ if backend == nil {
1585+ t .Fatal ("Cannot check object state, state store was never instantiated." )
1586+ }
1587+
1588+ finalState , err := backend .Get (testcase .expectedRemoteObject , clusterName )
1589+ if err != nil {
1590+ t .Fatalf ("Failed to get final state: %v" , err )
1591+ } else if ! bytes .Equal (finalState , []byte (testcase .expectedState )) {
1592+ t .Fatalf ("States do not match:\n %s" , diff .StringDiff (testcase .expectedState , string (finalState )))
1593+ }
1594+ }
1595+ }
1596+ })
1597+ }
1598+ }
13231599
13241600func assertObjectsEqual (t * testing.T , kind string , expected , actual * unstructured.Unstructured ) {
13251601 if expected == nil {
0 commit comments