1use std::collections::HashMap;
5
6use uuid::Uuid;
7
8use crate::client::Client;
9use crate::error::{Error, ErrorCode, Result};
10use crate::models::{
11 HostHealthMode, NewRecordValue, RecordSet, RecordValue, RecordValuePatch, Zone,
12};
13use crate::ops::rdata::same_data;
14
15pub const DEFAULT_TTL: u32 = 3600;
17
18#[derive(Debug, Clone)]
20pub enum ValueSelector {
21 Data(String),
23 Id(Uuid),
25}
26
27#[derive(Debug, Clone, Default)]
29pub struct AddValue {
30 pub data: String,
32 pub ttl: Option<u32>,
35 pub geo_routing: Option<bool>,
37 pub balance_ratio: Option<u16>,
39 pub host_health: Option<HostHealthMode>,
41}
42
43#[derive(Debug, Clone)]
45pub struct Added {
46 pub value: RecordValue,
48 pub ttl: u32,
50 pub new_group: bool,
52}
53
54pub fn group_values(values: Vec<RecordValue>) -> Vec<RecordSet> {
84 let mut groups: Vec<RecordSet> = Vec::new();
85 let mut index: HashMap<(Uuid, String, String), usize> = HashMap::new();
86 for value in values {
87 let key = (value.zone_id, value.name.clone(), value.record_type.clone());
88 match index.get(&key) {
89 Some(&at) => groups[at].values.push(value),
90 None => {
91 index.insert(key, groups.len());
92 groups.push(RecordSet {
93 zone_id: value.zone_id,
94 name: value.name.clone(),
95 record_type: value.record_type.clone(),
96 ttl: value.ttl,
97 values: vec![value],
98 });
99 }
100 }
101 }
102 groups
103}
104
105pub async fn find_group(
112 client: &Client,
113 zone: &Zone,
114 name: &str,
115 record_type: &str,
116 with_geo_bans: bool,
117) -> Result<Option<RecordSet>> {
118 match client
119 .records()
120 .get(zone.uuid, name, record_type, with_geo_bans)
121 .await
122 {
123 Ok(group) => Ok(Some(group)),
124 Err(Error::Api(err)) if err.code == ErrorCode::NotFound => Ok(None),
125 Err(err) => Err(err),
126 }
127}
128
129pub async fn add_value(
158 client: &Client,
159 zone: &Zone,
160 name: &str,
161 record_type: &str,
162 add: AddValue,
163) -> Result<Added> {
164 if add.host_health == Some(HostHealthMode::Auto) {
165 return Err(Error::InvalidInput(
166 "a new value cannot start on host health Auto: add it, then create its health check \
167 (that switches it to Auto)"
168 .into(),
169 ));
170 }
171 let record_type = record_type.trim().to_ascii_uppercase();
172 let existing = find_group(client, zone, name, &record_type, false).await?;
173 let (ttl, send_ttl) = match &existing {
174 None => {
175 let ttl = add.ttl.unwrap_or(DEFAULT_TTL);
176 (ttl, Some(ttl))
177 }
178 Some(group) => match add.ttl {
179 Some(ttl) if ttl != group.ttl => {
180 return Err(Error::InvalidInput(format!(
181 "{name} {record_type} already has TTL {}; change the group TTL separately",
182 group.ttl
183 )));
184 }
185 _ => (group.ttl, None),
186 },
187 };
188
189 let value = client
190 .records()
191 .create(&NewRecordValue {
192 zone_id: zone.uuid,
193 name: name.to_owned(),
194 record_type,
195 data: add.data,
196 ttl: send_ttl,
197 geo_routing: add.geo_routing,
198 balance_ratio: add.balance_ratio,
199 host_health: add.host_health,
200 })
201 .await?;
202 Ok(Added {
203 value,
204 ttl,
205 new_group: existing.is_none(),
206 })
207}
208
209pub async fn find_value(
231 client: &Client,
232 zone: &Zone,
233 name: &str,
234 record_type: &str,
235 selector: &ValueSelector,
236) -> Result<(RecordSet, RecordValue)> {
237 find_value_in(client, zone, name, record_type, selector, false).await
238}
239
240pub async fn find_value_with_geo_bans(
248 client: &Client,
249 zone: &Zone,
250 name: &str,
251 record_type: &str,
252 selector: &ValueSelector,
253) -> Result<(RecordSet, RecordValue)> {
254 find_value_in(client, zone, name, record_type, selector, true).await
255}
256
257async fn find_value_in(
258 client: &Client,
259 zone: &Zone,
260 name: &str,
261 record_type: &str,
262 selector: &ValueSelector,
263 with_geo_bans: bool,
264) -> Result<(RecordSet, RecordValue)> {
265 let record_type = record_type.trim().to_ascii_uppercase();
266 let group = find_group(client, zone, name, &record_type, with_geo_bans)
267 .await?
268 .ok_or_else(|| Error::NotFound(format!("no {record_type} record at {name}")))?;
269 let found = match selector {
270 ValueSelector::Id(id) => group.values.iter().find(|v| v.uuid == *id),
271 ValueSelector::Data(data) => group
272 .values
273 .iter()
274 .find(|v| same_data(&record_type, data, &v.data)),
275 };
276 match found {
277 Some(value) => {
278 let value = value.clone();
279 Ok((group, value))
280 }
281 None => Err(Error::NotFound(format!(
282 "{name} {record_type} has no value {}; existing values: {}",
283 match selector {
284 ValueSelector::Id(id) => id.to_string(),
285 ValueSelector::Data(data) => format!("{data:?}"),
286 },
287 describe_values(&group)
288 ))),
289 }
290}
291
292pub async fn update_value(
300 client: &Client,
301 zone: &Zone,
302 name: &str,
303 record_type: &str,
304 selector: &ValueSelector,
305 patch: &RecordValuePatch,
306) -> Result<RecordValue> {
307 if patch.is_empty() {
308 return Err(Error::InvalidInput("nothing to change".into()));
309 }
310 let (_, value) = find_value(client, zone, name, record_type, selector).await?;
311 client.records().update(value.uuid, patch).await
312}
313
314pub async fn remove_value(
322 client: &Client,
323 zone: &Zone,
324 name: &str,
325 record_type: &str,
326 selector: &ValueSelector,
327) -> Result<RecordValue> {
328 let (_, value) = find_value(client, zone, name, record_type, selector).await?;
329 client.records().delete(value.uuid).await?;
330 Ok(value)
331}
332
333pub fn check_group_removable(group: &RecordSet) -> Result<()> {
340 let locked: Vec<String> = group
341 .values
342 .iter()
343 .filter(|v| v.is_locked())
344 .map(|v| {
345 format!(
346 "{:?} (managed by {})",
347 v.data,
348 v.managed_by.as_deref().unwrap_or("?")
349 )
350 })
351 .collect();
352 if locked.is_empty() {
353 return Ok(());
354 }
355 Err(Error::InvalidInput(format!(
356 "{} {} has values Core keeps for another resource: {}; change or delete that \
357 resource instead",
358 group.name,
359 group.record_type,
360 locked.join(", ")
361 )))
362}
363
364pub async fn remove_group(client: &Client, group: &RecordSet) -> Result<Vec<RecordValue>> {
376 check_group_removable(group)?;
377 let mut removed = Vec::with_capacity(group.values.len());
378 for value in &group.values {
379 client.records().delete(value.uuid).await?;
380 removed.push(value.clone());
381 }
382 Ok(removed)
383}
384
385fn describe_values(group: &RecordSet) -> String {
386 if group.values.is_empty() {
387 return "none".into();
388 }
389 group
390 .values
391 .iter()
392 .map(|v| format!("{:?} ({})", v.data, v.uuid))
393 .collect::<Vec<_>>()
394 .join(", ")
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 fn group(managed: &[Option<&str>]) -> RecordSet {
402 let values = managed
403 .iter()
404 .map(|m| {
405 serde_json::from_value(serde_json::json!({
406 "uuid": Uuid::nil(), "zone_id": Uuid::nil(), "name": "go.example.com",
407 "type": "CNAME", "ttl": 300, "data": "edge.example.net.", "managed_by": m,
408 "created_at": 0, "updated_at": 0
409 }))
410 .unwrap()
411 })
412 .collect();
413 RecordSet {
414 zone_id: Uuid::nil(),
415 name: "go.example.com".into(),
416 record_type: "CNAME".into(),
417 ttl: 300,
418 values,
419 }
420 }
421
422 #[test]
423 fn group_values_does_not_rely_on_adjacency() {
424 let mut values = group(&[None, None]).values;
425 let mut other = values[0].clone();
426 other.record_type = "TXT".into();
427 values.insert(1, other);
428 let groups = group_values(values);
429 assert_eq!(groups.len(), 2);
430 assert_eq!(groups[0].record_type, "CNAME");
431 assert_eq!(groups[0].values.len(), 2);
432 assert_eq!(groups[1].values.len(), 1);
433 }
434
435 #[test]
436 fn locked_groups_are_not_removable() {
437 assert!(check_group_removable(&group(&[None])).is_ok());
438 assert!(check_group_removable(&group(&[Some("System")])).is_ok());
439 assert!(check_group_removable(&group(&[None, Some("Redirect")])).is_err());
440 assert!(check_group_removable(&group(&[Some("Acme")])).is_err());
441 assert!(check_group_removable(&group(&[Some("Future")])).is_err());
443 }
444}