-
Notifications
You must be signed in to change notification settings - Fork 67
[floating-ip,fix,api] Fix floating IP creation API to distinguish explicit IP from pool selection #9687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
… explicit allocation Fixes #9680. The `AddressAllocator::Explicit` variant now accepts just a `pool` field without requiring `ip`, allowing users to auto-allocate from a specific pool. Additionally, `AddressAllocator::Auto` now rejects unknown fields like `pool` instead of silently ignoring them. Includes: - Add `ExplicitAllocation` newtype with validation (at least one of `ip` or `pool` required) - Add `deny_unknown_fields` to `AddressAllocator` enum - Add API version 2026012000 (`ADDRESS_ALLOCATOR_OPTIONAL_IP`) - Add versioned types in v2026011600.rs and update v2026011501.rs delegation chain - Add unit tests for both bug cases and wire format compatibility
e6ee9c3 to
48948b2
Compare
My mental model so far (which might be totally wrong) is that |
Yeah, I think moving to #9687 (comment) simplifies this from what we had before anyhow. |
…icit)
The previous API had two issues:
- Auto variant silently ignored the pool field
- Explicit IP allocation failed for addresses not in the default pool
Updates:
- Moves explicit pool selection firmly to `Auto { pool_selector: PoolSelector::Explicit }`
- Explicit variant in floating IP creation now only requires ip (pool inferred from address)
- Adds deny_unknown_fields to reject invalid requests like `{\"type\": \"auto\", \"pool\": \"...\"}`
- Adds `ip_pool_fetch_containing_address()` to resolve pool from explicit IP address correctly
- Consolidates multicast pool resolution to use `ip_pool_fetch_containing_address()` too
- Adds FloatingIpAllocation enum at datastore layer (type-safe, mirrors multicast pattern)
- Adds Display impl for consistent pool selection logging (inferred/explicit/default)
- Converts NotFound to InvalidRequest for IP not in any configured pool
- Adds test_floating_ip_create_ip_not_in_pool integration test
|
@bnaecker @mergeconflict updated everything to be more "explicit" for explicit. The multicast changes are mainly here for cleanup and reuse. |
| Auto { | ||
| /// IP version for default pool selection. | ||
| /// Required if both IPv4 and IPv6 default multicast pools exist. | ||
| ip_version: Option<IpVersion>, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with the db-queries code yet. Does this need something analogous to a PoolSelector like in external_api::params::AddressAllocator?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It didn't have it previously. And, there is a difference in type (post authz). This just makes it explicit within the datastore code as well, vs tuple matching.
In terms of multicast, joins happen via members, and you can't specify a pool. It's a bit different in terms of API, and purposely so, i.e. you join a group by ip, uuid, name of the group, which is more common for mcast APIs.
| /// The pool containing this address. If not specified, the default | ||
| /// pool for the address's IP version is used. | ||
| pool: Option<NameOrId>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shoot... Does this mean I should remove pool from ExternalSubnetAllocator::Explicit too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think being more direct is the way to go semantically, right? Being explicit with explicit (haha) does seem better overall.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mergeconflict #9578 has a version of the subnet allocator type that follows this pattern, although I think I called it by the older term "selector". You're welcome to crib from that if you need.
| /// Specify how to allocate a floating IP address. | ||
| #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] | ||
| #[serde(tag = "type", rename_all = "snake_case")] | ||
| #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do I need deny_unknown_fields for ExternalSubnetAllocator as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably so, as someone could mistakenly pass pool and it would be silently ignored, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe we do this in general for API types, although I'm not sure there's a good reason for that. @ahl might have an opinion here too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I added it here due to #9680.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this comes about just due to the change in API semantics from what was expected? Of course we have versioned APIs now, but the issue showcased trying to treat the new API like an old one.
For this case, the {"type": "auto"} possibility may make it worthwhile? @ahl?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I lean against including deny_unknown_fields.
Like I said in my top level review, I think the reason we don't do it is we wanted to be generous and avoid blowing up when we don't have to. On the other hand, in most other spots when it comes up, we do tend to prefer erroring out and giving detailed explanations as to why. So maybe we should think about adding deny_unknown_fields on more request bodies.
However, a stronger reason might be this, from the serde docs:
Note: this attribute is not supported in combination with flatten, neither on the outer struct nor on the flattened field.
We do use flatten in create bodies:
omicron/nexus/types/src/external_api/params.rs
Lines 557 to 561 in 5eb1337
| /// Create-time parameters for a `Silo` | |
| #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] | |
| pub struct SiloCreate { | |
| #[serde(flatten)] | |
| pub identity: IdentityMetadataCreateParams, |
The strongest argument specific to this case, though, is that while pool was present in FloatingIpCreate in v17, we're talking about AddressAllocator here, not FloatingIpCreate. If I understand correctly, this actually won't error in the most likely user error situation: a leftover pool param in the FloatingIpCreate body in an out of date client library or CLI script. This will only error if you try to do
{
"address_allocator": { "pool": "abc" }
}
But since AddressAllocator is new in v18 and the pool param in there was only there for like a week, there is no existing customer code or out of date client code that expects to be able to stick pool in there. So in the name of consistency with all the other structs, I would leave it out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's good reasoning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@askfongjojo note this thread for your testing btw.
| ip_version, | ||
| ) | ||
| .await?; | ||
| let (authz_pool, explicit_ip) = match &allocation { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we also need to get the pool's rcgen to avoid concurrent modifications in the write that happens after this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This follows the same pattern as resolve_pool_for_allocation, as neither checks pool rcgen. The protection happens in NextExternalIp allocation which atomically allocates the IP and bumps the range rcgen in one CTE.
The lookup just identifies which pool to allocate from, while the actual allocation is atomic itself. If the pool/range changes between lookup and allocation, NextExternalIp will fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess I'm wondering what happens if (for example) the authz_pool is unlinked from the silo after this block but before we allocate an IP from it. I think the range won't have changed, but the write shouldn't succeed. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mergeconflict unrelated to this PR, but you have hit on something that's already a TODO/issue:
/// Look up whether the given pool is available to users in the current
/// silo, i.e., whether there is an entry in the association table linking
/// the pool with that silo
//
// TODO-correctness: This seems difficult to use without TOCTOU issues. It's
// currently used to ensure there's a link between a Silo and an IP Pool
// when allocating an external address for an instance in that Silo. But
// that works by checking that the link exists, and then in a separate
// query, allocating an address out of it. Suppose the silo was unlinked
// after this check, but before the external address allocation query ran.
// Then one could end up with an address from an unlinked IP Pool, which
// seems wrong.
//
// See https://github.com/oxidecomputer/omicron/issues/8992
pub async fn ip_pool_fetch_link(
...As per the issue, there's a series of these to make more robust.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was in this code a few months ago, which is where this comment and issue came from. There are lot of opportunities for races like this. I don't think we hit them in practice because linking / unlinking isn't that common, but we should definitely fix these things long term.
bnaecker
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, thanks for fixing this.
david-crespo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API and help comments look great, and thanks for including all the example JSON bodies, that was very helpful. Just taking a look at the deny_unknown_fields question. Generally we want to be generous in what we accept unless it's very misleading to do so (which in this case it may be).
| .select(IpPool::as_select()) | ||
| .first_async::<IpPool>( | ||
| &*self.pool_connection_authorized(opctx).await?, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is fun. Simpler than I pictured.
I asked Claude if we are missing any indexes that would help this query. It sounds quite plausible, but I'm not 100% sure -- key claim is that the join from ip_pool_range to ip_pool on pool ID would benefit from an index on ip_pool_id for the range table. Take it or leave it. It's a low-cost optimization.
The query in
ip_pool_fetch_containing_addressjoinsip_pool_range→ip_pool→ip_pool_resourcewith these key filters:
ip_pool_resource.resource_id = silo_id(highly selective)ip_pool.pool_type = pool_typeip_pool_range.first_address <= ip AND last_address >= ipThe query planner could start from
ip_pool_resource(highly selective bysilo_id) and work towardip_pool_range, but there's no index onip_pool_range.ip_pool_id. This means when joining fromip_pooltoip_pool_range, CockroachDB must either:
- Do a full scan of
ip_pool_range(filtering by address afterward), or- Scan one of the address indexes and filter by
ip_pool_idThe existing address indexes (
lookup_pool_range_by_first_address,lookup_pool_range_by_last_address) were designed for overlap detection, not for range containment lookups.A useful index would be:
CREATE INDEX IF NOT EXISTS ip_pool_range_by_pool_id ON omicron.public.ip_pool_range ( ip_pool_id ) WHERE time_deleted IS NULL;This would allow the planner to:
- Start from ip_pool_resource (filtered by silo_id)
- Join to ip_pool (filtered by pool_type)
- Use the new index to efficiently find ranges for those specific pools
- Filter by address containment
The number of ranges per pool is typically small, so filtering by address within that subset is cheap.
495b227 to
a0ae22a
Compare
Fixes #9680.
This work clears up ambiguity.
AddressAllocator::Explicitnow requires only an IP field, with the pool inferred from the address (since IP pools cannot have overlapping ranges). Pool-based allocation moves toAddressAllocator::AutowithPoolSelector::Explicit { pool }.Includes:
AddressAllocator::Explicitto only requireipas a field (pool inferred)Auto { pool_selector: PoolSelector::Explicit { pool } }FLOATING_IP_ALLOCATOR_UPDATE)ip_pool_fetch_containing_address()to resolve pool from explicit IP address correctlyip_pool_fetch_containing_address()tooNotFoundtoInvalidRequestfor an IP not in any configured poolip_pool_range.ip_pool_id(schema version 223) for optimizingip_pool_fetch_containing_address()and other queriesCurrent params JSON:
{"type": "explicit", "ip": "10.0.0.1"}{"type": "auto", "pool_selector": {"type": "explicit", "pool": "my-pool"}}{"type": "auto", "pool_selector": {"type": "auto", "ip_version": "v4"}}default pool)
{"type": "auto", "pool_selector": {"type": "auto"}}{"type": "auto"}Example
FloatingIpCreaterequest body:{ "name": "my-floating-ip", "description": "Example floating IP", "address_allocator": {"type": "explicit", "ip": "10.0.0.1"} }Or with pool selection:
{ "name": "my-floating-ip", "description": "Example floating IP", "address_allocator": { "type": "auto", "pool_selector": {"type": "explicit", "pool": "my-pool"} } }Note: There's some multicast cleanup here to make sure of the same functionality, which just removes unnecessary code.