Skip to content

Commit a6fa355

Browse files
committed
- Add support to choose the sync strategy resp. ownership per destination and switch the default behaviour from replace to apply.
- Fix a bug where `uid` and `resourceVersion` was not cleared in create requests if destination objects was deleted.
1 parent 3b68c9b commit a6fa355

File tree

4 files changed

+139
-21
lines changed

4 files changed

+139
-21
lines changed

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ spec:
105105
kind: ObjectSync
106106
apiVersion: sync.rustrial.org/v1alpha1
107107
metadata:
108-
name: my-test-config-map-distributor
108+
name: my-test-secret-map-distributor
109109
namespace: default
110110
spec:
111111
source:
@@ -123,7 +123,7 @@ spec:
123123
kind: ObjectSync
124124
apiVersion: sync.rustrial.org/v1alpha1
125125
metadata:
126-
name: my-test-config-map-distributor
126+
name: my-test-cronjob-map-distributor
127127
namespace: default
128128
spec:
129129
source:
@@ -141,7 +141,7 @@ spec:
141141
kind: ObjectSync
142142
apiVersion: sync.rustrial.org/v1alpha1
143143
metadata:
144-
name: my-test-config-map-distributor
144+
name: my-test-hr-map-distributor
145145
namespace: default
146146
spec:
147147
source:
@@ -191,6 +191,35 @@ make sure it obtains all `delete` events.
191191

192192
---
193193

194+
## Ownership
195+
196+
For each destination a sync strategy can be defined, which is either
197+
[sever side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/)
198+
for shared ownership or *replace* for exclusive ownership.
199+
By default the controller uses shared ownership (*server side apply* with the `force` flag)
200+
to manage sync destination objects, allowing other controllers and users to manage fields
201+
not set in the source object.
202+
203+
Set the strategy to `replace` if you want the controller to take *exclusive* ownership for a destination.
204+
205+
```yaml
206+
kind: ObjectSync
207+
apiVersion: sync.rustrial.org/v1alpha1
208+
metadata:
209+
name: my-test-config-map-distributor
210+
namespace: default
211+
spec:
212+
source:
213+
group: ""
214+
kind: ConfigMap
215+
name: my-namespace
216+
destinations:
217+
- namespace: "*"
218+
strategy: "replace"
219+
```
220+
221+
---
222+
194223
## License
195224

196225
Licensed under either of

charts/k8s-object-syncer/crds/crds.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ spec:
4040
namespace:
4141
description: "The destination (target) namespace, if empty `\"\"` or `\"*\"` the source object is synced to all namespaces."
4242
type: string
43+
strategy:
44+
description: "The sync strategy to use for this destination, defaults to \"apply\" (server side apply)."
45+
enum:
46+
- apply
47+
- replace
48+
nullable: true
49+
type: string
4350
required:
4451
- namespace
4552
type: object
@@ -139,6 +146,13 @@ spec:
139146
nullable: true
140147
type: string
141148
type: object
149+
strategy:
150+
description: "The [`SyncStrategy`] applied to this destination."
151+
enum:
152+
- apply
153+
- replace
154+
nullable: true
155+
type: string
142156
syncedVersion:
143157
description: "The last source version syced, `None` if not yet synced."
144158
nullable: true

rustrial-k8s-object-syncer-apis/src/lib.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ pub struct SourceObject {
108108
pub namespace: Option<String>,
109109
}
110110

