From c95a802ab9e5ab9faf80097fff54b3171cc9c415 Mon Sep 17 00:00:00 2001 From: Chet Nichols III Date: Tue, 23 Jun 2026 00:06:39 -0700 Subject: [PATCH] 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 https://github.com/NVIDIA/infra-controller/issues/2408 Signed-off-by: Chet Nichols III --- ...901_serve_overlay_instance_forward_dns.sql | 50 ++++++ crates/api-db/src/dns/resource_record.rs | 167 ++++++++++++++++++ crates/api-db/src/host_naming/mod.rs | 13 +- crates/api-db/src/instance_address.rs | 9 +- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 crates/api-db/migrations/20260623054901_serve_overlay_instance_forward_dns.sql diff --git a/crates/api-db/migrations/20260623054901_serve_overlay_instance_forward_dns.sql b/crates/api-db/migrations/20260623054901_serve_overlay_instance_forward_dns.sql new file mode 100644 index 0000000000..2130359863 --- /dev/null +++ b/crates/api-db/migrations/20260623054901_serve_overlay_instance_forward_dns.sql @@ -0,0 +1,50 @@ +-- Serve forward A/AAAA DNS for overlay (DPU-managed) instances. +-- +-- An overlay instance's addresses are allocated from its segment's pool into +-- instance_addresses and never reach machine_interface_addresses, so the +-- shortname/adm views never published them -- overlay instances had no forward +-- record. This adds an instance arm to the served dns_records view. +-- +-- The published name is the address in the host-naming strategy's IP-derived +-- form (..), the same convention machine interfaces use. +-- Rather than re-derive it in SQL, the hostname is computed once in Rust by the +-- host_naming::address_to_hostname helper -- the single source of truth shared +-- with machine_interfaces.hostname -- and stored on instance_addresses; the view +-- just reads the column. New addresses are populated at allocation time. +-- +-- Not published here: rows with no hostname (instance forward DNS has been +-- unserved since the old view was orphaned over a year ago, so existing rows +-- simply stay unpublished -- nothing to retrofit), and host_inband segments -- +-- a host_inband address is the host's own interface address, already served by +-- the shortname view. + +ALTER TABLE instance_addresses ADD COLUMN hostname varchar(63); + +CREATE OR REPLACE VIEW dns_records_instance AS +SELECT + concat(ia.hostname, '.', d.name, '.') AS q_name, + ia.address AS resource_record, + (CASE WHEN family(ia.address) = 6 THEN 'AAAA' ELSE 'A' END)::varchar(10) AS q_type, + -- Instances carry no per-record TTL metadata; find_record COALESCEs this to + -- the default (300s), so no dns_record_metadata join is needed here. + NULL::integer AS ttl, + d.id AS domain_id +FROM + instance_addresses ia + JOIN network_segments ns ON ns.id = ia.segment_id + JOIN domains d ON d.id = ns.subdomain_id +WHERE + ia.hostname IS NOT NULL + AND ns.network_segment_type <> 'host_inband'; + +-- Re-publish the combined view with the instance arm attached. +DROP VIEW IF EXISTS dns_records; + +CREATE OR REPLACE VIEW dns_records AS +SELECT * +FROM + dns_records_shortname_combined + FULL JOIN dns_records_adm_combined USING (q_name, resource_record, q_type, ttl, domain_id) + FULL JOIN dns_records_bmc_host_id USING (q_name, resource_record, q_type, ttl, domain_id) + FULL JOIN dns_records_bmc_dpu_id USING (q_name, resource_record, q_type, ttl, domain_id) + FULL JOIN dns_records_instance USING (q_name, resource_record, q_type, ttl, domain_id); diff --git a/crates/api-db/src/dns/resource_record.rs b/crates/api-db/src/dns/resource_record.rs index ca61447a40..dbd7d0e8b7 100644 --- a/crates/api-db/src/dns/resource_record.rs +++ b/crates/api-db/src/dns/resource_record.rs @@ -199,3 +199,170 @@ pub async fn get_all_records( .await .map_err(|e| DatabaseError::query(query, e)) } + +#[cfg(test)] +mod tests { + use carbide_uuid::instance::InstanceId; + use carbide_uuid::network::NetworkSegmentId; + use carbide_uuid::vpc::VpcId; + use model::dns::NewDomain; + + use super::find_record; + use crate::dns::domain; + + /// Seed a machine, an instance on it, a forward zone, and a network segment of + /// `segment_type` whose forward zone is that domain. Returns the instance and + /// segment so a caller can attach addresses and look them up. + async fn seed_instance_segment( + conn: &mut sqlx::PgConnection, + zone: &str, + segment_type: &str, + ) -> (InstanceId, NetworkSegmentId, VpcId) { + let zone_domain = domain::persist(NewDomain::new(zone.to_string()), conn) + .await + .unwrap(); + let vpc_id: VpcId = + sqlx::query_scalar("INSERT INTO vpcs (name, version) VALUES ($1, $2) RETURNING id") + .bind("vpc-2408") + .bind("1") + .fetch_one(&mut *conn) + .await + .unwrap(); + sqlx::query("INSERT INTO machines (id, dpf) VALUES ($1, '{}'::jsonb)") + .bind("test-machine-2408") + .execute(&mut *conn) + .await + .unwrap(); + let instance_id: InstanceId = + sqlx::query_scalar("INSERT INTO instances (machine_id) VALUES ($1) RETURNING id") + .bind("test-machine-2408") + .fetch_one(&mut *conn) + .await + .unwrap(); + let segment_id: NetworkSegmentId = sqlx::query_scalar( + "INSERT INTO network_segments (name, version, network_segment_type, subdomain_id, vpc_id) + VALUES ($1, $2, $3::network_segment_type_t, $4, $5) RETURNING id", + ) + .bind("seg-2408") + .bind("1") + .bind(segment_type) + .bind(zone_domain.id) + .bind(vpc_id) + .fetch_one(&mut *conn) + .await + .unwrap(); + (instance_id, segment_id, vpc_id) + } + + async fn add_address( + conn: &mut sqlx::PgConnection, + instance_id: InstanceId, + segment_id: NetworkSegmentId, + vpc_id: VpcId, + address: &str, + prefix: &str, + ) { + // The allocate path stores the IP-derived hostname; mirror that here so the + // view has a name to publish. + let hostname = + crate::host_naming::address_to_hostname(&address.parse::().unwrap()) + .unwrap(); + sqlx::query( + "INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id, hostname) + VALUES ($1::uuid, $2::inet, $3::uuid, $4::cidr, $5::uuid, $6)", + ) + .bind(instance_id) + .bind(address) + .bind(segment_id) + .bind(prefix) + .bind(vpc_id) + .bind(hostname) + .execute(conn) + .await + .unwrap(); + } + + #[crate::sqlx_test] + async fn overlay_instance_addresses_are_served_forward(pool: sqlx::PgPool) { + struct Case { + address: &'static str, + prefix: &'static str, + q_name: &'static str, + q_type: &'static str, + } + // One row per address family: the served name is the address in dashed, + // IP-derived form under the segment's forward zone. + let cases = [ + Case { + address: "10.1.2.3", + prefix: "10.1.2.0/24", + q_name: "10-1-2-3.tenant.example.com.", + q_type: "A", + }, + Case { + address: "2001:db8:abcd::2", + prefix: "2001:db8:abcd::/64", + q_name: "2001-0db8-abcd-0000-0000-0000-0000-0002.tenant.example.com.", + q_type: "AAAA", + }, + ]; + + let mut txn = pool.begin().await.unwrap(); + let (instance_id, segment_id, vpc_id) = + seed_instance_segment(txn.as_mut(), "tenant.example.com", "tenant").await; + for case in &cases { + add_address( + txn.as_mut(), + instance_id, + segment_id, + vpc_id, + case.address, + case.prefix, + ) + .await; + } + + for case in &cases { + let records = find_record(txn.as_mut(), case.q_name).await.unwrap(); + assert_eq!( + records.len(), + 1, + "one {} record for {}", + case.q_type, + case.address + ); + assert_eq!(records[0].q_type, case.q_type); + assert_eq!( + records[0].record.parse::().unwrap(), + case.address.parse::().unwrap() + ); + } + } + + #[crate::sqlx_test] + async fn host_inband_instance_addresses_are_not_served_here(pool: sqlx::PgPool) { + // A host_inband instance address *is* the host's own interface address, + // already published by the shortname view -- the instance arm must skip it + // so it is not served twice. + let mut txn = pool.begin().await.unwrap(); + let (instance_id, segment_id, vpc_id) = + seed_instance_segment(txn.as_mut(), "host.example.com", "host_inband").await; + add_address( + txn.as_mut(), + instance_id, + segment_id, + vpc_id, + "10.9.9.9", + "10.9.9.0/24", + ) + .await; + + let records = find_record(txn.as_mut(), "10-9-9-9.host.example.com.") + .await + .unwrap(); + assert!( + records.is_empty(), + "host_inband addresses are not published by the instance arm" + ); + } +} diff --git a/crates/api-db/src/host_naming/mod.rs b/crates/api-db/src/host_naming/mod.rs index e19c4b6537..8a1a9c2a60 100644 --- a/crates/api-db/src/host_naming/mod.rs +++ b/crates/api-db/src/host_naming/mod.rs @@ -410,7 +410,7 @@ fn ip_or_dormant(ctx: &NamingContext<'_>) -> DatabaseResult { /// Builds the IP-derived hostname: IPv4 dotted-quad with dashes (`10.1.2.3` -> /// `10-1-2-3`); IPv6 fully-expanded hex segments with dashes. Validates the /// result is a legal DNS name. -fn address_to_hostname(address: &IpAddr) -> DatabaseResult { +pub(crate) fn address_to_hostname(address: &IpAddr) -> DatabaseResult { let hostname = match address { IpAddr::V4(_) => address.to_string().replace('.', "-"), IpAddr::V6(v6) => v6 @@ -778,6 +778,17 @@ mod tests { ); } + #[test] + fn address_to_hostname_v4_mapped_ipv6_is_stable() { + // An IPv4-mapped IPv6 address renders as a fully-expanded v6 label, like any + // other v6 -- locking it guards the persisted-label format against drift. + let address: IpAddr = "::ffff:192.168.1.1".parse().unwrap(); + assert_eq!( + "0000-0000-0000-0000-0000-ffff-c0a8-0101", + address_to_hostname(&address).unwrap() + ); + } + #[test] fn address_to_hostname_v6_loopback() { let address: IpAddr = "::1".parse().unwrap(); diff --git a/crates/api-db/src/instance_address.rs b/crates/api-db/src/instance_address.rs index 0a65c08876..7a3b149225 100644 --- a/crates/api-db/src/instance_address.rs +++ b/crates/api-db/src/instance_address.rs @@ -393,10 +393,14 @@ pub async fn allocate( iface.vpc_id = Some(vpc_id); let query = - "INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id) - VALUES ($1::uuid, $2, $3::uuid, $4::cidr, $5::uuid)"; + "INSERT INTO instance_addresses (instance_id, address, segment_id, prefix, vpc_id, hostname) + VALUES ($1::uuid, $2, $3::uuid, $4::cidr, $5::uuid, $6)"; for address in addresses { + // The forward-DNS name is the address in the host-naming strategy's + // IP-derived form, stored once here so the dns_records_instance view + // serves it without re-deriving in SQL. + let hostname = crate::host_naming::address_to_hostname(&address.ip())?; sqlx::query(query) .bind(instance_id) // eg. 10.3.2.1/30 @@ -405,6 +409,7 @@ pub async fn allocate( .bind(segment.id) .bind(IpNetwork::new(address.network(), address.prefix())?) .bind(vpc_id) + .bind(hostname) .fetch_all(inner_txn.as_pgconn()) .await .map_err(|e| DatabaseError::query(query, e))?;