Skip to content

Commit 6a7c0d6

Browse files
committed
feat: add optional published resources
Signed-off-by: Karol Szwaj <[email protected]> On-behalf-of: @SAP [email protected]
1 parent c53499a commit 6a7c0d6

File tree

4 files changed

+315
-27
lines changed

4 files changed

+315
-27
lines changed

internal/sync/syncer_related.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ import (
3535

3636
func (s *ResourceSyncer) processRelatedResources(log *zap.SugaredLogger, stateStore ObjectStateStore, remote, local syncSide) (requeue bool, err error) {
3737
for _, relatedResource := range s.pubRes.Spec.Related {
38-
requeue, err := s.processRelatedResource(log.With("identifier", relatedResource.Identifier), stateStore, remote, local, relatedResource)
39-
if err != nil {
40-
return false, fmt.Errorf("failed to process related resource %s: %w", relatedResource.Identifier, err)
41-
}
38+
if !relatedResource.Optional {
39+
requeue, err := s.processRelatedResource(log.With("identifier", relatedResource.Identifier), stateStore, remote, local, relatedResource)
40+
if err != nil {
41+
return false, fmt.Errorf("failed to process related resource %s: %w", relatedResource.Identifier, err)
42+
}
4243

43-
if requeue {
44-
return true, nil
44+
if requeue {
45+
return true, nil
46+
}
4547
}
4648
}
4749

@@ -70,28 +72,23 @@ func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateSto
7072
dest = local
7173
}
7274

73-
// to find the source related object, we first need to determine its name/namespace
7475
sourceKey, err := resolveResourceReference(source.object, relRes.Reference)
7576
if err != nil {
7677
return false, fmt.Errorf("failed to determine related object's source key: %w", err)
7778
}
7879

79-
// find the source related object
8080
sourceObj := &unstructured.Unstructured{}
81-
sourceObj.SetAPIVersion("v1") // we only support ConfigMaps and Secrets, both are in core/v1
81+
sourceObj.SetAPIVersion("v1")
8282
sourceObj.SetKind(relRes.Kind)
8383

8484
err = source.client.Get(source.ctx, *sourceKey, sourceObj)
8585
if err != nil {
86-
// the source object doesn't exist yet, so we can just stop
8786
if apierrors.IsNotFound(err) {
8887
return false, nil
8988
}
90-
9189
return false, fmt.Errorf("failed to get source object: %w", err)
9290
}
9391

94-
// do the same to find the destination object
9592
destKey, err := resolveResourceReference(dest.object, relRes.Reference)
9693
if err != nil {
9794
return false, fmt.Errorf("failed to determine related object's destination key: %w", err)
@@ -137,7 +134,6 @@ func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateSto
137134
dest := source.DeepCopy()
138135
dest.SetName(destKey.Name)
139136
dest.SetNamespace(destKey.Namespace)
140-
141137
return dest
142138
},
143139
// ConfigMaps and Secrets have no subresources
@@ -242,10 +238,7 @@ func resolveResourceLocator(jsonData string, loc syncagentv1alpha1.ResourceLocat
242238
return "", fmt.Errorf("invalid pattern %q: %w", re.Pattern, err)
243239
}
244240

245-
// this does apply some coalescing, like turning numbers into strings
246-
strVal := gval.String()
247-
248-
return expr.ReplaceAllString(strVal, re.Replacement), nil
241+
return expr.ReplaceAllString(gval.String(), re.Replacement), nil
249242
}
250243

251244
return gval.String(), nil

internal/sync/syncer_test.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

13241600
func assertObjectsEqual(t *testing.T, kind string, expected, actual *unstructured.Unstructured) {
13251601
if expected == nil {

sdk/apis/syncagent/v1alpha1/published_resource.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ type RelatedResourceSpec struct {
176176
// Mutation configures optional transformation rules for the related resource.
177177
// Status mutations are only performed when the related resource originates in kcp.
178178
Mutation *ResourceMutationSpec `json:"mutation,omitempty"`
179+
180+
// Optional indicates whether the related resource must be referenced.
181+
Optional bool `json:"optional,omitempty"`
179182
}
180183

181184
type RelatedResourceReference struct {

0 commit comments

Comments
 (0)