Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (<dashed-address>.<zone>.), 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);
167 changes: 167 additions & 0 deletions crates/api-db/src/dns/resource_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<std::net::IpAddr>().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::<std::net::IpAddr>().unwrap(),
case.address.parse::<std::net::IpAddr>().unwrap()
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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"
);
}
}
13 changes: 12 additions & 1 deletion crates/api-db/src/host_naming/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ fn ip_or_dormant(ctx: &NamingContext<'_>) -> DatabaseResult<String> {
/// 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<String> {
pub(crate) fn address_to_hostname(address: &IpAddr) -> DatabaseResult<String> {
let hostname = match address {
IpAddr::V4(_) => address.to_string().replace('.', "-"),
IpAddr::V6(v6) => v6
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 7 additions & 2 deletions crates/api-db/src/instance_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))?;
Expand Down
Loading