cloudflare_dns_operator/
reconcile.rs

1use super::conditions::{
2    error_condition,
3    success_condition,
4};
5use crate::{
6    context::Context,
7    dns::cloudflare::{
8        self,
9        Zone,
10    },
11    dns_check::DnsCheckRequest,
12    resources::{
13        CloudflareDNSRecord,
14        CloudflareDNSRecordStatus,
15        ZoneNameOrId,
16    },
17};
18use eyre::{
19    Context as _,
20    OptionExt as _,
21};
22use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
23use kube::{
24    api::{
25        ObjectMeta,
26        Patch,
27        PatchParams,
28    },
29    runtime::wait,
30    Api,
31};
32use serde::{
33    Deserialize,
34    Serialize,
35};
36use std::sync::Arc;
37
38#[derive(thiserror::Error, Debug)]
39pub enum ReconcileError {
40    #[error(transparent)]
41    Kube(#[from] kube::Error),
42
43    #[error(transparent)]
44    Deletion(#[from] wait::delete::Error),
45
46    #[error(transparent)]
47    Other(#[from] eyre::Error),
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone)]
51struct AnnotationContent {
52    record_id: String,
53    api_token: String,
54    zone_id: String,
55}
56
57pub async fn apply(resource: Arc<CloudflareDNSRecord>, ctx: Arc<Context>) -> Result<(), ReconcileError> {
58    let client = &ctx.client;
59    let ns = resource.metadata.namespace.as_deref().unwrap_or("default");
60    let name = resource.metadata.name.as_deref().ok_or_eyre("missing name")?;
61    let is_new = resource.status.is_none();
62    let gen = resource.metadata.generation;
63
64    info!("processing reconcile request");
65
66    // If a record exists with a different name, we need to delete it first.
67    let domain_or_record_text = resource.spec.name.as_str();
68    let api = Api::<CloudflareDNSRecord>::namespaced(client.clone(), ns);
69    if let Some(existing) = api.get_opt(name).await? {
70        if existing.spec.name != domain_or_record_text {
71            warn!(
72                "conflict: CloudflareDNSRecord {ns}/{name} already exists with a different name, deleting old record"
73            );
74            wait::delete::delete_and_finalize(api, name, &Default::default()).await?;
75        }
76    }
77
78    let Some(content) = resource.spec.lookup_content(client, ns).await? else {
79        let msg = format!("unable to resolve content for CloudflareDNSRecord {ns}/{name}");
80        error!("{msg}");
81        update_conditions(
82            &resource,
83            &ctx,
84            vec![error_condition(&resource, "missing content", msg, gen)],
85        )
86        .await?;
87        return Ok(());
88    };
89
90    let zone = match &resource.spec.zone {
91        ZoneNameOrId::Name(it) => it.lookup(client, ns).await?.map(Zone::name),
92        ZoneNameOrId::Id(it) => it.lookup(client, ns).await?.map(Zone::id),
93    };
94
95    let Some(zone) = zone else {
96        let msg = format!(
97            "unable to resolve {:?} for CloudflareDNSRecord {ns}/{name}",
98            resource.spec.zone
99        );
100        error!("{msg}");
101        update_conditions(
102            &resource,
103            &ctx,
104            vec![error_condition(&resource, "missing zone", msg, gen)],
105        )
106        .await?;
107        return Ok(());
108    };
109
110    let Some(zone) = zone.resolve(&ctx.cloudflare_api_token).await? else {
111        let msg = format!("unable to resolve zone for CloudflareDNSRecord {ns}/{name}");
112        error!("{msg}");
113        update_conditions(
114            &resource,
115            &ctx,
116            vec![error_condition(&resource, "missing zone", msg, gen)],
117        )
118        .await?;
119        return Ok(());
120    };
121    let Zone::Identifier(zone_id) = zone.clone() else {
122        unreachable!();
123    };
124
125    debug!("updating dns record for CloudflareDNSRecord {ns}/{name}");
126
127    let record = cloudflare::update_dns_record_and_wait(cloudflare::CreateRecordArgs {
128        api_token: ctx.cloudflare_api_token.clone(),
129        zone,
130        name: domain_or_record_text.to_string(),
131        record_type: resource.spec.ty.unwrap_or_default(),
132        content,
133        comment: resource.spec.comment.clone(),
134        ttl: resource.spec.ttl,
135    })
136    .await?;
137
138    // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
139
140    let status_key = format!("{ns}:{name}");
141
142    let pending = if ctx.do_dns_check {
143        !ctx.dns_lookup_success
144            .lock()
145            .await
146            .get(&status_key)
147            .cloned()
148            .unwrap_or_default()
149    } else {
150        false
151    };
152    let condition = if !pending {
153        success_condition(&resource, gen)
154    } else {
155        let msg = "The DNS record has not propagated yet. This is expected to take some time.".to_string();
156        error_condition(&resource, "pending", msg, gen)
157    };
158
159    let patched = CloudflareDNSRecord {
160        metadata: ObjectMeta {
161            name: Some(name.to_string()),
162            namespace: Some(ns.to_string()),
163            ..Default::default()
164        },
165        spec: resource.spec.clone(),
166        status: Some(CloudflareDNSRecordStatus {
167            // We are storing the details about how we created the record in the
168            // status. At deletion time, the configmap / secrets we got the
169            // zone_id from might be gone already.
170            record_id: record.id,
171            zone_id,
172            pending,
173            conditions: Some(vec![condition]),
174        }),
175    };
176
177    if is_new && ctx.do_dns_check {
178        let _ = ctx
179            .dns_check_tx
180            .send(DnsCheckRequest::CheckSingleRecord {
181                name: name.to_string(),
182                namespace: ns.to_string(),
183            })
184            .await;
185    }
186
187    Api::<CloudflareDNSRecord>::namespaced(client.clone(), ns)
188        .patch(name, &PatchParams::apply("dns.cloudflare.com"), &Patch::Apply(&patched))
189        .await
190        .context("unable to patch CloudflareDNSRecord with record details")?;
191
192    Api::<CloudflareDNSRecord>::namespaced(client.clone(), ns)
193        .patch_status(name, &PatchParams::apply("dns.cloudflare.com"), &Patch::Apply(&patched))
194        .await
195        .context("unable to patch CloudflareDNSRecord with record details")?;
196
197    Ok(())
198}
199
200/// This functions runs before the resource is deleted. It'll try to delete the DNS record from Cloudflare.
201#[instrument(level = "debug", skip_all)]
202pub async fn cleanup(resource: Arc<CloudflareDNSRecord>, ctx: Arc<Context>) -> Result<(), ReconcileError> {
203    let ns = resource.metadata.namespace.as_deref().unwrap_or("default");
204    let name = resource.metadata.name.as_deref().ok_or_eyre("missing name")?;
205
206    info!("delete request: CloudflareDNSRecord {ns}/{name}");
207
208    let Some(status) = resource.status.as_ref() else {
209        error!("missing status for CloudflareDNSRecord {ns}/{name}");
210        return Ok(());
211    };
212
213    if let Err(err) = cloudflare::delete_dns_record(&status.zone_id, &status.record_id, &ctx.cloudflare_api_token).await
214    {
215        error!("Unable to delete dns record for cloudflare: {err}");
216    }
217
218    Ok(())
219}
220
221pub async fn update_conditions(
222    resource: &CloudflareDNSRecord,
223    ctx: &Context,
224    conditions: Vec<Condition>,
225) -> Result<(), ReconcileError> {
226    let name = resource.metadata.name.as_deref().ok_or_eyre("missing name")?;
227    let ns = resource.metadata.namespace.as_deref().unwrap_or("default");
228    let status = resource.status.clone().unwrap_or_default();
229
230    let patched = CloudflareDNSRecord {
231        metadata: ObjectMeta {
232            name: Some(name.to_string()),
233            namespace: Some(ns.to_string()),
234            ..Default::default()
235        },
236        spec: resource.spec.clone(),
237        status: Some(CloudflareDNSRecordStatus {
238            conditions: Some(conditions),
239            ..status
240        }),
241    };
242
243    Api::<CloudflareDNSRecord>::namespaced(ctx.client.clone(), ns)
244        .patch_status(name, &PatchParams::apply("dns.cloudflare.com"), &Patch::Apply(&patched))
245        .await
246        .context("unable to patch CloudflareDNSRecord with record details")?;
247
248    Ok(())
249}