Skip to content

Commit b099b9b

Browse files
committed
Add network interface parameters to instance creation
- Add helpers to check if an address provided by a user can be requested, i.e., is in the subnet and is not a reserved address. - Add a query used to insert a network interface into the database, handling both IP allocation and validation of any provided instance (i.e., that the instance spans exactly one VPC). This subsumes the previous IP allocation query. - Adds parameters for setting up network interfaces (possibly multiple) in the instance create request, with parameters for each. - Adds more robust and clear database error handling for detecting failures when inserting network interfaces. This code detects specific error messages from the database that we intentionally insert, and makes clear the relationship between the errors we expect, and the invariants they are designed to ensure. This includes special handling of a duplicate primary key, which is only expected during sagas and helps ensure their idempotency. - Ensures idempotency of multi-NIC allocation and rollback during sagas, by adding separate ID-allocation nodes and NIC-creation nodes. The rollback for the latter is a no-op, and the former deletes all NICs for the instance to be created. There are also tests to ensure that we completely any NICs created as a result of failure partway through, and that replaying the saga works as expected. - Adds some more tests to the NIC creation Nexus API - Adds convenience method to delete all NICs from an instance.
1 parent 0acc276 commit b099b9b

File tree

23 files changed

+2920
-526
lines changed

23 files changed

+2920
-526
lines changed

README.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,11 @@ documents being checked in.
645645
In general, changes any service API **require the following set of build steps**:
646646

647647
* Make changes to the service API
648+
* Build the package for the modified service alone. This can be done by changing
649+
directories there, or `cargo build -p <package>`. This is step is important,
650+
to avoid the circular dependency at this point. One needs to update this one
651+
OpenAPI document, without rebuilding the other components that depend on a
652+
now-outdated spec.
648653
* Update the OpenAPI document by running the relevant test with overwrite set:
649654
`EXPECTORATE=overwrite cargo test test_nexus_openapi_internal` (changing the
650655
test name as necessary)

common/src/api/external/mod.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,20 +1774,22 @@ pub struct NetworkInterface {
17741774
#[serde(flatten)]
17751775
pub identity: IdentityMetadata,
17761776

1777-
/** The Instance to which the interface belongs. */
1777+
/// The Instance to which the interface belongs.
17781778
pub instance_id: Uuid,
17791779

1780-
/** The VPC to which the interface belongs. */
1780+
/// The VPC to which the interface belongs.
17811781
pub vpc_id: Uuid,
17821782

1783-
/** The subnet to which the interface belongs. */
1783+
/// The subnet to which the interface belongs.
17841784
pub subnet_id: Uuid,
17851785

1786-
/** The MAC address assigned to this interface. */
1786+
/// The MAC address assigned to this interface.
17871787
pub mac: MacAddr,
17881788

1789-
/** The IP address assigned to this interface. */
1789+
/// The IP address assigned to this interface.
17901790
pub ip: IpAddr,
1791+
// TODO-correctness: We need to split this into an optional V4 and optional
1792+
// V6 address, at least one of which must be specified.
17911793
}
17921794

17931795
#[cfg(test)]

