Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 50c52f3

Browse files
committedMar 19, 2025··
feat: add optional published resources
Signed-off-by: Karol Szwaj <karol.szwaj@gmail.com> On-behalf-of: @SAP karol.szwaj@sap.com
1 parent c53499a commit 50c52f3

File tree

4 files changed

+315
-30
lines changed

4 files changed

+315
-30
lines changed
 

‎internal/sync/syncer_related.go

+10-20
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
/*
22
Copyright 2025 The KCP Authors.
3-
43
Licensed under the Apache License, Version 2.0 (the "License");
54
you may not use this file except in compliance with the License.
65
You may obtain a copy of the License at
7-
86
http://www.apache.org/licenses/LICENSE-2.0
9-
107
Unless required by applicable law or agreed to in writing, software
118
distributed under the License is distributed on an "AS IS" BASIS,
129
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -35,13 +32,15 @@ import (
3532

3633
func (s *ResourceSyncer) processRelatedResources(log *zap.SugaredLogger, stateStore ObjectStateStore, remote, local syncSide) (requeue bool, err error) {
3734
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-
}
35+
if !relatedResource.Optional {
36+
requeue, err := s.processRelatedResource(log.With("identifier", relatedResource.Identifier), stateStore, remote, local, relatedResource)
37+
if err != nil {
38+
return false, fmt.Errorf("failed to process related resource %s: %w", relatedResource.Identifier, err)
39+
}
4240

43-
if requeue {
44-
return true, nil
41+
if requeue {
42+
return true, nil
43+
}
4544
}
4645
}
4746

@@ -70,28 +69,23 @@ func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateSto
7069
dest = local
7170
}
7271

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

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

8481
err = source.client.Get(source.ctx, *sourceKey, sourceObj)
8582
if err != nil {
86-
// the source object doesn't exist yet, so we can just stop
8783
if apierrors.IsNotFound(err) {
8884
return false, nil
8985
}
90-
9186
return false, fmt.Errorf("failed to get source object: %w", err)
9287
}
9388

94-
// do the same to find the destination object
9589
destKey, err := resolveResourceReference(dest.object, relRes.Reference)
9690
if err != nil {
9791
return false, fmt.Errorf("failed to determine related object's destination key: %w", err)
@@ -137,7 +131,6 @@ func (s *ResourceSyncer) processRelatedResource(log *zap.SugaredLogger, stateSto
137131
dest := source.DeepCopy()
138132
dest.SetName(destKey.Name)
139133
dest.SetNamespace(destKey.Namespace)
140-
141134
return dest
142135
},
143136
// ConfigMaps and Secrets have no subresources
@@ -242,10 +235,7 @@ func resolveResourceLocator(jsonData string, loc syncagentv1alpha1.ResourceLocat
242235
return "", fmt.Errorf("invalid pattern %q: %w", re.Pattern, err)
243236
}
244237

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

251241
return gval.String(), nil

‎internal/sync/syncer_test.go

+276
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

+3
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)
Please sign in to comment.