Skip to content

Commit dfbad97

Browse files
committed
feat(dns): serve forward A/AAAA for overlay instance addresses
Overlay (DPU-managed) instances allocate their addresses from a segment's pool into instance_addresses -- addresses that never reach machine_interface_addresses, so the forward DNS views never published them and a tenant could not resolve an overlay instance by name. This serves them: each overlay instance address now answers a forward A/AAAA record, named by its address under the segment's forward zone -- the same IP-derived form the host-naming strategy uses elsewhere. Forward records are served entirely by the dns_records SQL view, so this is a new view arm rather than any resolver change. - Add a dns_records_instance view over instance_addresses joined to its segment's forward zone (network_segments.subdomain_id), and attach it to the served dns_records view. - Re-introduce nico_inet_to_dns_hostname to name records by their fully expanded address, matching the Rust address_to_hostname -- it had been removed as an orphan; the path it once fed is the on-a-machine case the shortname view already covers, so it now has its first real consumer. - Skip host_inband segments: those addresses are the host's own and are already published by the shortname view. Tests added! This supports #2408 Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent 86dd05c commit dfbad97

2 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
-- Serve forward A/AAAA DNS for overlay (DPU-managed) instances.
2+
--
3+
-- An overlay instance's addresses are allocated from its segment's pool and live
4+
-- only in `instance_addresses` -- they never reach `machine_interface_addresses`,
5+
-- so the shortname/adm views never published them and overlay instances had no
6+
-- forward record. This adds an instance arm to the served `dns_records` view,
7+
-- sourced from `instance_addresses` joined to its segment's forward zone
8+
-- (`network_segments.subdomain_id`). A segment without a forward zone publishes
9+
-- nothing, by construction.
10+
--
11+
-- host_inband instances are excluded: their address *is* the host's own interface
12+
-- address, already in `machine_interface_addresses` and already served by the
13+
-- shortname view -- serving it here too would duplicate it.
14+
--
15+
-- The published name is the address itself in the host-naming strategy's
16+
-- IP-derived form (`<dashed-address>.<zone>.`). `nico_inet_to_dns_hostname`
17+
-- returns to derive it: it was removed in 20260622072105 as an orphan (the view
18+
-- it fed read instance IPs from `network_config` JSON joined via `machine_id` --
19+
-- the on-a-machine path the shortname view already covers, which is why it was
20+
-- dead), and now has a live consumer in the `instance_addresses` path below.
21+
22+
CREATE OR REPLACE FUNCTION nico_inet_to_dns_hostname(address inet)
23+
RETURNS text
24+
LANGUAGE plpgsql
25+
IMMUTABLE
26+
STRICT
27+
AS $$
28+
DECLARE
29+
address_text text;
30+
parts text[];
31+
left_groups text[] := ARRAY[]::text[];
32+
right_groups text[] := ARRAY[]::text[];
33+
groups text[] := ARRAY[]::text[];
34+
embedded_ipv4_text text;
35+
embedded_ipv4_parts text[];
36+
missing_groups integer;
37+
hostname text;
38+
BEGIN
39+
IF family(address) = 4 THEN
40+
RETURN replace(host(address), '.', '-');
41+
END IF;
42+
43+
address_text := host(address);
44+
embedded_ipv4_text := substring(
45+
address_text FROM '([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$'
46+
);
47+
IF embedded_ipv4_text IS NOT NULL THEN
48+
embedded_ipv4_parts := string_to_array(embedded_ipv4_text, '.');
49+
address_text := regexp_replace(
50+
address_text,
51+
'([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$',
52+
to_hex(embedded_ipv4_parts[1]::integer * 256 + embedded_ipv4_parts[2]::integer)
53+
|| ':'
54+
|| to_hex(embedded_ipv4_parts[3]::integer * 256 + embedded_ipv4_parts[4]::integer)
55+
);
56+
END IF;
57+
58+
parts := regexp_split_to_array(address_text, '::');
59+
60+
IF parts[1] IS NOT NULL AND parts[1] != '' THEN
61+
left_groups := string_to_array(parts[1], ':');
62+
END IF;
63+
64+
IF cardinality(parts) > 1 AND parts[2] != '' THEN
65+
right_groups := string_to_array(parts[2], ':');
66+
END IF;
67+
68+
missing_groups := 8 - cardinality(left_groups) - cardinality(right_groups);
69+
IF missing_groups < 0 THEN
70+
RAISE EXCEPTION 'invalid IPv6 address expansion for %', address_text;
71+
END IF;
72+
73+
groups := left_groups;
74+
IF missing_groups > 0 THEN
75+
groups := groups || array_fill('0'::text, ARRAY[missing_groups]);
76+
END IF;
77+
groups := groups || right_groups;
78+
79+
IF cardinality(groups) != 8 THEN
80+
RAISE EXCEPTION 'invalid IPv6 address expansion for %', address_text;
81+
END IF;
82+
83+
SELECT string_agg(lpad(lower(group_text), 4, '0'), '-' ORDER BY group_index)
84+
INTO hostname
85+
FROM unnest(groups) WITH ORDINALITY AS expanded(group_text, group_index);
86+
87+
RETURN hostname;
88+
END;
89+
$$;
90+
91+
-- Forward A/AAAA records for overlay-instance addresses, named by the IP-derived
92+
-- hostname under the segment's forward zone. host_inband segments are excluded
93+
-- (their addresses are the host's own, served by the shortname view).
94+
CREATE OR REPLACE VIEW dns_records_instance AS
95+
SELECT
96+
concat(nico_inet_to_dns_hostname(ia.address), '.', d.name, '.') AS q_name,
97+
ia.address AS resource_record,
98+
(CASE WHEN family(ia.address) = 6 THEN 'AAAA' ELSE 'A' END)::varchar(10) AS q_type,
99+
NULL::integer AS ttl,
100+
d.id AS domain_id
101+
FROM
102+
instance_addresses ia
103+
JOIN network_segments ns ON ns.id = ia.segment_id
104+
JOIN domains d ON d.id = ns.subdomain_id
105+
WHERE
106+
ns.network_segment_type <> 'host_inband';
107+
108+
-- Re-publish the combined view with the instance arm attached.
109+
DROP VIEW IF EXISTS dns_records;
110+
111+
CREATE OR REPLACE VIEW dns_records AS
112+
SELECT *
113+
FROM
114+
dns_records_shortname_combined
115+
FULL JOIN dns_records_adm_combined USING (q_name, resource_record, q_type, ttl, domain_id)
116+
FULL JOIN dns_records_bmc_host_id USING (q_name, resource_record, q_type, ttl, domain_id)
117+
FULL JOIN dns_records_bmc_dpu_id USING (q_name, resource_record, q_type, ttl, domain_id)
118+
FULL JOIN dns_records_instance USING (q_name, resource_record, q_type, ttl, domain_id);

crates/api-db/src/dns/resource_record.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,167 @@ pub async fn get_all_records(
199199
.await
200200
.map_err(|e| DatabaseError::query(query, e))
201201
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use carbide_uuid::instance::InstanceId;
206+
use carbide_uuid::network::NetworkSegmentId;
207+
use model::dns::NewDomain;
208+
209+
use super::find_record;
210+
use crate::dns::domain;
211+
212+
/// Seed a machine, an instance on it, a forward zone, and a network segment of
213+
/// `segment_type` whose forward zone is that domain. Returns the instance and
214+
/// segment so a caller can attach addresses and look them up.
215+
async fn seed_instance_segment(
216+
conn: &mut sqlx::PgConnection,
217+
zone: &str,
218+
segment_type: &str,
219+
) -> (InstanceId, NetworkSegmentId) {
220+
let zone_domain = domain::persist(NewDomain::new(zone.to_string()), conn)
221+
.await
222+
.unwrap();
223+
sqlx::query("INSERT INTO machines (id, dpf) VALUES ($1, '{}'::jsonb)")
224+
.bind("test-machine-2408")
225+
.execute(&mut *conn)
226+
.await
227+
.unwrap();
228+
let instance_id: InstanceId =
229+
sqlx::query_scalar("INSERT INTO instances (machine_id) VALUES ($1) RETURNING id")
230+
.bind("test-machine-2408")
231+
.fetch_one(&mut *conn)
232+
.await
233+
.unwrap();
234+
let segment_id: NetworkSegmentId = sqlx::query_scalar(
235+
"INSERT INTO network_segments (name, version, network_segment_type, subdomain_id)
236+
VALUES ($1, $2, $3::network_segment_type_t, $4) RETURNING id",
237+
)
238+
.bind("seg-2408")
239+
.bind("1")
240+
.bind(segment_type)
241+
.bind(zone_domain.id)
242+
.fetch_one(&mut *conn)
243+
.await
244+
.unwrap();
245+
(instance_id, segment_id)
246+
}
247+
248+
async fn add_address(
249+
conn: &mut sqlx::PgConnection,
250+
instance_id: InstanceId,
251+
segment_id: NetworkSegmentId,
252+
address: &str,
253+
prefix: &str,
254+
) {
255+
sqlx::query(
256+
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix)
257+
VALUES ($1::uuid, $2::inet, $3::uuid, $4::cidr)",
258+
)
259+
.bind(instance_id)
260+
.bind(address)
261+
.bind(segment_id)
262+
.bind(prefix)
263+
.execute(conn)
264+
.await
265+
.unwrap();
266+
}
267+
268+
#[crate::sqlx_test]
269+
async fn overlay_instance_addresses_are_served_forward(pool: sqlx::PgPool) {
270+
let mut txn = pool.begin().await.unwrap();
271+
let (instance_id, segment_id) =
272+
seed_instance_segment(txn.as_mut(), "tenant.example.com", "tenant").await;
273+
add_address(
274+
txn.as_mut(),
275+
instance_id,
276+
segment_id,
277+
"10.1.2.3",
278+
"10.1.2.0/24",
279+
)
280+
.await;
281+
add_address(
282+
txn.as_mut(),
283+
instance_id,
284+
segment_id,
285+
"2001:db8:abcd::2",
286+
"2001:db8:abcd::/64",
287+
)
288+
.await;
289+
290+
// IPv4 -> A, named by the dashed address under the segment's forward zone.
291+
let v4 = find_record(txn.as_mut(), "10-1-2-3.tenant.example.com.")
292+
.await
293+
.unwrap();
294+
assert_eq!(v4.len(), 1, "one A record for the overlay address");
295+
assert_eq!(v4[0].q_type, "A");
296+
assert_eq!(
297+
v4[0].record.parse::<std::net::IpAddr>().unwrap(),
298+
"10.1.2.3".parse::<std::net::IpAddr>().unwrap()
299+
);
300+
301+
// IPv6 -> AAAA, named by the fully expanded address.
302+
let v6 = find_record(
303+
txn.as_mut(),
304+
"2001-0db8-abcd-0000-0000-0000-0000-0002.tenant.example.com.",
305+
)
306+
.await
307+
.unwrap();
308+
assert_eq!(v6.len(), 1, "one AAAA record for the overlay address");
309+
assert_eq!(v6[0].q_type, "AAAA");
310+
assert_eq!(
311+
v6[0].record.parse::<std::net::IpAddr>().unwrap(),
312+
"2001:db8:abcd::2".parse::<std::net::IpAddr>().unwrap()
313+
);
314+
}
315+
316+
#[crate::sqlx_test]
317+
async fn host_inband_instance_addresses_are_not_served_here(pool: sqlx::PgPool) {
318+
// A host_inband instance address *is* the host's own interface address,
319+
// already published by the shortname view -- the instance arm must skip it
320+
// so it is not served twice.
321+
let mut txn = pool.begin().await.unwrap();
322+
let (instance_id, segment_id) =
323+
seed_instance_segment(txn.as_mut(), "host.example.com", "host_inband").await;
324+
add_address(
325+
txn.as_mut(),
326+
instance_id,
327+
segment_id,
328+
"10.9.9.9",
329+
"10.9.9.0/24",
330+
)
331+
.await;
332+
333+
let records = find_record(txn.as_mut(), "10-9-9-9.host.example.com.")
334+
.await
335+
.unwrap();
336+
assert!(
337+
records.is_empty(),
338+
"host_inband addresses are not published by the instance arm"
339+
);
340+
}
341+
342+
#[crate::sqlx_test]
343+
async fn nico_inet_to_dns_hostname_matches_host_naming_contract(pool: sqlx::PgPool) {
344+
// The view derives instance names with this SQL function; the rest of the
345+
// system derives address hostnames with the Rust
346+
// `host_naming::address_to_hostname` helper. They must agree so a forward
347+
// record and its eventual PTR round-trip. These are that helper's own vectors.
348+
let mut txn = pool.begin().await.unwrap();
349+
for (address, expected) in [
350+
("192.168.1.0", "192-168-1-0"),
351+
(
352+
"2001:db8:abcd::2",
353+
"2001-0db8-abcd-0000-0000-0000-0000-0002",
354+
),
355+
("::1", "0000-0000-0000-0000-0000-0000-0000-0001"),
356+
] {
357+
let got: String = sqlx::query_scalar("SELECT nico_inet_to_dns_hostname($1::inet)")
358+
.bind(address)
359+
.fetch_one(txn.as_mut())
360+
.await
361+
.unwrap();
362+
assert_eq!(got, expected, "SQL hostname for {address}");
363+
}
364+
}
365+
}

0 commit comments

Comments
 (0)