Skip to main content

cloud/envoy/
dns_record.rs

1//! `dns.*` verb signatures — initial catalog (R409-T6).
2//!
3//! Four 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.list`   — read the records in a zone (R859-F1)
8//! - `dns.record.delete` — remove matching records from a zone
9//! - `dns.zone.list`    — enumerate accessible zones
10//!
11//! `dns.record.list` landed with the first production consumer of this
12//! catalog — [`crate::reconciler::ensure_passway_apex`], which reconciles a
13//! `front_door = "passway"` apex from declared intent. A reconciler cannot be
14//! idempotent without reading current state first, and until R859-F1 there was
15//! no read verb at all: `dns.zone.list` enumerates zones, not records.
16//!
17//! ## Multi-valued RRsets (R859-F1)
18//!
19//! A round-robin apex carries several A records under one name, so
20//! `(name, type)` is **not** a unique key there. Two optional fields exist for
21//! exactly that shape and default to the pre-R859 behaviour:
22//!
23//! - [`DnsRecordUpsertInput::match_content`] — match the record to update by
24//!   `(name, type, content)` instead of `(name, type)`. Without it, upserting
25//!   a second A record at an apex that already has one *rewrites the first*
26//!   rather than adding a sibling, silently collapsing the round-robin to one
27//!   origin.
28//! - [`DnsRecordDeleteInput::content`] — delete only the records carrying that
29//!   exact value, so pruning one withdrawn origin does not take its live
30//!   siblings with it.
31//!
32//! Zone resolution is by apex name (e.g. `"yah.dev"`), not by provider-issued
33//! zone ID — the adapter owns the name→id lookup so callers stay
34//! provider-agnostic. The `type` field follows the RFC 1035 convention
35//! (uppercase strings: `"A"`, `"CNAME"`, `"TXT"`, etc.).
36//!
37//! @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")
38//! @yah:at(2026-09-05T09:31:37Z)
39//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
40//! @yah:parent(R859)
41//! @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.")
42//! @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).")
43//! @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.")
44//! @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.")
45//! @yah:next("Tier: Wizard — new reconciler arm with provider seam, apply/validate wiring, and a live-DNS blast radius that needs careful idempotence tests.")
46//! @yah:handoff("LANDED. New passway arm in oss/yubaba/crates/cloud/src/reconciler/domain.rs, house pure-planner/IO-applier shape: public_origins() (collated front-door machine names -> machines carrying the public-ip taint -> connect.address, parsed as a public Ipv4Addr), plan_domain_passway() (front_door guard, sort+dedup by address, apex zone via parent_zone_name), diff_apex_records() (upsert/prune against the live A set), deploy_domain_passway() (list-first, skip-when-converged, upsert BEFORE prune, proxied=false always), and ensure_passway_apex() as the entry point yah cloud apply calls. Exported from reconciler/mod.rs. Apply wiring: app/yah/cli/src/cloud.rs:10944 `if dom.front_door != BucketDirect { Skipped }` became a 3-arm match — bucket-direct and passway both reconcile now, worker stays a Skip with a reason naming the Worker pass. domain.rs header sentence 'has no reconciler here yet' replaced.")
47//! @yah:handoff("DNS PRIMITIVES (decision 1, plus two additive fields that decision needed). New verb dns.record.list in envoy/dns_record.rs (zone + optional name + optional type -> records with id/name/type/content/ttl/proxied), registered in envoy.rs known_verb_descriptors + the ID assertion list (count 11 -> 12), implemented in provider/cloudflare_envoy.rs against GET /zones/{id}/dns_records via a new CloudflareClient::list_dns_records + pub DnsRecordDetail struct. TWO EXTRA FIELDS WERE UNAVOIDABLE and are the discovered work of this ticket: (a) DnsRecordUpsertInput.match_content (default false = pre-R859 behaviour) — CloudflareClient::upsert_dns_record matches on (name,type) and updates the FIRST match, so upserting a 2nd A record at an apex that already has one REWRITES the first and silently collapses the round-robin to one origin; new delegating CloudflareClient::upsert_dns_record_matching keys on (name,type,content) when set. (b) DnsRecordDeleteInput.content (Option, mirrors the existing record_type filter) — deleting 'the A records at yah.dev' to prune ONE withdrawn origin would take the live siblings with it; new delegating CloudflareClient::delete_dns_records_matching. Both old public signatures are unchanged and delegate, so nothing outside this ticket had to move.")
48//! @yah:handoff("TESTS + BASELINE. Baseline measured BEFORE editing at tree anchor 4bed91fe: `cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema` = lib 1104 passed / 0 failed / 4 ignored, tests/main.rs 3/0/1, pond_smoke 2/0, doc-tests 0/0/1. After: lib 1121 passed / 0 failed / 4 ignored (+17), other three targets unchanged. Also green: `cargo test -p yah --lib` 1442/0, `cargo test -p yah-agent-tools --lib` 1221/0, `cargo build --workspace` EXIT=0. New pure-planner tests in domain.rs's test module cover every case the dispatch asked for: converged set is a no-op; added machine yields exactly one added record and no prune; removed machine yields exactly one prune (type A) and leaves the survivor untouched; non-public address (100.64/10 tailnet, 10/8, 192.168/16, 127.0.0.1) and unparseable address are errors naming the machine; EMPTY front-door set is an error, not a wipe (`refusing to render an empty apex`). Plus: taint filter keeps only public-ip machines, plan sorts+dedups by address, front_door guard, a proxied record at a desired address is rewritten (not left orange, not pruned), full origin swap produces upsert+prune so the ordering contract holds.")
49//! @yah:handoff("ALSO TOUCHED (discovered work, all forced by the two new verb fields). app/yah/desktop/src/cloudflare.rs:210 constructs DnsRecordUpsertInput literally for tunnel CNAME sync — added `match_content: false` with a comment saying why a tunnel hostname is single-valued. crates/yah/agent-tools/src/envoy_tools.rs READ_VERB_IDS gained \"dns.record.list\": it is a pure read, and leaving it out would have had the agent tool surface classify it as a write and gate it behind write approval. scripts/cf-apex-mode.sh: header rewritten per decision 10 — every behaviour KEPT, but it is now labelled break-glass only, with the four jobs that remain its alone enumerated (worker rollback, orange flip under attack, `status` read, the CF-1052 R2-custom-domain diagnostic) and a note that a stale front_door will now actively UNDO an un-declared flip on the next apply rather than merely disagreeing with it. No manifest fields added (decisions 2/3/8), no new global lint in validate.rs (decision 4), R2 and worker arms untouched (decision 9), no schema regen needed (no config type moved; .yah/schema/ holds no envoy verb schemas).")
50//! @yah:assumes("Decision 5, recorded as instructed: the apex round-robins across EVERY node in collate_workspace_ingress(...).collation.front_doors whose provider is IngressProvider::Passway and that carries the public-ip taint. It does NOT filter by whether that node actually serves the specific domain's [[routes]]. This matches scripts/cf-apex-mode.sh's CF_ORIGIN_IP list semantics, and is correct while the camp has exactly one passway domain (yah.dev) and one passway edge. A second passway domain fronted by a different subset of nodes would get the union, not its own subset — that is the assumption to revisit first.")
51//! @yah:assumes("deploy_domain_passway's I/O path has NOT been exercised against a live Cloudflare account (no creds in this session) — same status the worker arm's doc comment records for itself. The decision logic is fully covered offline; treat the first real `yah cloud apply` against yah.dev as the acceptance test, and run it while the current apex A records are known so a wrong prune is visible immediately.")
52//! @yah:gotcha("Peer activity on the shared tree during this session, none of it mine and none needing action: (1) `cargo build --workspace` was transiently RED mid-session on yah-workload-spec (E0425 DURABILITY_ENGINE_ANNOTATION / DURABILITY_SUBJECTS_ANNOTATION undefined) from a peer's half-landed 166-line addition to oss/yah-base/crates/workload-spec/src/lib.rs — it went green on its own once they finished, and the final workspace build is EXIT=0. (2) app/yah/cli/src/cloud.rs carries two hunks that are not mine: the removal of the R856-F8 annotation block from the module header (an archive by another session), separate from my hunk at the domain apply loop. No collision — different regions of the file.")
53//! @yah:cleanup("Followup CANDIDATE, deliberately not filed (decision 8 said mention, do not file): the domain manifest has no `use = \"<id>\"` provider slot, so the apply site still hardcodes DOMAIN_CF_PROVIDER = \"cloudflare\" (app/yah/cli/src/cloud.rs:10939). The passway arm is already provider-agnostic ABOVE that line — every read and write goes through the dns.* envoy verbs — so giving domains a provider slot is now purely a config-plumbing change, and it is what would let a second DNS provider (the .yah/envoys/digitalocean sketch exists) serve an apex.")
54//! @yah:cleanup("parent_zone_name's two-label heuristic is REUSED here and is fine for this ticket (yah.dev is a two-label apex, so zone == name), but the limitation is unchanged and still noted at domain.rs's deploy_domain_worker caveat: a passway domain under an alias tier (net.yah.dev / com.yah.dev, which are their own CF zones) would resolve to the wrong zone. Not fixed here per the dispatch; the upgrade path is the longest-suffix match against dns.zone.list that parent_zone_name's own doc already names — and dns.zone.list is right there in the catalog now.")
55//! @yah:verify("cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema  (lib 1121 passed / 0 failed vs baseline 1104/0 at anchor 4bed91fe)")
56//! @yah:verify("cargo build --workspace && cargo test -p yah --lib && cargo test -p yah-agent-tools --lib  (all EXIT=0; 1442/0 and 1221/0)")
57//! @yah:verify("LIVE ACCEPTANCE, not yet run and the one thing left: `yah cloud apply` against yah.dev with the current apex A records recorded first (scripts/cf-apex-mode.sh status), then a second apply to confirm it writes nothing the second time. The I/O path has no live-credential coverage in this session.")
58//! @yah:handoff("PRUNE GATE (Leader's verification finding, fixed). ensure_passway_apex never inspected report.problems, and collate_workspace_ingress returns Ok while SKIPPING an edge whose declaration fails to plan — so a passway edge with a config typo drops its machine from front_doors, which from inside the plan is indistinguishable from an operator withdrawal, and the arm would have pruned that origin's live A record. A typo becoming a DNS withdrawal. Fix, per the decision handed down: fail-closed on withdrawal, fail-open on addition. DomainPasswayPlan gained `origins_complete: bool` (plan_domain_passway takes it as a third arg); ensure_passway_apex sets it from `report.problems.is_empty()` and warns once per problem via IngressProblem::message(). diff_apex_records routes surplus records into a new ApexRecordDiff.withheld_prune instead of prune when the flag is false — upserts are untouched, so growing the fleet still works through another service's broken declaration, and problems never fail the arm. deploy_domain_passway warns naming the withheld records BEFORE the converged early-return (the record stays live either way), and PasswayApexOutcome.withheld_prune carries them to the apply site, which prints `KEPT A <domain> -> <ip> (ingress collation reported problems — run yah cloud validate)`. The empty-set guard stays ahead of the gate: an empty origin set is an error whether or not the collation was clean, so it cannot degrade into a silent everything-withheld apply. deploy_domain_passway's doc comment now states the contract as a third invariant beside list-first and upsert-before-prune.")
59//! @yah:handoff("NUMBERS AFTER THE PRUNE GATE, superseding the counts in the TESTS + BASELINE entry above: yah-cloud lib 1124 passed / 0 failed / 4 ignored (was 1121 at the first pass, 1104 at the pre-edit baseline measured at anchor 4bed91fe); tests/main.rs 3/0/1, pond_smoke 2/0, doc-tests 0/0/1 all unchanged; `cargo build --workspace` EXIT=0. Three tests added for the gate: an incomplete collation upserts but withholds every prune (with a clean-collation control on the same inputs proving the gate is what changed the outcome); the empty-origin-set error still fires on an incomplete collation, so the louder failure stays ahead of the quieter one; and a withheld-prune-only diff reports is_converged (no write to make) while still carrying the withheld record. Leader's independent pass measured `cargo test -p yah --lib` at 1444/0 against my 1442/0 — peer drift on the shared tree, not a discrepancy in this work.")
60//! @yah:assumes("Both earlier assumes still hold as written; this one sharpens the first. The union-not-subset assumption above and the new prune gate come due at the SAME moment — the second passway edge. Today `report.problems` non-empty plus one passway edge collapses into the guarded empty-set error, so the gate is inert; with two edges it becomes the thing standing between a config typo and a live DNS withdrawal, and the union behaviour becomes the thing deciding which origins a second passway domain publishes. Whoever adds the second passway edge should re-read both together rather than either alone.")
61//! @yah:handoff("LEADER SIGN-OFF (independently verified, not taken on the courier's word). Two verification passes by a separate session re-ran the builds and traced the code. Pass 1 confirmed: upsert loop strictly precedes prune loop with provably disjoint sets; empty-origin-set is a hard error, not a wipe; the prune passes BOTH record_type Some(\"A\") and content Some(ip) through cloudflare_envoy.rs into the client-side filter, so MX/TXT/AAAA/CNAME are unreachable; the pre-change upsert_dns_record did take find_dns_record's first (name,type) match, match_content defaults false and only the passway arm sets true; proxied is always false with an existing orange record at a desired address rewritten rather than left; old public signatures delegate unchanged with the sole live caller untouched; dns.record.list registered in the catalog with the ID-assertion count 11 -> 12 and listed in READ_VERB_IDS so it is not gated as a write; the apply site is a real 3-arm match with Worker still a Skip; cf-apex-mode.sh has exactly one comment-only hunk. Pass 2 (after the prune-gate increment) re-confirmed all of it plus the gate itself.")
62//! @yah:handoff("Tree anchor at handoff: f086233d6b092de2f32cafad5e0010494078269c — the shared tree as I left it. Diff against it (`git diff f086233d6b092de2f32cafad5e0010494078269c..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
63//! @yah:handoff("DISCOVERED DEFECT, found by leader verification and fixed in-run rather than filed. The first-pass arm never inspected report.problems, and collate_workspace_ingress returns Ok while SKIPPING an edge whose declaration fails to plan (validate.rs:870-879) — so a config typo in a passway declaration would drop that machine from front_doors, the arm would read the absence as an operator withdrawal, and it would PRUNE a live A record. A typo becoming a DNS withdrawal is exactly the failure class this ticket exists to remove. Directed fix, landed: gate the PRUNE, not the upsert. DomainPasswayPlan.origins_complete is set from report.problems.is_empty() at its single non-test construction site (domain.rs:671, so the flag cannot be forged); when false, surplus records route into ApexRecordDiff.withheld_prune instead of prune. Upserts are computed above the branch and are unaffected, so growing the fleet keeps working under a broken unrelated declaration; nothing is ever withdrawn from an untrusted picture. Fail-closed on withdrawal, fail-open on addition. The arm does NOT fail on unrelated ingress problems — one broken service declaration must not block DNS apply. warn! naming each withheld record fires BEFORE the converged early-return, so a no-op apply still tells the operator, and PasswayApexOutcome carries them to app/yah/cli/src/cloud.rs:11002-11008 as a KEPT line printed outside the is_noop branch.")
64
65use serde::{Deserialize, Serialize};
66
67use super::{InternalVerb, VerbCategory};
68
69// ── dns.record.upsert ─────────────────────────────────────────────────────
70
71/// Marker type for the `dns.record.upsert` verb.
72pub struct DnsRecordUpsert;
73
74/// Request body for `dns.record.upsert`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
77pub struct DnsRecordUpsertInput {
78    /// Zone apex name, e.g. `"yah.dev"`. The adapter resolves it to a
79    /// provider-issued zone ID.
80    pub zone: String,
81    /// Fully-qualified record name, e.g. `"yubaba.yah.dev"`. Apex records
82    /// may also be passed as `"@"` — adapters normalise as needed.
83    pub name: String,
84    /// DNS record type (`"A"`, `"AAAA"`, `"CNAME"`, `"TXT"`, `"MX"`, …).
85    #[serde(rename = "type")]
86    pub record_type: String,
87    /// Record value: for CNAME the target hostname; for A/AAAA the IP; for
88    /// TXT the verbatim string content.
89    pub content: String,
90    /// TTL in seconds. `1` means "automatic" (effective TTL chosen by the
91    /// provider). Defaults to `1`.
92    #[serde(default = "ttl_auto")]
93    pub ttl: u32,
94    /// Route through Cloudflare's reverse proxy (orange-cloud). Only
95    /// meaningful on Cloudflare for A/AAAA/CNAME records; adapters for
96    /// other providers should ignore this field. Defaults to `false`.
97    #[serde(default)]
98    pub proxied: bool,
99    /// Match the record to replace by `(name, type, content)` rather than
100    /// `(name, type)` — R859-F1.
101    ///
102    /// `false` (the default, and the only shape before R859) is right for a
103    /// single-valued name: "whatever CNAME is at `cdn.yah.dev`, make it point
104    /// here". `true` is required for a **multi-valued RRset** such as a
105    /// round-robin apex, where several A records legitimately share
106    /// name+type: it turns the verb into ensure-this-exact-record-exists, so
107    /// building a 2-origin apex is two upserts rather than one upsert that
108    /// overwrites the other origin.
109    #[serde(default)]
110    pub match_content: bool,
111}
112
113fn ttl_auto() -> u32 {
114    1
115}
116
117/// Response body for `dns.record.upsert`.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
120pub struct DnsRecordUpsertOutput {
121    /// Provider-issued record ID. Stable for the lifetime of the record;
122    /// can be used in `dns.record.delete` to target a specific record by ID
123    /// instead of name+type once that verb shape grows an `id` field.
124    pub id: String,
125}
126
127impl InternalVerb for DnsRecordUpsert {
128    type Input = DnsRecordUpsertInput;
129    type Output = DnsRecordUpsertOutput;
130    const ID: &'static str = "dns.record.upsert";
131    const CATEGORY: VerbCategory = VerbCategory::Dns;
132}
133
134// ── dns.record.delete ─────────────────────────────────────────────────────
135
136/// Marker type for the `dns.record.delete` verb.
137pub struct DnsRecordDelete;
138
139/// Request body for `dns.record.delete`.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
142pub struct DnsRecordDeleteInput {
143    /// Zone apex name, e.g. `"yah.dev"`.
144    pub zone: String,
145    /// Record name to delete, e.g. `"yubaba.yah.dev"`.
146    pub name: String,
147    /// Filter by record type. When absent, all records matching `name` are
148    /// deleted regardless of type. Pass `"CNAME"` to delete only CNAME
149    /// records for the name, for example.
150    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
151    pub record_type: Option<String>,
152    /// Filter by exact record value — R859-F1. When absent, every record
153    /// matching `name` (and `record_type`) is deleted.
154    ///
155    /// Present so a caller pruning one member of a multi-valued RRset can name
156    /// it: deleting "the A records at `yah.dev`" would take the surviving
157    /// origins down with the withdrawn one.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub content: Option<String>,
160}
161
162/// Response body for `dns.record.delete`.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
165pub struct DnsRecordDeleteOutput {
166    /// Count of records actually deleted. `0` is not an error — the record
167    /// may already have been absent (idempotent).
168    pub deleted: u32,
169}
170
171impl InternalVerb for DnsRecordDelete {
172    type Input = DnsRecordDeleteInput;
173    type Output = DnsRecordDeleteOutput;
174    const ID: &'static str = "dns.record.delete";
175    const CATEGORY: VerbCategory = VerbCategory::Dns;
176}
177
178// ── dns.record.list ───────────────────────────────────────────────────────
179
180/// Marker type for the `dns.record.list` verb (R859-F1).
181pub struct DnsRecordList;
182
183/// Request body for `dns.record.list`.
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
186pub struct DnsRecordListInput {
187    /// Zone apex name, e.g. `"yah.dev"`.
188    pub zone: String,
189    /// Restrict to records with this exact name, e.g. `"yah.dev"` for the
190    /// apex. When absent, every record in the zone is returned.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub name: Option<String>,
193    /// Restrict to one record type (`"A"`, `"CNAME"`, …). When absent, every
194    /// type is returned — which is why a caller that only owns the A records
195    /// at a name must pass `"A"`: MX and TXT live at the apex too.
196    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
197    pub record_type: Option<String>,
198}
199
200/// One live DNS record.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
203pub struct DnsRecordEntry {
204    /// Provider-issued record ID — the same identifier
205    /// [`DnsRecordUpsertOutput::id`] returns.
206    pub id: String,
207    /// Fully-qualified record name, e.g. `"yah.dev"`.
208    pub name: String,
209    /// DNS record type (`"A"`, `"CNAME"`, `"TXT"`, …).
210    #[serde(rename = "type")]
211    pub record_type: String,
212    /// Record value: the IP for A/AAAA, the target hostname for CNAME, …
213    pub content: String,
214    /// TTL in seconds; `1` means the provider chooses.
215    #[serde(default = "ttl_auto")]
216    pub ttl: u32,
217    /// Whether the provider proxies this record (Cloudflare orange-cloud).
218    /// Always `false` from providers with no such concept.
219    #[serde(default)]
220    pub proxied: bool,
221}
222
223/// Response body for `dns.record.list`.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
226pub struct DnsRecordListOutput {
227    /// Matching records, in provider order. An empty list is not an error.
228    pub records: Vec<DnsRecordEntry>,
229}
230
231impl InternalVerb for DnsRecordList {
232    type Input = DnsRecordListInput;
233    type Output = DnsRecordListOutput;
234    const ID: &'static str = "dns.record.list";
235    const CATEGORY: VerbCategory = VerbCategory::Dns;
236}
237
238// ── dns.zone.list ─────────────────────────────────────────────────────────
239
240/// Marker type for the `dns.zone.list` verb.
241pub struct DnsZoneList;
242
243/// Request body for `dns.zone.list`. Empty — zone listing requires no
244/// parameters beyond the adapter's credential scope.
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
247pub struct DnsZoneListInput {}
248
249/// One zone entry in the `dns.zone.list` response.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
252pub struct DnsZoneEntry {
253    /// Provider-issued zone ID. Opaque; stable within a provider.
254    pub id: String,
255    /// Zone apex name, e.g. `"yah.dev"`.
256    pub name: String,
257}
258
259/// Response body for `dns.zone.list`.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
262pub struct DnsZoneListOutput {
263    pub zones: Vec<DnsZoneEntry>,
264}
265
266impl InternalVerb for DnsZoneList {
267    type Input = DnsZoneListInput;
268    type Output = DnsZoneListOutput;
269    const ID: &'static str = "dns.zone.list";
270    const CATEGORY: VerbCategory = VerbCategory::Dns;
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn verb_ids_match_canonical_namespace() {
279        assert_eq!(DnsRecordUpsert::ID, "dns.record.upsert");
280        assert_eq!(DnsRecordList::ID, "dns.record.list");
281        assert_eq!(DnsRecordDelete::ID, "dns.record.delete");
282        assert_eq!(DnsZoneList::ID, "dns.zone.list");
283        for id in [
284            DnsRecordUpsert::ID,
285            DnsRecordList::ID,
286            DnsRecordDelete::ID,
287            DnsZoneList::ID,
288        ] {
289            assert!(id.starts_with("dns."), "{id}");
290        }
291    }
292
293    #[test]
294    fn verbs_are_under_dns_category() {
295        assert_eq!(DnsRecordUpsert::CATEGORY, VerbCategory::Dns);
296        assert_eq!(DnsRecordList::CATEGORY, VerbCategory::Dns);
297        assert_eq!(DnsRecordDelete::CATEGORY, VerbCategory::Dns);
298        assert_eq!(DnsZoneList::CATEGORY, VerbCategory::Dns);
299    }
300
301    #[test]
302    fn upsert_input_defaults_ttl_to_auto_and_proxied_false() {
303        let wire = r#"{"zone":"yah.dev","name":"yubaba.yah.dev","type":"CNAME","content":"t.cfargotunnel.com"}"#;
304        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
305        assert_eq!(parsed.ttl, 1, "default TTL should be 1 (automatic)");
306        assert!(!parsed.proxied, "default proxied should be false");
307        assert!(
308            !parsed.match_content,
309            "default must stay match-by-(name,type) — R859-F1 added the field"
310        );
311    }
312
313    /// R859-F1: a round-robin apex needs upserts keyed on content, otherwise
314    /// the second origin overwrites the first.
315    #[test]
316    fn upsert_input_accepts_match_content() {
317        let wire = r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"51.81.85.145","match_content":true}"#;
318        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
319        assert!(parsed.match_content);
320    }
321
322    /// R859-F1: pruning one withdrawn origin must not be expressible only as
323    /// "delete the A records at the apex".
324    #[test]
325    fn delete_input_content_filter_is_optional_and_omitted_when_absent() {
326        let with_content =
327            r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"15.204.89.240"}"#;
328        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_content).unwrap();
329        assert_eq!(parsed.content.as_deref(), Some("15.204.89.240"));
330
331        let bare = DnsRecordDeleteInput {
332            zone: "yah.dev".into(),
333            name: "yah.dev".into(),
334            record_type: Some("A".into()),
335            content: None,
336        };
337        let wire = serde_json::to_value(&bare).unwrap();
338        assert!(!wire.as_object().unwrap().contains_key("content"));
339    }
340
341    #[test]
342    fn record_list_input_omits_absent_filters() {
343        let input = DnsRecordListInput {
344            zone: "yah.dev".into(),
345            ..Default::default()
346        };
347        let wire = serde_json::to_value(&input).unwrap();
348        assert_eq!(wire, serde_json::json!({"zone": "yah.dev"}));
349    }
350
351    #[test]
352    fn record_list_output_round_trips_with_type_renamed() {
353        let out = DnsRecordListOutput {
354            records: vec![DnsRecordEntry {
355                id: "r1".into(),
356                name: "yah.dev".into(),
357                record_type: "A".into(),
358                content: "51.81.85.145".into(),
359                ttl: 1,
360                proxied: false,
361            }],
362        };
363        let wire = serde_json::to_value(&out).unwrap();
364        assert_eq!(wire["records"][0]["type"], "A");
365        assert!(wire["records"][0].get("record_type").is_none());
366        let back: DnsRecordListOutput = serde_json::from_value(wire).unwrap();
367        assert_eq!(back.records, out.records);
368    }
369
370    #[test]
371    fn upsert_input_type_renamed_in_wire() {
372        let wire = r#"{"zone":"yah.dev","name":"a.yah.dev","type":"A","content":"1.2.3.4","ttl":300,"proxied":true}"#;
373        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
374        assert_eq!(parsed.record_type, "A");
375        assert_eq!(parsed.ttl, 300);
376        assert!(parsed.proxied);
377        // Verify the Rust field serializes back as "type".
378        let back = serde_json::to_value(&parsed).unwrap();
379        assert!(back.get("type").is_some(), "should serialize as 'type'");
380        assert!(
381            back.get("record_type").is_none(),
382            "should not serialize as 'record_type'"
383        );
384    }
385
386    #[test]
387    fn delete_input_type_optional() {
388        let with_type = r#"{"zone":"yah.dev","name":"old.yah.dev","type":"CNAME"}"#;
389        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_type).unwrap();
390        assert_eq!(parsed.record_type.as_deref(), Some("CNAME"));
391
392        let no_type = r#"{"zone":"yah.dev","name":"old.yah.dev"}"#;
393        let parsed: DnsRecordDeleteInput = serde_json::from_str(no_type).unwrap();
394        assert!(parsed.record_type.is_none());
395    }
396
397    #[test]
398    fn delete_input_omits_type_when_absent() {
399        let input = DnsRecordDeleteInput {
400            zone: "z".into(),
401            name: "n".into(),
402            record_type: None,
403            content: None,
404        };
405        let wire = serde_json::to_value(&input).unwrap();
406        assert!(!wire.as_object().unwrap().contains_key("type"));
407    }
408
409    #[test]
410    fn delete_output_zero_is_not_an_error() {
411        let out = DnsRecordDeleteOutput { deleted: 0 };
412        let wire = serde_json::to_value(&out).unwrap();
413        assert_eq!(wire["deleted"], 0);
414    }
415
416    #[test]
417    fn zone_list_input_serializes_to_empty_object() {
418        let wire = serde_json::to_value(DnsZoneListInput::default()).unwrap();
419        assert_eq!(wire, serde_json::json!({}));
420    }
421
422    #[test]
423    fn zone_list_output_round_trips() {
424        let out = DnsZoneListOutput {
425            zones: vec![
426                DnsZoneEntry {
427                    id: "z1".into(),
428                    name: "yah.dev".into(),
429                },
430                DnsZoneEntry {
431                    id: "z2".into(),
432                    name: "noisetable.com".into(),
433                },
434            ],
435        };
436        let wire = serde_json::to_string(&out).unwrap();
437        let back: DnsZoneListOutput = serde_json::from_str(&wire).unwrap();
438        assert_eq!(back.zones.len(), 2);
439        assert_eq!(back.zones[0].name, "yah.dev");
440    }
441
442    #[cfg(feature = "json-schema")]
443    #[test]
444    fn verbs_emit_schemas_via_for_verb() {
445        use super::super::VerbDescriptor;
446
447        let upsert = VerbDescriptor::for_verb::<DnsRecordUpsert>();
448        assert_eq!(upsert.id, "dns.record.upsert");
449        assert!(upsert.input_schema.to_string().contains("content"));
450
451        let delete = VerbDescriptor::for_verb::<DnsRecordDelete>();
452        assert_eq!(delete.id, "dns.record.delete");
453        assert!(delete.output_schema.to_string().contains("deleted"));
454
455        let list = VerbDescriptor::for_verb::<DnsZoneList>();
456        assert_eq!(list.id, "dns.zone.list");
457        assert!(list.output_schema.to_string().contains("zones"));
458
459        let records = VerbDescriptor::for_verb::<DnsRecordList>();
460        assert_eq!(records.id, "dns.record.list");
461        assert!(records.output_schema.to_string().contains("records"));
462    }
463}