Skip to content

Commit cc822c3

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. The name is derived once in Rust by the same address_to_hostname helper machine interfaces use, and stored on instance_addresses; the dns_records view just reads the column, exactly as the shortname view reads machine_interfaces.hostname. One source of truth for the IP-to-name rule, and no hostname logic in SQL. - Store a Rust-derived hostname on instance_addresses (populated at allocation) and add a dns_records_instance arm to the served view that reads it, joined to the segment's forward zone (network_segments.subdomain_id). - Skip host_inband segments: those addresses are the host's own and are already published by the shortname view. New addresses pick up DNS as they are allocated; instance forward DNS had been unserved for over a year, so there is no existing state to retrofit. Tests added! This supports #2408 Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent 86dd05c commit cc822c3

4 files changed

Lines changed: 205 additions & 3 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 into
4+
-- instance_addresses and never reach machine_interface_addresses, so the
5+
-- shortname/adm views never published them -- overlay instances had no forward
6+
-- record. This adds an instance arm to the served dns_records view.
7+
--
8+
-- The published name is the address in the host-naming strategy's IP-derived
9+
-- form (<dashed-address>.<zone>.), the same convention machine interfaces use.
10+
-- Rather than re-derive it in SQL, the hostname is computed once in Rust by the
11+
-- host_naming::address_to_hostname helper -- the single source of truth shared
12+
-- with machine_interfaces.hostname -- and stored on instance_addresses; the view
13+
-- just reads the column. New addresses are populated at allocation time.
14+
--
15+
-- Not published here: rows with no hostname (instance forward DNS has been
16+
-- unserved since the old view was orphaned over a year ago, so existing rows
17+
-- simply stay unpublished -- nothing to retrofit), and host_inband segments --
18+
-- a host_inband address is the host's own interface address, already served by
19+
-- the shortname view.
20+
21+
ALTER TABLE instance_addresses ADD COLUMN hostname varchar(63);
22+
23+
CREATE OR REPLACE VIEW dns_records_instance AS
24+
SELECT
25+
concat(ia.hostname, '.', d.name, '.') AS q_name,
26+
ia.address AS resource_record,
27+
(CASE WHEN family(ia.address) = 6 THEN 'AAAA' ELSE 'A' END)::varchar(10) AS q_type,
28+
-- Instances carry no per-record TTL metadata; find_record COALESCEs this to
29+
-- the default (300s), so no dns_record_metadata join is needed here.
30+
NULL::integer AS ttl,
31+
d.id AS domain_id
32+
FROM
33+
instance_addresses ia
34+
JOIN network_segments ns ON ns.id = ia.segment_id
35+
JOIN domains d ON d.id = ns.subdomain_id
36+
WHERE
37+
ia.hostname IS NOT NULL
38+
AND ns.network_segment_type <> 'host_inband';
39+
40+
-- Re-publish the combined view with the instance arm attached.
41+
DROP VIEW IF EXISTS dns_records;
42+
43+
CREATE OR REPLACE VIEW dns_records AS
44+
SELECT *
45+
FROM
46+
dns_records_shortname_combined
47+
FULL JOIN dns_records_adm_combined USING (q_name, resource_record, q_type, ttl, domain_id)
48+
FULL JOIN dns_records_bmc_host_id USING (q_name, resource_record, q_type, ttl, domain_id)
49+
FULL JOIN dns_records_bmc_dpu_id USING (q_name, resource_record, q_type, ttl, domain_id)
50+
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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,149 @@ 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+
// The allocate path stores the IP-derived hostname; mirror that here so the
256+
// view has a name to publish.
257+
let hostname =
258+
crate::host_naming::address_to_hostname(&address.parse::<std::net::IpAddr>().unwrap())
259+
.unwrap();
260+
sqlx::query(
261+
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, hostname)
262+
VALUES ($1::uuid, $2::inet, $3::uuid, $4::cidr, $5)",
263+
)
264+
.bind(instance_id)
265+
.bind(address)
266+
.bind(segment_id)
267+
.bind(prefix)
268+
.bind(hostname)
269+
.execute(conn)
270+
.await
271+
.unwrap();
272+
}
273+
274+
#[crate::sqlx_test]
275+
async fn overlay_instance_addresses_are_served_forward(pool: sqlx::PgPool) {
276+
let mut txn = pool.begin().await.unwrap();
277+
let (instance_id, segment_id) =
278+
seed_instance_segment(txn.as_mut(), "tenant.example.com", "tenant").await;
279+
add_address(
280+
txn.as_mut(),
281+
instance_id,
282+
segment_id,
283+
"10.1.2.3",
284+
"10.1.2.0/24",
285+
)
286+
.await;
287+
add_address(
288+
txn.as_mut(),
289+
instance_id,
290+
segment_id,
291+
"2001:db8:abcd::2",
292+
"2001:db8:abcd::/64",
293+
)
294+
.await;
295+
296+
// IPv4 -> A, named by the dashed address under the segment's forward zone.
297+
let v4 = find_record(txn.as_mut(), "10-1-2-3.tenant.example.com.")
298+
.await
299+
.unwrap();
300+
assert_eq!(v4.len(), 1, "one A record for the overlay address");
301+
assert_eq!(v4[0].q_type, "A");
302+
assert_eq!(
303+
v4[0].record.parse::<std::net::IpAddr>().unwrap(),
304+
"10.1.2.3".parse::<std::net::IpAddr>().unwrap()
305+
);
306+
307+
// IPv6 -> AAAA, named by the fully expanded address.
308+
let v6 = find_record(
309+
txn.as_mut(),
310+
"2001-0db8-abcd-0000-0000-0000-0000-0002.tenant.example.com.",
311+
)
312+
.await
313+
.unwrap();
314+
assert_eq!(v6.len(), 1, "one AAAA record for the overlay address");
315+
assert_eq!(v6[0].q_type, "AAAA");
316+
assert_eq!(
317+
v6[0].record.parse::<std::net::IpAddr>().unwrap(),
318+
"2001:db8:abcd::2".parse::<std::net::IpAddr>().unwrap()
319+
);
320+
}
321+
322+
#[crate::sqlx_test]
323+
async fn host_inband_instance_addresses_are_not_served_here(pool: sqlx::PgPool) {
324+
// A host_inband instance address *is* the host's own interface address,
325+
// already published by the shortname view -- the instance arm must skip it
326+
// so it is not served twice.
327+
let mut txn = pool.begin().await.unwrap();
328+
let (instance_id, segment_id) =
329+
seed_instance_segment(txn.as_mut(), "host.example.com", "host_inband").await;
330+
add_address(
331+
txn.as_mut(),
332+
instance_id,
333+
segment_id,
334+
"10.9.9.9",
335+
"10.9.9.0/24",
336+
)
337+
.await;
338+
339+
let records = find_record(txn.as_mut(), "10-9-9-9.host.example.com.")
340+
.await
341+
.unwrap();
342+
assert!(
343+
records.is_empty(),
344+
"host_inband addresses are not published by the instance arm"
345+
);
346+
}
347+
}

