Skip to content

Commit 4f9b237

Browse files
authored
feat(dns): serve forward A/AAAA for overlay instance addresses (#2786)
1 parent 3407d61 commit 4f9b237

4 files changed

Lines changed: 236 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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,170 @@ 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 carbide_uuid::vpc::VpcId;
208+
use model::dns::NewDomain;
209+
210+
use super::find_record;
211+
use crate::dns::domain;
212+
213+
/// Seed a machine, an instance on it, a forward zone, and a network segment of
214+
/// `segment_type` whose forward zone is that domain. Returns the instance and
215+
/// segment so a caller can attach addresses and look them up.
216+
async fn seed_instance_segment(
217+
conn: &mut sqlx::PgConnection,
218+
zone: &str,
219+
segment_type: &str,
220+
) -> (InstanceId, NetworkSegmentId, VpcId) {
221+
let zone_domain = domain::persist(NewDomain::new(zone.to_string()), conn)
222+
.await
223+
.unwrap();
224+
let vpc_id: VpcId =
225+
sqlx::query_scalar("INSERT INTO vpcs (name, version) VALUES ($1, $2) RETURNING id")
226+
.bind("vpc-2408")
227+
.bind("1")
228+
.fetch_one(&mut *conn)
229+
.await
230+
.unwrap();
231+
sqlx::query("INSERT INTO machines (id, dpf) VALUES ($1, '{}'::jsonb)")
232+
.bind("test-machine-2408")
233+
.execute(&mut *conn)
234+
.await
235+
.unwrap();
236+
let instance_id: InstanceId =
237+
sqlx::query_scalar("INSERT INTO instances (machine_id) VALUES ($1) RETURNING id")
238+
.bind("test-machine-2408")
239+
.fetch_one(&mut *conn)
240+
.await
241+
.unwrap();
242+
let segment_id: NetworkSegmentId = sqlx::query_scalar(
243+
"INSERT INTO network_segments (name, version, network_segment_type, subdomain_id, vpc_id)
244+
VALUES ($1, $2, $3::network_segment_type_t, $4, $5) RETURNING id",
245+
)
246+
.bind("seg-2408")
247+
.bind("1")
248+
.bind(segment_type)
249+
.bind(zone_domain.id)
250+
.bind(vpc_id)
251+
.fetch_one(&mut *conn)
252+
.await
253+
.unwrap();
254+
(instance_id, segment_id, vpc_id)
255+
}
256+
257+
async fn add_address(
258+
conn: &mut sqlx::PgConnection,
259+
instance_id: InstanceId,
260+
segment_id: NetworkSegmentId,
261+
vpc_id: VpcId,
262+
address: &str,
263+
prefix: &str,
264+
) {
265+
// The allocate path stores the IP-derived hostname; mirror that here so the
266+
// view has a name to publish.
267+
let hostname =
268+
crate::host_naming::address_to_hostname(&address.parse::<std::net::IpAddr>().unwrap())
269+
.unwrap();
270+
sqlx::query(
271+
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id, hostname)
272+
VALUES ($1::uuid, $2::inet, $3::uuid, $4::cidr, $5::uuid, $6)",
273+
)
274+
.bind(instance_id)
275+
.bind(address)
276+
.bind(segment_id)
277+
.bind(prefix)
278+
.bind(vpc_id)
279+
.bind(hostname)
280+
.execute(conn)
281+
.await
282+
.unwrap();
283+
}
284+
285+
#[crate::sqlx_test]
286+
async fn overlay_instance_addresses_are_served_forward(pool: sqlx::PgPool) {
287+
struct Case {
288+
address: &'static str,
289+
prefix: &'static str,
290+
q_name: &'static str,
291+
q_type: &'static str,
292+
}
293+
// One row per address family: the served name is the address in dashed,
294+
// IP-derived form under the segment's forward zone.
295+
let cases = [
296+
Case {
297+
address: "10.1.2.3",
298+
prefix: "10.1.2.0/24",
299+
q_name: "10-1-2-3.tenant.example.com.",
300+
q_type: "A",
301+
},
302+
Case {
303+
address: "2001:db8:abcd::2",
304+
prefix: "2001:db8:abcd::/64",
305+
q_name: "2001-0db8-abcd-0000-0000-0000-0000-0002.tenant.example.com.",
306+
q_type: "AAAA",
307+
},
308+
];
309+
310+
let mut txn = pool.begin().await.unwrap();
311+
let (instance_id, segment_id, vpc_id) =
312+
seed_instance_segment(txn.as_mut(), "tenant.example.com", "tenant").await;
313+
for case in &cases {
314+
add_address(
315+
txn.as_mut(),
316+
instance_id,
317+
segment_id,
318+
vpc_id,
319+
case.address,
320+
case.prefix,
321+
)
322+
.await;
323+
}
324+
325+
for case in &cases {
326+
let records = find_record(txn.as_mut(), case.q_name).await.unwrap();
327+
assert_eq!(
328+
records.len(),
329+
1,
330+
"one {} record for {}",
331+
case.q_type,
332+
case.address
333+
);
334+
assert_eq!(records[0].q_type, case.q_type);
335+
assert_eq!(
336+
records[0].record.parse::<std::net::IpAddr>().unwrap(),
337+
case.address.parse::<std::net::IpAddr>().unwrap()
338+
);
339+
}
340+
}
341+
342+
#[crate::sqlx_test]
343+
async fn host_inband_instance_addresses_are_not_served_here(pool: sqlx::PgPool) {
344+
// A host_inband instance address *is* the host's own interface address,
345+
// already published by the shortname view -- the instance arm must skip it
346+
// so it is not served twice.
347+
let mut txn = pool.begin().await.unwrap();
348+
let (instance_id, segment_id, vpc_id) =
349+
seed_instance_segment(txn.as_mut(), "host.example.com", "host_inband").await;
350+
add_address(
351+
txn.as_mut(),
352+
instance_id,
353+
segment_id,
354+
vpc_id,
355+
"10.9.9.9",
356+
"10.9.9.0/24",
357+
)
358+
.await;
359+
360+
let records = find_record(txn.as_mut(), "10-9-9-9.host.example.com.")
361+
.await
362+
.unwrap();
363+
assert!(
364+
records.is_empty(),
365+
"host_inband addresses are not published by the instance arm"
366+
);
367+
}
368+
}

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

