bindy/reconcilers/
resources.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Generic resource creation and update helpers for Kubernetes resources.
5//!
6//! This module provides reusable functions for creating and updating Kubernetes
7//! resources with different strategies (Apply, Replace, or JSON Patch). It eliminates
8//! duplicate create/update code across reconcilers.
9//!
10//! # Strategies
11//!
12//! - **Apply**: Uses server-side apply (SSA) for idempotent updates
13//! - **Replace**: Uses replace operation (suitable for resources like Deployments)
14//! - **Create with JSON Patch**: Try create, fallback to JSON patch on `AlreadyExists`
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use bindy::reconcilers::resources::{create_or_apply, create_or_replace};
20//! use k8s_openapi::api::core::v1::ServiceAccount;
21//! use kube::Client;
22//! use anyhow::Result;
23//!
24//! async fn example(client: &Client, namespace: &str, sa: ServiceAccount) -> Result<()> {
25//!     // Using Apply strategy (server-side apply)
26//!     create_or_apply(
27//!         client,
28//!         namespace,
29//!         &sa,
30//!         "bindy-controller"
31//!     ).await?;
32//!
33//!     Ok(())
34//! }
35//! ```
36
37use anyhow::Result;
38use kube::api::{Patch, PatchParams, PostParams};
39use kube::core::NamespaceResourceScope;
40use kube::{Api, Client, Resource, ResourceExt};
41use tracing::{debug, info};
42
43/// Create or update a resource using server-side apply strategy.
44///
45/// This function checks if the resource exists. If it does, it patches using
46/// server-side apply (SSA). Otherwise, it creates the resource.
47///
48/// Server-side apply is the recommended approach for managing resources in modern
49/// Kubernetes as it provides better conflict resolution and field ownership tracking.
50///
51/// # Arguments
52///
53/// * `client` - Kubernetes API client
54/// * `namespace` - Namespace where the resource should be created/updated
55/// * `resource` - The resource to create or update
56/// * `field_manager` - Field manager name for server-side apply (e.g., "bindy-controller")
57///
58/// # Returns
59///
60/// Returns `Ok(())` if the operation succeeded.
61///
62/// # Errors
63///
64/// Returns an error if:
65/// - The resource has no name in its metadata
66/// - API operations fail
67///
68/// # Example
69///
70/// ```rust,no_run
71/// # use bindy::reconcilers::resources::create_or_apply;
72/// # use k8s_openapi::api::core::v1::ServiceAccount;
73/// # use kube::Client;
74/// # async fn example(client: &Client, namespace: &str, sa: ServiceAccount) {
75/// create_or_apply(client, namespace, &sa, "bindy-controller").await.unwrap();
76/// # }
77/// ```
78pub async fn create_or_apply<T>(
79    client: &Client,
80    namespace: &str,
81    resource: &T,
82    field_manager: &str,
83) -> Result<()>
84where
85    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
86        + ResourceExt
87        + Clone
88        + std::fmt::Debug
89        + serde::Serialize
90        + for<'de> serde::Deserialize<'de>,
91{
92    let name = resource
93        .meta()
94        .name
95        .as_ref()
96        .ok_or_else(|| anyhow::anyhow!("Resource must have a name"))?;
97
98    let api: Api<T> = Api::namespaced(client.clone(), namespace);
99
100    debug!(
101        namespace = %namespace,
102        name = %name,
103        kind = %T::kind(&()),
104        "Creating or updating resource with Apply strategy"
105    );
106
107    if api.get(name).await.is_ok() {
108        debug!(
109            "{} {}/{} already exists, applying update",
110            T::kind(&()),
111            namespace,
112            name
113        );
114        api.patch(
115            name,
116            &PatchParams::apply(field_manager),
117            &Patch::Apply(resource),
118        )
119        .await?;
120        info!("Updated {} {}/{}", T::kind(&()), namespace, name);
121    } else {
122        debug!(
123            "{} {}/{} does not exist, creating",
124            T::kind(&()),
125            namespace,
126            name
127        );
128        api.create(&PostParams::default(), resource).await?;
129        info!("Created {} {}/{}", T::kind(&()), namespace, name);
130    }
131
132    Ok(())
133}
134
135/// Create or update a resource using replace strategy.
136///
137/// This function checks if the resource exists. If it does, it replaces the entire
138/// resource. Otherwise, it creates the resource.
139///
140/// The replace strategy is suitable for resources like Deployments where you want
141/// to completely replace the specification rather than merge changes.
142///
143/// # Arguments
144///
145/// * `client` - Kubernetes API client
146/// * `namespace` - Namespace where the resource should be created/updated
147/// * `resource` - The resource to create or update
148///
149/// # Returns
150///
151/// Returns `Ok(())` if the operation succeeded.
152///
153/// # Errors
154///
155/// Returns an error if:
156/// - The resource has no name in its metadata
157/// - API operations fail
158///
159/// # Example
160///
161/// ```rust,no_run
162/// # use bindy::reconcilers::resources::create_or_replace;
163/// # use k8s_openapi::api::apps::v1::Deployment;
164/// # use kube::Client;
165/// # async fn example(client: &Client, namespace: &str, deploy: Deployment) {
166/// create_or_replace(client, namespace, &deploy).await.unwrap();
167/// # }
168/// ```
169pub async fn create_or_replace<T>(client: &Client, namespace: &str, resource: &T) -> Result<()>
170where
171    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
172        + ResourceExt
173        + Clone
174        + std::fmt::Debug
175        + serde::Serialize
176        + for<'de> serde::Deserialize<'de>,
177{
178    let name = resource
179        .meta()
180        .name
181        .as_ref()
182        .ok_or_else(|| anyhow::anyhow!("Resource must have a name"))?;
183
184    let api: Api<T> = Api::namespaced(client.clone(), namespace);
185
186    debug!(
187        namespace = %namespace,
188        name = %name,
189        kind = %T::kind(&()),
190        "Creating or replacing resource"
191    );
192
193    if api.get(name).await.is_ok() {
194        info!("Replacing {} {}/{}", T::kind(&()), namespace, name);
195        api.replace(name, &PostParams::default(), resource).await?;
196    } else {
197        info!("Creating {} {}/{}", T::kind(&()), namespace, name);
198        api.create(&PostParams::default(), resource).await?;
199    }
200
201    Ok(())
202}
203
204/// Create or update a resource using JSON patch on conflict.
205///
206/// This function attempts to create the resource. If it already exists (`AlreadyExists` error),
207/// it patches the resource using server-side apply with a provided JSON patch object.
208///
209/// This strategy is useful when you need full control over the patch structure, such as
210/// when updating complex resources with owner references, labels, and annotations.
211///
212/// # Arguments
213///
214/// * `client` - Kubernetes API client
215/// * `namespace` - Namespace where the resource should be created/updated
216/// * `resource` - The resource to create
217/// * `patch_json` - JSON value containing the patch to apply if resource exists
218/// * `field_manager` - Field manager name for server-side apply (e.g., "bindy-controller")
219///
220/// # Returns
221///
222/// Returns `Ok(())` if the operation succeeded.
223///
224/// # Errors
225///
226/// Returns an error if:
227/// - The resource has no name in its metadata
228/// - Create or patch operations fail (other than `AlreadyExists` on create)
229///
230/// # Example
231///
232/// ```rust,no_run
233/// # use bindy::reconcilers::resources::create_or_patch_json;
234/// # use bindy::crd::Bind9Instance;
235/// # use kube::Client;
236/// # use serde_json::json;
237/// # async fn example(client: &Client, namespace: &str, instance: Bind9Instance) {
238/// let patch = json!({
239///     "apiVersion": "dns.firestoned.io/v1alpha1",
240///     "kind": "Bind9Instance",
241///     "metadata": {
242///         "name": "my-instance",
243///         "namespace": namespace,
244///     },
245///     "spec": instance.spec,
246/// });
247///
248/// create_or_patch_json(
249///     client,
250///     namespace,
251///     &instance,
252///     &patch,
253///     "bindy-controller"
254/// ).await.unwrap();
255/// # }
256/// ```
257pub async fn create_or_patch_json<T>(
258    client: &Client,
259    namespace: &str,
260    resource: &T,
261    patch_json: &serde_json::Value,
262    field_manager: &str,
263) -> Result<()>
264where
265    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
266        + ResourceExt
267        + Clone
268        + std::fmt::Debug
269        + serde::Serialize
270        + for<'de> serde::Deserialize<'de>,
271{
272    let name = resource
273        .meta()
274        .name
275        .as_ref()
276        .ok_or_else(|| anyhow::anyhow!("Resource must have a name"))?;
277
278    let api: Api<T> = Api::namespaced(client.clone(), namespace);
279
280    debug!(
281        namespace = %namespace,
282        name = %name,
283        kind = %T::kind(&()),
284        "Creating or patching resource with JSON strategy"
285    );
286
287    match api.create(&PostParams::default(), resource).await {
288        Ok(_) => {
289            info!("Created {} {}/{}", T::kind(&()), namespace, name);
290            Ok(())
291        }
292        Err(e) => {
293            // If already exists, patch it to ensure spec is up to date
294            if e.to_string().contains("AlreadyExists") {
295                debug!(
296                    "{} {}/{} already exists, patching with updated spec",
297                    T::kind(&()),
298                    namespace,
299                    name
300                );
301
302                api.patch(
303                    name,
304                    &PatchParams::apply(field_manager).force(),
305                    &Patch::Apply(patch_json),
306                )
307                .await?;
308
309                info!(
310                    "Patched {} {}/{} with updated spec",
311                    T::kind(&()),
312                    namespace,
313                    name
314                );
315                Ok(())
316            } else {
317                Err(e.into())
318            }
319        }
320    }
321}
322
323#[cfg(test)]
324#[path = "resources_tests.rs"]
325mod resources_tests;