@@ -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,293 @@ 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+ localClient := buildFakeClient (testcase .localObject , credentials )
1502+ remoteClient := buildFakeClient (testcase .remoteObject )
1503+
1504+ syncer , err := NewResourceSyncer (
1505+ // zap.Must(zap.NewDevelopment()).Sugar(),
1506+ zap .NewNop ().Sugar (),
1507+ localClient ,
1508+ remoteClient ,
1509+ testcase .pubRes ,
1510+ testcase .localCRD ,
1511+ testcase .remoteAPIGroup ,
1512+ nil ,
1513+ stateNamespace ,
1514+ "textor-the-doctor" ,
1515+ )
1516+ if err != nil {
1517+ t .Fatalf ("Failed to create syncer: %v" , err )
1518+ }
1519+
1520+ localCtx := context .Background ()
1521+ remoteCtx := kontext .WithCluster (localCtx , clusterName )
1522+ ctx := NewContext (localCtx , remoteCtx )
1523+
1524+ // setup a custom state backend that we can prime
1525+ var backend * kubernetesBackend
1526+ syncer .newObjectStateStore = func (primaryObject , stateCluster syncSide ) ObjectStateStore {
1527+ // .Process() is called multiple times, but we want the state to persist between reconciles.
1528+ if backend == nil {
1529+ backend = newKubernetesBackend (stateNamespace , primaryObject , stateCluster )
1530+ if testcase .existingState != "" {
1531+ if err := backend .Put (testcase .remoteObject , clusterName , []byte (testcase .existingState )); err != nil {
1532+ t .Fatalf ("Failed to prime state store: %v" , err )
1533+ }
1534+ }
1535+ }
1536+
1537+ return & objectStateStore {
1538+ backend : backend ,
1539+ }
1540+ }
1541+
1542+ var requeue bool
1543+
1544+ if testcase .performRequeues {
1545+ target := testcase .remoteObject .DeepCopy ()
1546+
1547+ for i := 0 ; true ; i ++ {
1548+ if i > 20 {
1549+ t .Fatalf ("Detected potential infinite loop, stopping after %d requeues." , i )
1550+ }
1551+
1552+ requeue , err = syncer .Process (ctx , target )
1553+ if err != nil {
1554+ break
1555+ }
1556+
1557+ if ! requeue {
1558+ break
1559+ }
1560+
1561+ if err = remoteClient .Get (remoteCtx , ctrlruntimeclient .ObjectKeyFromObject (target ), target ); err != nil {
1562+ // it's possible for the processing to have deleted the remote object,
1563+ // so a NotFound is valid here
1564+ if apierrors .IsNotFound (err ) {
1565+ break
1566+ }
1567+
1568+ t .Fatalf ("Failed to get updated remote object: %v" , err )
1569+ }
1570+ }
1571+ } else {
1572+ requeue , err = syncer .Process (ctx , testcase .remoteObject )
1573+ }
1574+
1575+ finalRemoteObject , getErr := getFinalObjectVersion (remoteCtx , remoteClient , testcase .remoteObject , testcase .expectedRemoteObject )
1576+ if getErr != nil {
1577+ t .Fatalf ("Failed to get final remote object: %v" , getErr )
1578+ }
1579+
1580+ finalLocalObject , getErr := getFinalObjectVersion (localCtx , localClient , testcase .localObject , testcase .expectedLocalObject )
1581+ if getErr != nil {
1582+ t .Fatalf ("Failed to get final local object: %v" , getErr )
1583+ }
1584+
1585+ if testcase .customVerification != nil {
1586+ testcase .customVerification (t , requeue , err , finalRemoteObject , finalLocalObject , testcase )
1587+ } else {
1588+ if err != nil {
1589+ t .Fatalf ("Processing failed: %v" , err )
1590+ }
1591+
1592+ assertObjectsEqual (t , "local" , testcase .expectedLocalObject , finalLocalObject )
1593+ assertObjectsEqual (t , "remote" , testcase .expectedRemoteObject , finalRemoteObject )
1594+
1595+ if testcase .expectedState != "" {
1596+ if backend == nil {
1597+ t .Fatal ("Cannot check object state, state store was never instantiated." )
1598+ }
1599+
1600+ finalState , err := backend .Get (testcase .expectedRemoteObject , clusterName )
1601+ if err != nil {
1602+ t .Fatalf ("Failed to get final state: %v" , err )
1603+ } else if ! bytes .Equal (finalState , []byte (testcase .expectedState )) {
1604+ t .Fatalf ("States do not match:\n %s" , diff .StringDiff (testcase .expectedState , string (finalState )))
1605+ }
1606+ }
1607+ }
1608+ })
1609+ }
1610+ }
1611+
13241612func assertObjectsEqual (t * testing.T , kind string , expected , actual * unstructured.Unstructured ) {
13251613 if expected == nil {
13261614 if actual != nil {
0 commit comments