Lines changed: 12 additions & 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
@@ -778,6 +778,17 @@ mod tests {
778778
);
779779
}
780780

781+
#[test]
782+
fn address_to_hostname_v4_mapped_ipv6_is_stable() {
783+
// An IPv4-mapped IPv6 address renders as a fully-expanded v6 label, like any
784+
// other v6 -- locking it guards the persisted-label format against drift.
785+
let address: IpAddr = "::ffff:192.168.1.1".parse().unwrap();
786+
assert_eq!(
787+
"0000-0000-0000-0000-0000-ffff-c0a8-0101",
788+
address_to_hostname(&address).unwrap()
789+
);
790+
}
791+
781792
#[test]
782793
fn address_to_hostname_v6_loopback() {
783794
let address: IpAddr = "::1".parse().unwrap();

crates/api-db/src/instance_address.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,14 @@ pub async fn allocate(
393393
iface.vpc_id = Some(vpc_id);
394394

395395
let query =
396-
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id)
397-
VALUES ($1::uuid, $2, $3::uuid, $4::cidr, $5::uuid)";
396+
"INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id, hostname)
397+
VALUES ($1::uuid, $2, $3::uuid, $4::cidr, $5::uuid, $6)";
398398

399399
for address in addresses {
400+
// The forward-DNS name is the address in the host-naming strategy's
401+
// IP-derived form, stored once here so the dns_records_instance view
402+
// serves it without re-deriving in SQL.
403+
let hostname = crate::host_naming::address_to_hostname(&address.ip())?;
400404
sqlx::query(query)
401405
.bind(instance_id)
402406
// eg. 10.3.2.1/30
@@ -405,6 +409,7 @@ pub async fn allocate(
405409
.bind(segment.id)
406410
.bind(IpNetwork::new(address.network(), address.prefix())?)
407411
.bind(vpc_id)
412+
.bind(hostname)
408413
.fetch_all(inner_txn.as_pgconn())
409414
.await
410415
.map_err(|e| DatabaseError::query(query, e))?;

0 commit comments

Comments
 (0)