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