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