111+
/// Destination object synchronization strategy.
112+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, JsonSchema)]
113+
pub enum SyncStrategy {
114+
/// Use [server side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) to
115+
/// manage (sync) destination objects with a shared ownership model
116+
/// (only overwriting changes in fields present in source object).
117+
#[serde(rename = "apply")]
118+
Apply,
119+
/// Use replace to manage (sync) destination objects with exclusive ownership
120+
/// (overwriting all changes made by others).
121+
#[serde(rename = "replace")]
122+
Replace,
123+
}
124+
125+
impl Default for SyncStrategy {
126+
fn default() -> Self {
127+
Self::Apply
128+
}
129+
}
130+
111131
/// Synchronization target configuration.
112132
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone, JsonSchema)]
113133
pub struct Destination {
@@ -118,9 +138,16 @@ pub struct Destination {
118138
/// The destination (target) namespace, if empty `""` or `"*"` the source object
119139
/// is synced to all namespaces.
120140
pub namespace: String,
141+
/// The sync strategy to use for this destination, defaults to "apply" (server side apply).
142+
#[serde(skip_serializing_if = "Option::is_none")]
143+
pub strategy: Option<SyncStrategy>,
121144
}
122145

123146
impl Destination {
147+
pub fn strategy(&self) -> SyncStrategy {
148+
self.strategy.unwrap_or_default()
149+
}
150+
124151
pub fn applies_to_all_namespaces(&self) -> bool {
125152
self.namespace.as_str() == "" || self.namespace.as_str() == "*"
126153
}
@@ -196,6 +223,15 @@ pub struct DestinationStatus {
196223
/// The last source version syced, `None` if not yet synced.
197224
#[serde(skip_serializing_if = "Option::is_none", rename = "syncedVersion")]
198225
pub synced_version: Option<ObjectRevision>,
226+
/// The [`SyncStrategy`] applied to this destination.
227+
#[serde(skip_serializing_if = "Option::is_none")]
228+
pub strategy: Option<SyncStrategy>,
229+
}
230+
231+
impl DestinationStatus {
232+
pub fn strategy(&self) -> SyncStrategy {
233+
self.strategy.unwrap_or_default()
234+
}
199235
}
200236

201237
impl PartialOrd for DestinationStatus {
@@ -326,4 +362,16 @@ mod tests {
326362
serde_json::to_string(&p).unwrap()
327363
);
328364
}
365+
366+
#[test]
367+
fn sync_strategy() {
368+
assert_eq!(
369+
r#""apply""#,
370+
serde_json::to_string(&SyncStrategy::Apply).unwrap()
371+
);
372+
assert_eq!(
373+
r#""replace""#,
374+
serde_json::to_string(&SyncStrategy::Replace).unwrap()
375+
);
376+
}
329377
}

rustrial-k8s-object-syncer/src/resource_controller.rs

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use futures::{
1313
};
1414
use k8s_openapi::{api::core::v1::Namespace, chrono::Utc};
1515
use kube::{
16-
api::{ApiResource, DynamicObject, GroupVersionKind, ListParams, PostParams, TypeMeta},
16+
api::{
17+
ApiResource, DynamicObject, GroupVersionKind, ListParams, Patch, PatchParams, PostParams,
18+
TypeMeta,
19+
},
1720
Api, Client, ResourceExt,
1821
};
1922
use kube_runtime::{
@@ -27,7 +30,8 @@ use opentelemetry::{
2730
KeyValue,
2831
};
2932
use rustrial_k8s_object_syncer_apis::{
30-
Condition, DestinationStatus, ObjectRevision, ObjectSync, ObjectSyncSpec, API_GROUP,
33+
Condition, DestinationStatus, ObjectRevision, ObjectSync, ObjectSyncSpec, SyncStrategy,
34+
API_GROUP,
3135
};
3236
use std::{
3337
borrow::BorrowMut,
@@ -212,27 +216,27 @@ impl ResourceControllerImpl {
212216
fn expected_destinations<'a>(
213217
&self,
214218
event: &'a ObjectSyncModifications,
215-
) -> impl Iterator<Item = (String, String)> + 'a {
219+
) -> impl Iterator<Item = (String, String, Option<SyncStrategy>)> + 'a {
216220
let spec: &ObjectSyncSpec = &event.spec;
217221
let cache = self.namespace_cache.state();
218222
spec.destinations.iter().flat_map(move |d| {
219-
let mut tmp: Vec<(String, String)> = Default::default();
223+
let mut tmp: Vec<(String, String, Option<SyncStrategy>)> = Default::default();
220224
if d.applies_to_all_namespaces() {
221225
for ns in cache.iter() {
222226
// Make sure we skip deleted namespaces, as otherwise the finalizers on the synced
223227
// destination objects will prevent the namespace from being deleted.
224228
if ns.metadata.deletion_timestamp.is_none() {
225-
if let Some(x) = d.applies_to(event, ns.name().as_str()) {
226-
tmp.push(x);
229+
if let Some((ns, name)) = d.applies_to(event, ns.name().as_str()) {
230+
tmp.push((ns, name, d.strategy));
227231
}
228232
}
229233
}
230-
} else if let Some(x) = d.applies_to(event, d.namespace.as_str()) {
234+
} else if let Some((namespace, name)) = d.applies_to(event, d.namespace.as_str()) {
231235
if let Some(ns) = cache.iter().find(|ns| ns.name() == d.namespace) {
232236
// Make sure we skip deleted namespaces, as otherwise the finalizers on the synced
233237
// destination objects will prevent the namespace from being deleted.
234238
if ns.metadata.deletion_timestamp.is_none() {
235-
tmp.push(x);
239+
tmp.push((namespace, name, d.strategy));
236240
}
237241
}
238242
}
@@ -280,8 +284,6 @@ impl ResourceControllerImpl {
280284
}
281285
template.metadata.namespace = Some(d.namespace.clone());
282286
template.metadata.name = Some(d.name.clone());
283-
template.metadata.uid = Default::default();
284-
template.metadata.resource_version = Default::default();
285287
template.metadata.generation = Default::default();
286288
template.metadata.generate_name = Default::default();
287289
template.metadata.managed_fields = Default::default();
@@ -293,6 +295,8 @@ impl ResourceControllerImpl {
293295
let api = self.namespaced_api(d.namespace.as_str());
294296
let mut retry_attempts = 3i32;
295297
while retry_attempts > 0 {
298+
template.metadata.uid = Default::default();
299+
template.metadata.resource_version = Default::default();
296300
retry_attempts -= 1;
297301
match api.get(d.name.as_str()).await {
298302
Ok(mut current) => {
@@ -317,11 +321,24 @@ impl ResourceControllerImpl {
317321
resource_version: current.resource_version(),
318322
};
319323
if &Some(dst_version) != &d.synced_version
320-
|| &Some(src_version.clone()) != &d.source_version
324+
|| &Some(&src_version) != &d.source_version.as_ref()
321325
{
322326
template.metadata.uid = current.uid();
323327
template.metadata.resource_version = current.resource_version();
324-
match api.replace(d.name.as_str(), &pp, &template).await {
328+
329+
let result = match &d.strategy() {
330+
SyncStrategy::Replace => {
331+
api.replace(d.name.as_str(), &pp, &template).await
332+
}
333+
SyncStrategy::Apply => {
334+
let mut pp = PatchParams::default();
335+
pp.field_manager = Some(MANAGER.to_string());
336+
pp.force = true;
337+
api.patch(d.name.as_str(), &pp, &Patch::Apply(&template))
338+
.await
339+
}
340+
};
341+
match result {
325342
Ok(updated) => {
326343
d.synced_version = Some(ObjectRevision {
327344
uid: updated.uid(),
@@ -410,22 +427,32 @@ impl ResourceControllerImpl {
410427
.flatten()
411428
.unwrap_or_default();
412429
let mut expected_destinations: Vec<DestinationStatus> = Default::default();
413-
for (dst_namespace, dst_name) in self.expected_destinations(event) {
414-
let expected_dst = DestinationStatus {
430+
for (dst_namespace, dst_name, strategy) in self.expected_destinations(event) {
431+
let mut expected_dst = DestinationStatus {
415432
name: dst_name,
416433
namespace: dst_namespace,
417434
source_version: None,
418435
synced_version: None,
419436
group: self.gvk.group.clone(),
420437
version: self.gvk.version.clone(),
421438
kind: self.gvk.kind.clone(),
439+
strategy,
422440
};
423-
let expected_dst = stale_destinations
441+
if let Some(status) = stale_destinations
424442
.iter()
425443
.find(|d| Self::is_same_destination(d, &expected_dst))
426-
.map(|d| (*d).clone())
427-
.unwrap_or(expected_dst);
428-
stale_destinations.retain(|d| !Self::is_same_destination(d, &expected_dst));
444+
{
445+
// If strategy (sync config) changed do not set version to make sure the destination
446+
// object is update. Note, this is required as we cannot track the ObjectSync's resourceVersion
447+
// in its own status as this would lead to an infinit reconciliation cycle.
448+
if status.strategy() == expected_dst.strategy() {
449+
expected_dst.source_version = status.source_version.clone();
450+
expected_dst.synced_version = status.synced_version.clone();
451+
}
452+
// As destination is in set of expected destinations, remove it from the set of
453+
// stale destinations.
454+
stale_destinations.retain(|d| !Self::is_same_destination(d, &expected_dst));
455+
}
429456
expected_destinations.push(expected_dst);
430457
}
431458
// 1. Remove stale destinations.

0 commit comments

Comments
 (0)