common/src/sql/dbinit.sql

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,13 @@ CREATE TABLE omicron.public.network_interface (
465465
time_modified TIMESTAMPTZ NOT NULL,
466466
/* Indicates that the object has been deleted */
467467
time_deleted TIMESTAMPTZ,
468-
/* FK into Instance table. */
468+
469+
/* FK into Instance table.
470+
* Note that interfaces are always attached to a particular instance.
471+
* IP addresses may be reserved, but this is a different resource.
472+
*/
469473
instance_id UUID NOT NULL,
474+
470475
/* FK into VPC table */
471476
vpc_id UUID NOT NULL,
472477
/* FK into VPCSubnet table. */
@@ -483,12 +488,6 @@ CREATE TABLE omicron.public.network_interface (
483488
* as moving IPs between NICs on different instances, etc.
484489
*/
485490

486-
CREATE UNIQUE INDEX ON omicron.public.network_interface (
487-
vpc_id,
488-
name
489-
) WHERE
490-
time_deleted IS NULL;
491-
492491
/* Ensure we do not assign the same address twice within a subnet */
493492
CREATE UNIQUE INDEX ON omicron.public.network_interface (
494493
subnet_id,
@@ -505,6 +504,18 @@ CREATE UNIQUE INDEX ON omicron.public.network_interface (
505504
) WHERE
506505
time_deleted IS NULL;
507506

507+
/*
508+
* Index used to verify that an Instance's networking is contained
509+
* within a single VPC.
510+
*/
511+
CREATE UNIQUE INDEX ON omicron.public.network_interface (
512+
instance_id,
513+
name
514+
)
515+
STORING (vpc_id)
516+
WHERE
517+
time_deleted IS NULL;
518+
508519
CREATE TYPE omicron.public.vpc_router_kind AS ENUM (
509520
'system',
510521
'custom'

nexus/src/db/datastore.rs

Lines changed: 125 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ use crate::db::{
7979
},
8080
pagination::paginated,
8181
pagination::paginated_multicolumn,
82-
subnet_allocation::AllocateIpQuery,
8382
subnet_allocation::FilterConflictingVpcSubnetRangesQuery,
83+
subnet_allocation::InsertNetworkInterfaceQuery,
84+
subnet_allocation::NetworkInterfaceError,
8485
subnet_allocation::SubnetError,
8586
update_and_check::{UpdateAndCheck, UpdateStatus},
8687
};
@@ -1698,109 +1699,153 @@ impl DataStore {
16981699
/*
16991700
* Network interfaces
17001701
*/
1701-
17021702
pub async fn instance_create_network_interface(
17031703
&self,
17041704
interface: IncompleteNetworkInterface,
1705-
) -> CreateResult<NetworkInterface> {
1705+
) -> Result<NetworkInterface, NetworkInterfaceError> {
17061706
use db::schema::network_interface::dsl;
1707+
let query = InsertNetworkInterfaceQuery {
1708+
interface: interface.clone(),
1709+
now: Utc::now(),
1710+
};
1711+
diesel::insert_into(dsl::network_interface)
1712+
.values(query)
1713+
.returning(NetworkInterface::as_returning())
1714+
.get_result_async(self.pool())
1715+
.await
1716+
.map_err(|e| NetworkInterfaceError::from_pool(e, &interface))
1717+
}
17071718

1708-
// TODO: Longer term, it would be nice to decouple the IP allocation
1709-
// (and MAC allocation) from the NetworkInterface table, so that
1710-
// retrying from parallel inserts doesn't need to happen here.
1711-
1712-
let name = interface.identity.name.clone();
1713-
match interface.ip {
1714-
// Attempt an insert with a requested IP address
1715-
Some(ip) => {
1716-
interface.subnet.contains(ip)?;
1717-
let row = NetworkInterface {
1718-
identity: interface.identity,
1719-
instance_id: interface.instance_id,
1720-
vpc_id: interface.vpc_id,
1721-
subnet_id: interface.subnet.id(),
1722-
mac: interface.mac,
1723-
ip: ip.into(),
1724-
};
1725-
diesel::insert_into(dsl::network_interface)
1726-
.values(row)
1727-
.returning(NetworkInterface::as_returning())
1728-
.get_result_async(self.pool())
1729-
.await
1730-
.map_err(|e| {
1731-
public_error_from_diesel_pool(
1732-
e,
1733-
ErrorHandler::Conflict(
1734-
ResourceType::NetworkInterface,
1735-
name.as_str(),
1736-
),
1737-
)
1738-
})
1739-
}
1740-
// Insert and allocate an IP address
1741-
None => {
1742-
let allocation_query = AllocateIpQuery {
1743-
block: ipnetwork::IpNetwork::V4(
1744-
interface.subnet.ipv4_block.0 .0,
1719+
/// Delete all network interfaces attached to the given instance.
1720+
// NOTE: This is mostly useful in the context of sagas, but might be helpful
1721+
// in other situations, such as moving an instance between VPC Subnets.
1722+
pub async fn instance_delete_all_network_interfaces(
1723+
&self,
1724+
instance_id: &Uuid,
1725+
) -> DeleteResult {
1726+
use db::schema::network_interface::dsl;
1727+
let now = Utc::now();
1728+
diesel::update(dsl::network_interface)
1729+
.filter(dsl::instance_id.eq(*instance_id))
1730+
.filter(dsl::time_deleted.is_null())
1731+
.set(dsl::time_deleted.eq(now))
1732+
.execute_async(self.pool())
1733+
.await
1734+
.map_err(|e| {
1735+
public_error_from_diesel_pool(
1736+
e,
1737+
ErrorHandler::NotFoundByLookup(
1738+
ResourceType::Instance,
1739+
LookupType::ById(*instance_id),
17451740
),
1746-
interface,
1747-
now: Utc::now(),
1748-
};
1749-
diesel::insert_into(dsl::network_interface)
1750-
.values(allocation_query)
1751-
.returning(NetworkInterface::as_returning())
1752-
.get_result_async(self.pool())
1753-
.await
1754-
.map_err(|e| {
1755-
if let PoolError::Connection(ConnectionError::Query(
1756-
diesel::result::Error::NotFound,
1757-
)) = e
1758-
{
1759-
Error::InvalidRequest {
1760-
message: "no available IP addresses"
1761-
.to_string(),
1762-
}
1763-
} else {
1764-
public_error_from_diesel_pool(
1765-
e,
1766-
ErrorHandler::Conflict(
1767-
ResourceType::NetworkInterface,
1768-
name.as_str(),
1769-
),
1770-
)
1771-
}
1772-
})
1773-
}
1774-
}
1741+
)
1742+
})?;
1743+
Ok(())
17751744
}
17761745

17771746
pub async fn instance_delete_network_interface(
17781747
&self,
1779-
network_interface_id: &Uuid,
1748+
interface_id: &Uuid,
17801749
) -> DeleteResult {
17811750
use db::schema::network_interface::dsl;
1751+
let now = Utc::now();
1752+
let result = diesel::update(dsl::network_interface)
1753+
.filter(dsl::id.eq(*interface_id))
1754+
.filter(dsl::time_deleted.is_null())
1755+
.set((dsl::time_deleted.eq(now),))
1756+
.check_if_exists::<db::model::NetworkInterface>(*interface_id)
1757+
.execute_and_check(self.pool())
1758+
.await
1759+
.map_err(|e| {
1760+
public_error_from_diesel_pool(
1761+
e,
1762+
ErrorHandler::NotFoundByLookup(
1763+
ResourceType::NetworkInterface,
1764+
LookupType::ById(*interface_id),
1765+
),
1766+
)
1767+
})?;
1768+
match result.status {
1769+
UpdateStatus::Updated => Ok(()),
1770+
UpdateStatus::NotUpdatedButExists => {
1771+
let interface = &result.found;
1772+
if interface.time_deleted().is_some() {
1773+
// Already deleted
1774+
Ok(())
1775+
} else {
1776+
Err(Error::internal_error(&format!(
1777+
"failed to delete network interface: {}",
1778+
interface_id
1779+
)))
1780+
}
1781+
}
1782+
}
1783+
}
1784+
1785+
pub async fn subnet_lookup_network_interface(
1786+
&self,
1787+
subnet_id: &Uuid,
1788+
interface_name: &Name,
1789+
) -> LookupResult<db::model::NetworkInterface> {
1790+
use db::schema::network_interface::dsl;
1791+
1792+
dsl::network_interface
1793+
.filter(dsl::subnet_id.eq(*subnet_id))
1794+
.filter(dsl::time_deleted.is_null())
1795+
.filter(dsl::name.eq(interface_name.clone()))
1796+
.select(db::model::NetworkInterface::as_select())
1797+
.get_result_async(self.pool())
1798+
.await
1799+
.map_err(|e| {
1800+
public_error_from_diesel_pool(
1801+
e,
1802+
ErrorHandler::NotFoundByLookup(
1803+
ResourceType::NetworkInterface,
1804+
LookupType::ByName(interface_name.to_string()),
1805+
),
1806+
)
1807+
})
1808+
}
17821809

1783-
// TODO-correctness: Do not allow deleting interfaces on running
1784-
// instances until we support hotplug
1810+
/// List network interfaces associated with a given instance.
1811+
pub async fn instance_list_network_interfaces(
1812+
&self,
1813+
instance_id: &Uuid,
1814+
pagparams: &DataPageParams<'_, Name>,
1815+
) -> ListResultVec<NetworkInterface> {
1816+
use db::schema::network_interface::dsl;
1817+
paginated(dsl::network_interface, dsl::name, &pagparams)
1818+
.filter(dsl::time_deleted.is_null())
1819+
.filter(dsl::instance_id.eq(*instance_id))
1820+
.select(NetworkInterface::as_select())
1821+
.load_async::<NetworkInterface>(self.pool())
1822+
.await
1823+
.map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server))
1824+
}
17851825

1786-
let now = Utc::now();
1787-
diesel::update(dsl::network_interface)
1826+
/// Get a network interface by name attached to an instance
1827+
pub async fn instance_lookup_network_interface(
1828+
&self,
1829+
instance_id: &Uuid,
1830+
interface_name: &Name,
1831+
) -> LookupResult<NetworkInterface> {
1832+
use db::schema::network_interface::dsl;
1833+
dsl::network_interface
1834+
.filter(dsl::instance_id.eq(*instance_id))
1835+
.filter(dsl::name.eq(interface_name.clone()))
17881836
.filter(dsl::time_deleted.is_null())
1789-
.filter(dsl::id.eq(*network_interface_id))
1790-
.set(dsl::time_deleted.eq(now))
1791-
.returning(NetworkInterface::as_returning())
1837+
.select(NetworkInterface::as_select())
17921838
.get_result_async(self.pool())
17931839
.await
17941840
.map_err(|e| {
17951841
public_error_from_diesel_pool(
17961842
e,
17971843
ErrorHandler::NotFoundByLookup(
17981844
ResourceType::NetworkInterface,
1799-
LookupType::ById(*network_interface_id),
1845+
LookupType::ByName(interface_name.to_string()),
18001846
),
18011847
)
1802-
})?;
1803-
Ok(())
1848+
})
18041849
}
18051850

18061851
// Create a record for a new Oximeter instance

0 commit comments

Comments
 (0)