crates/api-db/src/host_naming/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ fn ip_or_dormant(ctx: &NamingContext<'_>) -> DatabaseResult<String> {
410410
/// Builds the IP-derived hostname: IPv4 dotted-quad with dashes (`10.1.2.3` ->
411411
/// `10-1-2-3`); IPv6 fully-expanded hex segments with dashes. Validates the
412412
/// result is a legal DNS name.
413-
fn address_to_hostname(address: &IpAddr) -> DatabaseResult<String> {
413+
pub(crate) fn address_to_hostname(address: &IpAddr) -> DatabaseResult<String> {
414414
let hostname = match address {
415415
IpAddr::V4(_) => address.to_string().replace('.', "-"),
416416
IpAddr::V6(v6) => v6

crates/api-db/src/instance_address.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,17 +371,23 @@ pub async fn allocate(
371371
iface.assign_ips_from(ip_allocator)?
372372
};
373373

374-
let query = "INSERT INTO instance_addresses (instance_id, address, segment_id, prefix)
375-
VALUES ($1::uuid, $2, $3::uuid, $4::cidr)";
374+
let query =
375+
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, hostname)
376+
VALUES ($1::uuid, $2, $3::uuid, $4::cidr, $5)";
376377

377378
for address in addresses {
379+
// The forward-DNS name is the address in the host-naming strategy's
380+
// IP-derived form, stored once here so the dns_records_instance view
381+
// serves it without re-deriving in SQL.
382+
let hostname = crate::host_naming::address_to_hostname(&address.ip())?;
378383
sqlx::query(query)
379384
.bind(instance_id)
380385
// eg. 10.3.2.1/30
381386
.bind(address.ip())
382387
// eg. 10.3.2.0/30
383388
.bind(segment.id)
384389
.bind(IpNetwork::new(address.network(), address.prefix())?)
390+
.bind(hostname)
385391
.fetch_all(inner_txn.as_pgconn())
386392
.await
387393
.map_err(|e| DatabaseError::query(query, e))?;

0 commit comments

Comments
 (0)