Skip to content

Commit 99750c8

Browse files
authored
Merge pull request #66 from kcp-dev/go-templates
Add support for Go template expressions in PublishedResources
2 parents b8dfca0 + 1b5b995 commit 99750c8

31 files changed

+1554
-458
lines changed

deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,8 @@ spec:
438438
description: |-
439439
Reference points to a field inside the main object. This reference is
440440
evaluated on both source and destination sides to find the related object.
441+
442+
Deprecated: Use Go templates instead.
441443
properties:
442444
path:
443445
description: |-
@@ -553,6 +555,8 @@ spec:
553555
description: |-
554556
Reference points to a field inside the main object. This reference is
555557
evaluated on both source and destination sides to find the related object.
558+
559+
Deprecated: Use Go templates instead.
556560
properties:
557561
path:
558562
description: |-
@@ -665,7 +669,9 @@ spec:
665669
type: object
666670
type: object
667671
origin:
668-
description: '"service" or "kcp"'
672+
enum:
673+
- service
674+
- kcp
669675
type: string
670676
required:
671677
- identifier

docs/content/publish-resources/.pages

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
nav:
22
- index.md
3+
- templating.md
34
- api-lifecycle.md
45
- technical-details.md

docs/content/publish-resources/index.md

Lines changed: 208 additions & 47 deletions
Large diffs are not rendered by default.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Templating
2+
3+
`PublishedResources` allow to use [Go templates](https://pkg.go.dev/text/template) in a number of
4+
places. A simple template could look like `{{ .Object.spec.secretName | sha3sum }}`.
5+
6+
## General Usage
7+
8+
Users are encouraged to get familiar with the [Go documentation](https://pkg.go.dev/text/template)
9+
on templates.
10+
11+
Specifically within the agent, the following rules apply when a template is evaluated:
12+
13+
* All templates must evaluate successfully. Any error will cancel the synchronization process for
14+
that object, potentially leaving it in a half-finished overall state.
15+
* Templates should not output random values, as those can lead to reconcile loops and higher load
16+
on the service cluster.
17+
* Any leading and trailing whitespace will be automatically trimmed from the template's output.
18+
* All "objects" mentioned in this documentation refer technically to an `unstructured.Unstructured`
19+
value's `.Object` field, i.e. the JSON-decoded representation of a Kubernetes object.
20+
21+
## Functions
22+
23+
Templates can make use of all functions provided by [sprig/v3](https://masterminds.github.io/sprig/),
24+
for example `join` or `b64enc`. The agent then adds the following functions:
25+
26+
* `sha3sum STRING`<br>Returns the hex-encoded SHA3-256 hash (32 characters long).
27+
* `sha3short STRING [LENGTH=20]`<br>Returns the first `LENGTH` characters of the hex-encoded SHA3-256 hash.
28+
* <del>`shortHash STRING`</del><br>Returns the first 20 characters of the hex-encoded SHA-1 hash.
29+
This function is only available for backwards compatibility when migrating `$variable`-based
30+
naming rules to use Go templates. New setups should not use this function, but one of the explicitly
31+
named ones, like `sha256sum` or `sha3sum`.
32+
33+
## Context
34+
35+
Depending on where a template is used, different data is available inside the template. The following
36+
is a summary of those different values:
37+
38+
### Primary Object Naming Rules
39+
40+
This is for templates used in `.spec.naming`:
41+
42+
| Name | Type | Description |
43+
| ------------- | --------------------- | ----------- |
44+
| `Object` | `map[string]any` | the full remote object found in a kcp workspace |
45+
| `ClusterName` | `logicalcluster.Name` | the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") |
46+
| `ClusterPath` | `logicalcluster.Path` | the workspace path (e.g. "root:customer:projectx") |
47+
48+
### Related Object Template Source
49+
50+
This is for templates used in `.spec.related[*].object.template` and
51+
`.spec.related[*].object.namespace.template`:
52+
53+
| Name | Type | Description |
54+
| ------------- | --------------------- | ----------- |
55+
| `Side` | `string` | set to either one of the possible origin values (`kcp` or `origin`) to indicate for which cluster the template is currently being evaluated for |
56+
| `Object` | `map[string]any` | the primary object belonging to the related object. Since related object templates are evaluated twice (once for the origin side and once for the destination side), object is the primary object on the side the template is evaluated for |
57+
| `ClusterName` | `logicalcluster.Name` | the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") of the kcp workspace that the synchronization is currently processing; this value is set for both evaluations, regardless of side |
58+
| `ClusterPath` | `logicalcluster.Path` | the workspace path (e.g. "root:customer:projectx"); this value is set for both evaluations, regardless of side |
59+
60+
These templates are evaluated once on each side of the synchronization.
61+
62+
### Related Object Label Selectors
63+
64+
This is for templates used in `.spec.related[*].object.selector.matchLabels` and
65+
`.spec.related[*].object.namespace.selector.matchLabels`, both keys and values:
66+
67+
| Name | Type | Description |
68+
| -------------- | --------------------- | ----------- |
69+
| `LocalObject` | `map[string]any` | the primary object copy on the local side of the sync (i.e. on the service cluster) |
70+
| `RemoteObject` | `map[string]any` | the primary object original, in kcp |
71+
| `ClusterName` | `logicalcluster.Name` | the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") of the kcp workspace that the synchronization is currently processing (where the remote object exists) |
72+
| `ClusterPath` | `logicalcluster.Path` | the workspace path (e.g. "root:customer:projectx") |
73+
74+
If a template for a key evaluates to an empty string, the key-value combination will be omitted from
75+
the final selector. Empty values however are allowed.
76+
77+
### Related Object Label Selector Rewrites
78+
79+
This is for templates used in `.spec.related[*].object.selector.rewrite.template` and
80+
`.spec.related[*].object.namespace.selector.rewrite.template`:
81+
82+
| Name | Type | Description |
83+
| --------------- | --------------------- | ----------- |
84+
| `Value` | `string` | Either the a found namespace name (when a label selector was used to select the source namespaces for related objects) or the name of a found object (when a label selector was used to find objects). In the former case, the template should return the new namespace to use on the destination side, in the latter case it should return the new object name to use on the destination side. |
85+
| `RelatedObject` | `map[string]any` | When a rewrite is used to rewrite object names, RelatedObject is the original related object (found on the origin side). This enables you to ignore the given Value entirely and just select anything from the object itself. RelatedObject is `nil` when the rewrite is performed for a namespace. |
86+
| `LocalObject` | `map[string]any` | the primary object copy on the local side of the sync (i.e. on the service cluster) |
87+
| `RemoteObject` | `map[string]any` | the primary object original, in kcp |
88+
| `ClusterName` | `logicalcluster.Name` | the internal cluster identifier (e.g. "34hg2j4gh24jdfgf") of the kcp workspace that the synchronization is currently processing (where the remote object exists) |
89+
| `ClusterPath` | `logicalcluster.Path` | the workspace path (e.g. "root:customer:projectx") |

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/kcp-dev/api-syncagent
22

3-
go 1.23.0
3+
go 1.24.0
44

55
replace github.com/kcp-dev/api-syncagent/sdk => ./sdk
66

internal/projection/naming.go

Lines changed: 0 additions & 69 deletions
This file was deleted.

internal/projection/projection.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func ProjectCRD(crd *apiextensionsv1.CustomResourceDefinition, pubRes *syncagent
116116
func stripUnwantedVersions(crd *apiextensionsv1.CustomResourceDefinition, pubRes *syncagentv1alpha1.PublishedResource) (*apiextensionsv1.CustomResourceDefinition, error) {
117117
src := pubRes.Spec.Resource
118118

119-
//nolint:staticcheck
119+
//nolint:staticcheck // .Version is deprecated, but we still support it for now.
120120
if src.Version != "" && len(src.Versions) > 0 {
121121
return nil, errors.New("cannot configure both .version and .versions in as the source of a PublishedResource")
122122
}
@@ -181,7 +181,7 @@ func projectCRDVersions(crd *apiextensionsv1.CustomResourceDefinition, pubRes *s
181181

182182
// We already validated that Version and Versions can be set at the same time.
183183

184-
//nolint:staticcheck
184+
//nolint:staticcheck // .Version is deprecated, but we still support it for now.
185185
if projection.Version != "" {
186186
if size := len(crd.Spec.Versions); size != 1 {
187187
return nil, fmt.Errorf("cannot project CRD version to a single version %q because it contains %d versions", projection.Version, size)

internal/sync/apis/dummy/v1alpha1/thing.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Thing struct {
3434

3535
type ThingSpec struct {
3636
Username string `json:"username"`
37+
Kink string `json:"kink"`
3738
Address string `json:"address,omitempty"`
3839
}
3940

internal/sync/context_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ limitations under the License.
1717
package sync
1818

1919
import (
20-
"context"
2120
"testing"
2221

2322
"github.com/kcp-dev/logicalcluster/v3"
@@ -27,9 +26,9 @@ import (
2726

2827
func TestNewContext(t *testing.T) {
2928
clusterName := logicalcluster.Name("foo")
30-
ctx := kontext.WithCluster(context.Background(), clusterName)
29+
ctx := kontext.WithCluster(t.Context(), clusterName)
3130

32-
combinedCtx := NewContext(context.Background(), ctx)
31+
combinedCtx := NewContext(t.Context(), ctx)
3332

3433
if combinedCtx.clusterName != clusterName {
3534
t.Fatalf("Expected function to recognize the cluster name in the context, but got %q", combinedCtx.clusterName)

internal/sync/crd/dummy.example.com_namespacedthings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ spec:
4040
properties:
4141
address:
4242
type: string
43+
kink:
44+
type: string
4345
username:
4446
type: string
4547
required:
48+
- kink
4649
- username
4750
type: object
4851
required:

internal/sync/crd/dummy.example.com_things.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ spec:
4040
properties:
4141
address:
4242
type: string
43+
kink:
44+
type: string
4345
username:
4446
type: string
4547
required:
48+
- kink
4649
- username
4750
type: object
4851
required:

internal/sync/crd/dummy.example.com_thingwithstatuses.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ spec:
4040
properties:
4141
address:
4242
type: string
43+
kink:
44+
type: string
4345
username:
4446
type: string
4547
required:
48+
- kink
4649
- username
4750
type: object
4851
status:

internal/sync/crd/dummy.example.com_thingwithstatussubresources.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ spec:
4040
properties:
4141
address:
4242
type: string
43+
kink:
44+
type: string
4345
username:
4446
type: string
4547
required:
48+
- kink
4649
- username
4750
type: object
4851
status:

internal/sync/init_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
dummyv1alpha1 "github.com/kcp-dev/api-syncagent/internal/sync/apis/dummy/v1alpha1"
2323

24+
corev1 "k8s.io/api/core/v1"
2425
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2526
"k8s.io/apimachinery/pkg/runtime"
2627
)
@@ -32,6 +33,9 @@ func init() {
3233
if err := dummyv1alpha1.AddToScheme(testScheme); err != nil {
3334
panic(err)
3435
}
36+
if err := corev1.AddToScheme(testScheme); err != nil {
37+
panic(err)
38+
}
3539
}
3640

3741
var nonEmptyTime = metav1.Time{

internal/sync/object_syncer.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import (
3535
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
3636
)
3737

38-
type objectCreatorFunc func(source *unstructured.Unstructured) *unstructured.Unstructured
38+
type objectCreatorFunc func(source *unstructured.Unstructured) (*unstructured.Unstructured, error)
3939

4040
type objectSyncer struct {
4141
// When set, the syncer will create a label on the destination object that contains
@@ -134,7 +134,11 @@ func (s *objectSyncer) applyMutations(source, dest syncSide) (syncSide, syncSide
134134
// the mutated names available.
135135
destObject := dest.object
136136
if destObject == nil {
137-
destObject = s.destCreator(source.object)
137+
var err error
138+
destObject, err = s.destCreator(source.object)
139+
if err != nil {
140+
return source, dest, fmt.Errorf("failed to create destination object: %w", err)
141+
}
138142
}
139143

140144
sourceObj, err := s.mutator.MutateSpec(source.object.DeepCopy(), destObject)
@@ -287,7 +291,10 @@ func (s *objectSyncer) syncObjectStatus(log *zap.SugaredLogger, source, dest syn
287291

288292
func (s *objectSyncer) ensureDestinationObject(log *zap.SugaredLogger, source, dest syncSide) error {
289293
// create a copy of the source with GVK projected and renaming rules applied
290-
destObj := s.destCreator(source.object)
294+
destObj, err := s.destCreator(source.object)
295+
if err != nil {
296+
return fmt.Errorf("failed to create destination object: %w", err)
297+
}
291298

292299
// make sure the target namespace on the destination cluster exists
293300
if err := s.ensureNamespace(dest.ctx, log, dest.client, destObj.GetNamespace()); err != nil {

internal/sync/state_store_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ limitations under the License.
1717
package sync
1818

1919
import (
20-
"context"
2120
"testing"
2221

2322
dummyv1alpha1 "github.com/kcp-dev/api-syncagent/internal/sync/apis/dummy/v1alpha1"
@@ -37,7 +36,7 @@ func TestStateStoreBasics(t *testing.T) {
3736
}, withKind("RemoteThing"))
3837

3938
serviceClusterClient := buildFakeClient()
40-
ctx := context.Background()
39+
ctx := t.Context()
4140
stateNamespace := "kcp-system"
4241

4342
primaryObjectSide := syncSide{

0 commit comments

Comments
 (0)