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 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 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 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#[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}