Skip to main content

dinio_sdk/ops/
records.rs

1//! Record operations addressed the way people think about them: zone, relative name, type
2//! and data, instead of zone uuid and value uuid.
3
4use 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
15/// TTL for a new group when the caller does not give one.
16pub const DEFAULT_TTL: u32 = 3600;
17
18/// How to pick one value of a group.
19#[derive(Debug, Clone)]
20pub enum ValueSelector {
21    /// By data, compared with Core's canonical form ([`crate::ops::same_data`]).
22    Data(String),
23    /// By the value's uuid; for data the comparison cannot match.
24    Id(Uuid),
25}
26
27/// Options for [`add_value`].
28#[derive(Debug, Clone, Default)]
29pub struct AddValue {
30    /// Record data, zone-file style (`10 mail.example.com`).
31    pub data: String,
32    /// For a new group; defaults to [`DEFAULT_TTL`]. For an existing group it must equal the
33    /// group's TTL (change that with `set_ttl`), or be `None`.
34    pub ttl: Option<u32>,
35    /// See [`RecordValue::geo_routing`].
36    pub geo_routing: Option<bool>,
37    /// See [`RecordValue::balance_ratio`].
38    pub balance_ratio: Option<u16>,
39    /// See [`NewRecordValue::host_health`]; `Auto` is not allowed here.
40    pub host_health: Option<HostHealthMode>,
41}
42
43/// What [`add_value`] did.
44#[derive(Debug, Clone)]
45pub struct Added {
46    /// The new value, as Core stored it (canonical data).
47    pub value: RecordValue,
48    /// TTL of the group the value is in now.
49    pub ttl: u32,
50    /// Whether the value started a new group.
51    pub new_group: bool,
52}
53
54/// Groups a flat list of values (as from [`Records::list_all`](crate::api::Records::list_all))
55/// into RRsets by (zone, name, type). Groups keep the order in which they first appear,
56/// values the order they had; a group's TTL is taken from its first value (Core keeps them
57/// equal). Sends nothing.
58///
59/// # Examples
60///
61/// ```
62/// use dinio_sdk::models::RecordValue;
63/// use dinio_sdk::ops::group_values;
64///
65/// let value = |name: &str, data: &str| -> RecordValue {
66///     serde_json::from_value(serde_json::json!({
67///         "uuid": dinio_sdk::Uuid::nil(), "zone_id": dinio_sdk::Uuid::nil(),
68///         "name": name, "type": "A", "ttl": 300, "data": data,
69///         "created_at": 0, "updated_at": 0
70///     }))
71///     .unwrap()
72/// };
73/// let groups = group_values(vec![
74///     value("www.example.com", "192.0.2.1"),
75///     value("api.example.com", "192.0.2.9"),
76///     value("www.example.com", "192.0.2.2"),
77/// ]);
78/// assert_eq!(groups.len(), 2);
79/// assert_eq!(groups[0].name, "www.example.com");
80/// assert_eq!(groups[0].values.len(), 2);
81/// assert_eq!(groups[0].ttl, 300);
82/// ```
83pub 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
105/// The group (RRset) at a full owner name and type, or `None` if it does not exist.
106///
107/// # Errors
108///
109/// Any error of [`Records::get`](crate::api::Records::get) except
110/// [`ErrorCode::NotFound`], which becomes `Ok(None)`.
111pub 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
129/// Adds a value to a group, creating the group if needed. `name` is the full owner name
130/// ([`crate::ops::qualify_name`]).
131///
132/// A new group gets `add.ttl` or [`DEFAULT_TTL`]. An existing group keeps its TTL, so
133/// `add.ttl` must then be `None` or equal to it.
134///
135/// # Errors
136///
137/// * [`Error::InvalidInput`] if `add.ttl` differs from an existing group's TTL, or
138///   `add.host_health` is `Auto` (create a health check for the value instead).
139/// * Any error of [`Records::create`](crate::api::Records::create), e.g. invalid data or
140///   a duplicate value.
141///
142/// # Examples
143///
144/// ```no_run
145/// use dinio_sdk::ops::{self, AddValue};
146///
147/// # async fn example(client: &dinio_sdk::Client, zone: &dinio_sdk::models::Zone) -> dinio_sdk::Result<()> {
148/// let name = ops::qualify_name("@", &zone.name)?;
149/// let add = AddValue { data: "10 mail.example.com".into(), ..Default::default() };
150/// let added = ops::add_value(client, zone, &name, "MX", add).await?;
151/// if added.new_group {
152///     println!("new MX group with TTL {}", added.ttl);
153/// }
154/// # Ok(())
155/// # }
156/// ```
157pub 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
209/// The group and the one value `selector` points at.
210///
211/// # Errors
212///
213/// [`Error::NotFound`] if the group does not exist or has no such value; the message
214/// lists the existing values.
215///
216/// # Examples
217///
218/// ```no_run
219/// use dinio_sdk::ops::{self, ValueSelector};
220///
221/// # async fn example(client: &dinio_sdk::Client, zone: &dinio_sdk::models::Zone) -> dinio_sdk::Result<()> {
222/// // Data is matched the way Core stores it: this finds "target.example.com.".
223/// let selector = ValueSelector::Data("Target.Example.com".into());
224/// let (group, value) =
225///     ops::find_value(client, zone, "www.example.com", "CNAME", &selector).await?;
226/// println!("{} (TTL {}) is {}", value.data, group.ttl, value.uuid);
227/// # Ok(())
228/// # }
229/// ```
230pub 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
240/// Like [`find_value`], with the value's `geo_bans` filled in, as
241/// [`add_geo_bans`](crate::ops::add_geo_bans) and
242/// [`select_geo_bans`](crate::ops::select_geo_bans) need.
243///
244/// # Errors
245///
246/// As [`find_value`].
247pub 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
292/// Changes the value `selector` points at.
293///
294/// # Errors
295///
296/// * [`Error::InvalidInput`] if `patch` changes nothing.
297/// * [`Error::NotFound`] as in [`find_value`].
298/// * Any error of [`Records::update`](crate::api::Records::update).
299pub 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
314/// Removes the value `selector` points at, with its geo bans and health check; returns
315/// it. The group goes with its last value.
316///
317/// # Errors
318///
319/// * [`Error::NotFound`] as in [`find_value`].
320/// * Any error of [`Records::delete`](crate::api::Records::delete).
321pub 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
333/// Whether [`remove_group`] may remove the group: refuses groups with values Core keeps
334/// for a redirect or a certificate issuance ([`RecordValue::is_locked`]). Sends nothing.
335///
336/// # Errors
337///
338/// [`Error::InvalidInput`] naming the locked values and their manager.
339pub 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
364/// Removes every value of the group (and so the group). Not atomic: on an error midway the
365/// values already removed stay removed. Returns the removed values.
366///
367/// A group with values Core keeps for another resource is refused before anything is
368/// removed (see [`check_group_removable`]), instead of failing halfway on Core's 409
369/// `MANAGED_RECORD`.
370///
371/// # Errors
372///
373/// * [`Error::InvalidInput`] as in [`check_group_removable`].
374/// * Any error of [`Records::delete`](crate::api::Records::delete).
375pub 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        // An unknown manager counts as locked.
442        assert!(check_group_removable(&group(&[Some("Future")])).is_err());
443    }
444}