Skip to main content

cloud/envoy/
dns_record.rs

1//! `dns.*` verb signatures — initial catalog (R409-T6).
2//!
3//! Three verbs that cover the DNS plane with Cloudflare as the exemplar
4//! tier-S provider (W144 §"dns.* — name resolution"):
5//!
6//! - `dns.record.upsert` — create or update a DNS record in a named zone
7//! - `dns.record.delete` — remove matching records from a zone
8//! - `dns.zone.list`    — enumerate accessible zones
9//!
10//! Zone resolution is by apex name (e.g. `"yah.dev"`), not by provider-issued
11//! zone ID — the adapter owns the name→id lookup so callers stay
12//! provider-agnostic. The `type` field follows the RFC 1035 convention
13//! (uppercase strings: `"A"`, `"CNAME"`, `"TXT"`, etc.).
14//!
15//! @yah:ticket(R859-F1, "Domain reconciler arm for front_door = \"passway\": render A records from the ingress plan via dns.* verbs, retire cf-apex-mode.sh as the flip mechanism")
16//! @yah:at(2026-09-04T19:06:41Z)
17//! @yah:status(open)
18//! @yah:assignee(agent:user-custom-char-gul2)
19//! @yah:parent(R859)
20//! @yah:next("domain.rs header says it plainly: front_door = \"passway\" 'has no reconciler here yet'. Build the arm: domain manifest + IngressPlan.front_doors → the set of public IPs of machines carrying that edge → dns.record.upsert (DNS-only, proxied=false) through the provider-agnostic dns.* verbs. Idempotent, list-first, like ensure_r2_custom_domain.")
21//! @yah:next("This dissolves the two-source front_door flip: the field in domains/*.toml becomes the single source and the reconciler renders it, so a flip is one line + apply instead of cf-apex-mode.sh + a manual TOML edit kept honest only by the publish beacon after the fact (it cost 19 days once, R330-B36, and 4 more, R703-B4).")
22//! @yah:next("Growing the public fleet organically falls out: adding a machine with the public-ip taint to an edge's machines list adds its A record on the next apply; removing it withdraws.")
23//! @yah:next("Keep cf-apex-mode.sh as break-glass (worker/orange flip under attack per W267 tier ladder) — retire it as the routine mechanism, don't delete it.")
24//! @yah:next("Tier: Wizard — new reconciler arm with provider seam, apply/validate wiring, and a live-DNS blast radius that needs careful idempotence tests.")
25
26use serde::{Deserialize, Serialize};
27
28use super::{InternalVerb, VerbCategory};
29
30// ── dns.record.upsert ─────────────────────────────────────────────────────
31
32/// Marker type for the `dns.record.upsert` verb.
33pub struct DnsRecordUpsert;
34
35/// Request body for `dns.record.upsert`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
38pub struct DnsRecordUpsertInput {
39    /// Zone apex name, e.g. `"yah.dev"`. The adapter resolves it to a
40    /// provider-issued zone ID.
41    pub zone: String,
42    /// Fully-qualified record name, e.g. `"yubaba.yah.dev"`. Apex records
43    /// may also be passed as `"@"` — adapters normalise as needed.
44    pub name: String,
45    /// DNS record type (`"A"`, `"AAAA"`, `"CNAME"`, `"TXT"`, `"MX"`, …).
46    #[serde(rename = "type")]
47    pub record_type: String,
48    /// Record value: for CNAME the target hostname; for A/AAAA the IP; for
49    /// TXT the verbatim string content.
50    pub content: String,
51    /// TTL in seconds. `1` means "automatic" (effective TTL chosen by the
52    /// provider). Defaults to `1`.
53    #[serde(default = "ttl_auto")]
54    pub ttl: u32,
55    /// Route through Cloudflare's reverse proxy (orange-cloud). Only
56    /// meaningful on Cloudflare for A/AAAA/CNAME records; adapters for
57    /// other providers should ignore this field. Defaults to `false`.
58    #[serde(default)]
59    pub proxied: bool,
60}
61
62fn ttl_auto() -> u32 {
63    1
64}
65
66/// Response body for `dns.record.upsert`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
69pub struct DnsRecordUpsertOutput {
70    /// Provider-issued record ID. Stable for the lifetime of the record;
71    /// can be used in `dns.record.delete` to target a specific record by ID
72    /// instead of name+type once that verb shape grows an `id` field.
73    pub id: String,
74}
75
76impl InternalVerb for DnsRecordUpsert {
77    type Input = DnsRecordUpsertInput;
78    type Output = DnsRecordUpsertOutput;
79    const ID: &'static str = "dns.record.upsert";
80    const CATEGORY: VerbCategory = VerbCategory::Dns;
81}
82
83// ── dns.record.delete ─────────────────────────────────────────────────────
84
85/// Marker type for the `dns.record.delete` verb.
86pub struct DnsRecordDelete;
87
88/// Request body for `dns.record.delete`.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
91pub struct DnsRecordDeleteInput {
92    /// Zone apex name, e.g. `"yah.dev"`.
93    pub zone: String,
94    /// Record name to delete, e.g. `"yubaba.yah.dev"`.
95    pub name: String,
96    /// Filter by record type. When absent, all records matching `name` are
97    /// deleted regardless of type. Pass `"CNAME"` to delete only CNAME
98    /// records for the name, for example.
99    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
100    pub record_type: Option<String>,
101}
102
103/// Response body for `dns.record.delete`.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
106pub struct DnsRecordDeleteOutput {
107    /// Count of records actually deleted. `0` is not an error — the record
108    /// may already have been absent (idempotent).
109    pub deleted: u32,
110}
111
112impl InternalVerb for DnsRecordDelete {
113    type Input = DnsRecordDeleteInput;
114    type Output = DnsRecordDeleteOutput;
115    const ID: &'static str = "dns.record.delete";
116    const CATEGORY: VerbCategory = VerbCategory::Dns;
117}
118
119// ── dns.zone.list ─────────────────────────────────────────────────────────
120
121/// Marker type for the `dns.zone.list` verb.
122pub struct DnsZoneList;
123
124/// Request body for `dns.zone.list`. Empty — zone listing requires no
125/// parameters beyond the adapter's credential scope.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
128pub struct DnsZoneListInput {}
129
130/// One zone entry in the `dns.zone.list` response.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
133pub struct DnsZoneEntry {
134    /// Provider-issued zone ID. Opaque; stable within a provider.
135    pub id: String,
136    /// Zone apex name, e.g. `"yah.dev"`.
137    pub name: String,
138}
139
140/// Response body for `dns.zone.list`.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
143pub struct DnsZoneListOutput {
144    pub zones: Vec<DnsZoneEntry>,
145}
146
147impl InternalVerb for DnsZoneList {
148    type Input = DnsZoneListInput;
149    type Output = DnsZoneListOutput;
150    const ID: &'static str = "dns.zone.list";
151    const CATEGORY: VerbCategory = VerbCategory::Dns;
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn verb_ids_match_canonical_namespace() {
160        assert_eq!(DnsRecordUpsert::ID, "dns.record.upsert");
161        assert_eq!(DnsRecordDelete::ID, "dns.record.delete");
162        assert_eq!(DnsZoneList::ID, "dns.zone.list");
163        for id in [DnsRecordUpsert::ID, DnsRecordDelete::ID, DnsZoneList::ID] {
164            assert!(id.starts_with("dns."), "{id}");
165        }
166    }
167
168    #[test]
169    fn verbs_are_under_dns_category() {
170        assert_eq!(DnsRecordUpsert::CATEGORY, VerbCategory::Dns);
171        assert_eq!(DnsRecordDelete::CATEGORY, VerbCategory::Dns);
172        assert_eq!(DnsZoneList::CATEGORY, VerbCategory::Dns);
173    }
174
175    #[test]
176    fn upsert_input_defaults_ttl_to_auto_and_proxied_false() {
177        let wire = r#"{"zone":"yah.dev","name":"yubaba.yah.dev","type":"CNAME","content":"t.cfargotunnel.com"}"#;
178        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
179        assert_eq!(parsed.ttl, 1, "default TTL should be 1 (automatic)");
180        assert!(!parsed.proxied, "default proxied should be false");
181    }
182
183    #[test]
184    fn upsert_input_type_renamed_in_wire() {
185        let wire = r#"{"zone":"yah.dev","name":"a.yah.dev","type":"A","content":"1.2.3.4","ttl":300,"proxied":true}"#;
186        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
187        assert_eq!(parsed.record_type, "A");
188        assert_eq!(parsed.ttl, 300);
189        assert!(parsed.proxied);
190        // Verify the Rust field serializes back as "type".
191        let back = serde_json::to_value(&parsed).unwrap();
192        assert!(back.get("type").is_some(), "should serialize as 'type'");
193        assert!(
194            back.get("record_type").is_none(),
195            "should not serialize as 'record_type'"
196        );
197    }
198
199    #[test]
200    fn delete_input_type_optional() {
201        let with_type = r#"{"zone":"yah.dev","name":"old.yah.dev","type":"CNAME"}"#;
202        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_type).unwrap();
203        assert_eq!(parsed.record_type.as_deref(), Some("CNAME"));
204
205        let no_type = r#"{"zone":"yah.dev","name":"old.yah.dev"}"#;
206        let parsed: DnsRecordDeleteInput = serde_json::from_str(no_type).unwrap();
207        assert!(parsed.record_type.is_none());
208    }
209
210    #[test]
211    fn delete_input_omits_type_when_absent() {
212        let input = DnsRecordDeleteInput {
213            zone: "z".into(),
214            name: "n".into(),
215            record_type: None,
216        };
217        let wire = serde_json::to_value(&input).unwrap();
218        assert!(!wire.as_object().unwrap().contains_key("type"));
219    }
220
221    #[test]
222    fn delete_output_zero_is_not_an_error() {
223        let out = DnsRecordDeleteOutput { deleted: 0 };
224        let wire = serde_json::to_value(&out).unwrap();
225        assert_eq!(wire["deleted"], 0);
226    }
227
228    #[test]
229    fn zone_list_input_serializes_to_empty_object() {
230        let wire = serde_json::to_value(DnsZoneListInput::default()).unwrap();
231        assert_eq!(wire, serde_json::json!({}));
232    }
233
234    #[test]
235    fn zone_list_output_round_trips() {
236        let out = DnsZoneListOutput {
237            zones: vec![
238                DnsZoneEntry {
239                    id: "z1".into(),
240                    name: "yah.dev".into(),
241                },
242                DnsZoneEntry {
243                    id: "z2".into(),
244                    name: "noisetable.com".into(),
245                },
246            ],
247        };
248        let wire = serde_json::to_string(&out).unwrap();
249        let back: DnsZoneListOutput = serde_json::from_str(&wire).unwrap();
250        assert_eq!(back.zones.len(), 2);
251        assert_eq!(back.zones[0].name, "yah.dev");
252    }
253
254    #[cfg(feature = "json-schema")]
255    #[test]
256    fn verbs_emit_schemas_via_for_verb() {
257        use super::super::VerbDescriptor;
258
259        let upsert = VerbDescriptor::for_verb::<DnsRecordUpsert>();
260        assert_eq!(upsert.id, "dns.record.upsert");
261        assert!(upsert.input_schema.to_string().contains("content"));
262
263        let delete = VerbDescriptor::for_verb::<DnsRecordDelete>();
264        assert_eq!(delete.id, "dns.record.delete");
265        assert!(delete.output_schema.to_string().contains("deleted"));
266
267        let list = VerbDescriptor::for_verb::<DnsZoneList>();
268        assert_eq!(list.id, "dns.zone.list");
269        assert!(list.output_schema.to_string().contains("zones"));
270    }
271}