Skip to content

Commit c6bfa1f

Browse files
neddpIvaylogi98dudejas
committed
Fix IP allocation from reserved CIDR ranges in dynamic networks
Add overlaps? method to IpAddrOrCidr that uses integer range comparison instead of IPAddr#include?, which loses CIDR prefix information during coercion (coerce_other converts IpAddrOrCidr via .to_i, producing /32). Fix find_next_available_ip in IpRepo: - Use overlaps? instead of include? for blocking IP detection - Prune filtered_ips by last address (not base) to retain CIDRs that span the current candidate position - Capture blocking_ip reference before mutation for correct advance Add unit tests for overlaps?, CIDR block handling, IPv6 allocation, and an integration test for CIDR reserved ranges. Co-authored-by: Ivaylo Ivanov <19604369+Ivaylogi98@users.noreply.github.com> Co-authored-by: Saumya Dudeja <193609732+dudejas@users.noreply.github.com>
1 parent 3f4620e commit c6bfa1f

5 files changed

Lines changed: 328 additions & 6 deletions

File tree

src/bosh-director/lib/bosh/director/deployment_plan/ip_provider/ip_repo.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def try_to_allocate_dynamic_ip(reservation, subnet)
106106
ip_address_cidr = find_next_available_ip(addresses_we_cant_allocate, first_range_address, subnet.prefix)
107107

108108
if !(subnet.range == ip_address_cidr || subnet.range.include?(ip_address_cidr))
109-
raise NoMoreIPsAvailableAndStopRetrying
109+
raise NoMoreIPsAvailableAndStopRetrying
110110
end
111111

112112
save_ip(ip_address_cidr, reservation, false)
@@ -123,12 +123,12 @@ def find_next_available_ip(addresses_we_cant_allocate, first_range_address, pref
123123

124124
while found == false
125125
current_prefix = to_ipaddr("#{current_ip.base_addr}/#{prefix}")
126+
filtered_ips.reject! { |ip| ip.to_range.last.to_i < current_prefix.to_i }
126127

127-
if filtered_ips.any? { |ip| current_prefix.include?(ip) }
128-
filtered_ips.reject! { |ip| ip.to_i < current_prefix.to_i }
129-
actual_ip_prefix = filtered_ips.first.count
130-
if actual_ip_prefix > current_prefix.count
131-
current_ip = to_ipaddr(current_ip.to_i + actual_ip_prefix)
128+
blocking_ip = filtered_ips.first
129+
if blocking_ip&.overlaps?(current_prefix)
130+
if blocking_ip.count > current_prefix.count
131+
current_ip = to_ipaddr(blocking_ip.to_i + blocking_ip.count)
132132
else
133133
current_ip = to_ipaddr(current_ip.to_i + current_prefix.count)
134134
end

src/bosh-director/lib/bosh/director/ip_addr_or_cidr.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ def first
8484
Bosh::Director::IpAddrOrCidr.new(@ipaddr.to_range.first.to_i)
8585
end
8686

87+
def overlaps?(other)
88+
my_first = @ipaddr.to_range.first.to_i
89+
my_last = @ipaddr.to_range.last.to_i
90+
other_first = other.to_range.first.to_i
91+
other_last = other.to_range.last.to_i
92+
my_first <= other_last && other_first <= my_last
93+
end
94+
8795
private
8896

8997
def max_addresses

src/bosh-director/spec/unit/bosh/director/deployment_plan/ip_provider/ip_repo_spec.rb

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,201 @@ def fail_saving_ips(ips, fail_error)
492492
it_behaves_like :retries_on_race_condition
493493
end
494494
end
495+
496+
context 'when handling CIDR blocks and overlapping ranges' do
497+
def save_ip_string(ip_string)
498+
ip_addr = to_ipaddr(ip_string)
499+
Bosh::Director::Models::IpAddress.new(
500+
address_str: ip_addr.to_s,
501+
network_name: 'my-manual-network',
502+
instance: instance_model,
503+
task_id: Bosh::Director::Config.current_job.task_id
504+
).save
505+
end
506+
507+
context 'when database has individual IPs that are contained in a reserved CIDR block' do
508+
it 'deduplicates and skips the entire CIDR block' do
509+
network_spec['subnets'].first['range'] = '10.0.11.32/27'
510+
network_spec['subnets'].first['gateway'] = '10.0.11.33'
511+
network_spec['subnets'].first['reserved'] = ['10.0.11.32 - 10.0.11.35', '10.0.11.63']
512+
network_spec['subnets'].first['static'] = ['10.0.11.36', '10.0.11.37', '10.0.11.38', '10.0.11.39', '10.0.11.40']
513+
514+
save_ip_string('10.0.11.32/32')
515+
save_ip_string('10.0.11.33/32')
516+
517+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
518+
expect(ip_address).to eq(cidr_ip('10.0.11.41'))
519+
end
520+
end
521+
522+
context 'when multiple overlapping CIDR blocks exist' do
523+
it 'deduplicates to largest block only' do
524+
network_spec['subnets'].first['range'] = '192.168.1.0/24'
525+
network_spec['subnets'].first['gateway'] = '192.168.1.1'
526+
network_spec['subnets'].first['reserved'] = [
527+
'192.168.1.0 - 192.168.1.15',
528+
'192.168.1.0 - 192.168.1.3',
529+
'192.168.1.4 - 192.168.1.7',
530+
'192.168.1.8',
531+
]
532+
533+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
534+
expect(ip_address).to eq(cidr_ip('192.168.1.16'))
535+
end
536+
end
537+
538+
context 'when nested CIDR blocks exist' do
539+
it 'deduplicates to outermost block' do
540+
network_spec['subnets'].first['range'] = '192.168.1.0/24'
541+
network_spec['subnets'].first['gateway'] = '192.168.1.1'
542+
network_spec['subnets'].first['reserved'] = [
543+
'192.168.1.0/24',
544+
'192.168.1.0/26',
545+
'192.168.1.0/28',
546+
]
547+
548+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
549+
expect(ip_address).to be_nil
550+
end
551+
end
552+
553+
context 'when adjacent non-overlapping CIDR blocks exist' do
554+
it 'preserves all blocks and skips each correctly' do
555+
network_spec['subnets'].first['range'] = '10.0.0.0/24'
556+
network_spec['subnets'].first['gateway'] = '10.0.0.1'
557+
network_spec['subnets'].first['reserved'] = [
558+
'10.0.0.0 - 10.0.0.3',
559+
'10.0.0.4 - 10.0.0.7',
560+
'10.0.0.8 - 10.0.0.11',
561+
]
562+
563+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
564+
expect(ip_address).to eq(cidr_ip('10.0.0.12'))
565+
end
566+
end
567+
568+
context 'when large CIDR block contains scattered individual IPs' do
569+
it 'deduplicates scattered IPs within the block' do
570+
network_spec['subnets'].first['range'] = '10.1.1.0/24'
571+
network_spec['subnets'].first['gateway'] = '10.1.1.1'
572+
network_spec['subnets'].first['reserved'] = ['10.1.1.0/24']
573+
network_spec['subnets'].first['static'] = []
574+
575+
save_ip_string('10.1.1.5/32')
576+
save_ip_string('10.1.1.50/32')
577+
save_ip_string('10.1.1.100/32')
578+
save_ip_string('10.1.1.200/32')
579+
580+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
581+
expect(ip_address).to be_nil
582+
end
583+
end
584+
585+
context 'when handling AWS reserved IP ranges' do
586+
it 'correctly skips reserved ranges with database IPs' do
587+
network_spec['subnets'].first['range'] = '10.0.11.32/27'
588+
network_spec['subnets'].first['gateway'] = '10.0.11.33'
589+
network_spec['subnets'].first['reserved'] = ['10.0.11.32 - 10.0.11.35', '10.0.11.63']
590+
network_spec['subnets'].first['static'] = []
591+
592+
save_ip_string('10.0.11.32/32')
593+
save_ip_string('10.0.11.33/32')
594+
save_ip_string('10.0.11.34/32')
595+
596+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
597+
expect(ip_address).to eq(cidr_ip('10.0.11.36'))
598+
end
599+
end
600+
601+
context 'when candidate block lands inside a reserved CIDR that started before it' do
602+
it 'does not allocate an address inside the reserved range' do
603+
# Reserved: 10.0.0.130/23 covers 10.0.130.0 - 10.0.131.255 (512 IPs).
604+
# Walking forward from 10.0.129.0/24: that first candidate overlaps
605+
# the /23, so we advance by 512, landing at 10.0.131.0. But .131.0
606+
# is still inside the /23. The pruning step must NOT discard the /23
607+
# just because its base (.130.0) < new candidate base (.131.0).
608+
# The first address outside the reserved range is 10.0.132.0.
609+
network_spec['subnets'].first['range'] = '10.0.128.0/21'
610+
network_spec['subnets'].first['gateway'] = '10.0.128.1'
611+
network_spec['subnets'].first['reserved'] = [
612+
'10.0.128.0 - 10.0.129.255',
613+
'10.0.130.0 - 10.0.131.255',
614+
'10.0.132.0 - 10.0.133.0',
615+
]
616+
network_spec['subnets'].first['static'] = []
617+
618+
ip_address = ip_repo.allocate_dynamic_ip(reservation, subnet)
619+
expect(ip_address).to eq(cidr_ip('10.0.133.1'))
620+
end
621+
end
622+
end
623+
624+
context 'when allocating dynamic IPs from an IPv6 subnet' do
625+
let(:ipv6_network_spec) do
626+
{
627+
'name' => 'my-manual-network',
628+
'subnets' => [
629+
{
630+
'range' => '2001:db8::/120',
631+
'gateway' => '2001:db8::1',
632+
'dns' => [],
633+
'static' => [],
634+
'reserved' => [],
635+
'cloud_properties' => {},
636+
'az' => 'az-1',
637+
},
638+
],
639+
}
640+
end
641+
642+
let(:ipv6_network) do
643+
ManualNetwork.parse(
644+
ipv6_network_spec,
645+
availability_zones,
646+
per_spec_logger,
647+
)
648+
end
649+
650+
let(:ipv6_subnet) do
651+
ManualNetworkSubnet.parse(
652+
ipv6_network.name,
653+
ipv6_network_spec['subnets'].first,
654+
availability_zones,
655+
)
656+
end
657+
658+
let(:ipv6_reservation) { Bosh::Director::DesiredNetworkReservation.new_dynamic(instance_model, ipv6_network) }
659+
660+
it 'returns the first available IPv6 address' do
661+
ip_address = ip_repo.allocate_dynamic_ip(ipv6_reservation, ipv6_subnet)
662+
expect(ip_address).to eq(cidr_ip('2001:db8::2'))
663+
end
664+
665+
it 'allocates sequential IPv6 addresses' do
666+
first = ip_repo.allocate_dynamic_ip(ipv6_reservation, ipv6_subnet)
667+
second = ip_repo.allocate_dynamic_ip(ipv6_reservation, ipv6_subnet)
668+
expect(first).to eq(cidr_ip('2001:db8::2'))
669+
expect(second).to eq(cidr_ip('2001:db8::3'))
670+
end
671+
672+
context 'when there are reserved IPv6 ranges' do
673+
it 'skips reserved addresses' do
674+
ipv6_network_spec['subnets'].first['reserved'] = ['2001:db8::2 - 2001:db8::4']
675+
676+
ip_address = ip_repo.allocate_dynamic_ip(ipv6_reservation, ipv6_subnet)
677+
expect(ip_address).to eq(cidr_ip('2001:db8::5'))
678+
end
679+
end
680+
681+
context 'when there are reserved IPv6 CIDR blocks' do
682+
it 'skips the entire CIDR block' do
683+
ipv6_network_spec['subnets'].first['reserved'] = ['2001:db8::/124']
684+
685+
ip_address = ip_repo.allocate_dynamic_ip(ipv6_reservation, ipv6_subnet)
686+
expect(ip_address).to eq(cidr_ip('2001:db8::10'))
687+
end
688+
end
689+
end
495690
end
496691

497692
describe :allocate_vip_ip do

src/bosh-director/spec/unit/bosh/director/ip_addr_or_cidr_spec.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,102 @@ module Bosh::Director
131131
end
132132
end
133133
end
134+
135+
describe '#overlaps?' do
136+
context 'when two /32 IPs are the same' do
137+
it 'returns true' do
138+
a = IpAddrOrCidr.new('10.0.0.1')
139+
b = IpAddrOrCidr.new('10.0.0.1')
140+
expect(a.overlaps?(b)).to be true
141+
end
142+
end
143+
144+
context 'when two /32 IPs are different' do
145+
it 'returns false' do
146+
a = IpAddrOrCidr.new('10.0.0.1')
147+
b = IpAddrOrCidr.new('10.0.0.2')
148+
expect(a.overlaps?(b)).to be false
149+
end
150+
end
151+
152+
context 'when a /32 is inside a CIDR block' do
153+
it 'returns true (IP inside block)' do
154+
ip = IpAddrOrCidr.new('192.168.1.5')
155+
block = IpAddrOrCidr.new('192.168.1.0/25')
156+
expect(ip.overlaps?(block)).to be true
157+
expect(block.overlaps?(ip)).to be true
158+
end
159+
end
160+
161+
context 'when a /32 is outside a CIDR block' do
162+
it 'returns false' do
163+
ip = IpAddrOrCidr.new('192.168.2.1')
164+
block = IpAddrOrCidr.new('192.168.1.0/25')
165+
expect(ip.overlaps?(block)).to be false
166+
expect(block.overlaps?(ip)).to be false
167+
end
168+
end
169+
170+
context 'when two CIDR blocks overlap partially' do
171+
it 'returns true' do
172+
a = IpAddrOrCidr.new('192.168.1.0/30')
173+
b = IpAddrOrCidr.new('192.168.1.2/30')
174+
expect(a.overlaps?(b)).to be true
175+
end
176+
end
177+
178+
context 'when two CIDR blocks are adjacent but non-overlapping' do
179+
it 'returns false' do
180+
a = IpAddrOrCidr.new('192.168.1.0/30')
181+
b = IpAddrOrCidr.new('192.168.1.4/30')
182+
expect(a.overlaps?(b)).to be false
183+
expect(b.overlaps?(a)).to be false
184+
end
185+
end
186+
187+
context 'when a smaller block is nested inside a larger block' do
188+
it 'returns true' do
189+
outer = IpAddrOrCidr.new('10.0.0.0/24')
190+
inner = IpAddrOrCidr.new('10.0.0.128/25')
191+
expect(outer.overlaps?(inner)).to be true
192+
expect(inner.overlaps?(outer)).to be true
193+
end
194+
end
195+
196+
context 'when CIDR blocks are completely disjoint' do
197+
it 'returns false' do
198+
a = IpAddrOrCidr.new('10.0.0.0/24')
199+
b = IpAddrOrCidr.new('10.0.1.0/24')
200+
expect(a.overlaps?(b)).to be false
201+
end
202+
end
203+
204+
context 'with the exact scenario that triggers the IPAddr coercion bug' do
205+
it 'correctly detects overlap between /32 and /25' do
206+
ip32 = IpAddrOrCidr.new('192.168.1.0')
207+
block25 = IpAddrOrCidr.new('192.168.1.0/25')
208+
expect(ip32.overlaps?(block25)).to be true
209+
210+
# A /32 NOT in the /25 range
211+
ip_outside = IpAddrOrCidr.new('192.168.1.200')
212+
expect(ip_outside.overlaps?(block25)).to be false
213+
end
214+
end
215+
216+
context 'with IPv6 addresses' do
217+
it 'detects overlapping IPv6 ranges' do
218+
a = IpAddrOrCidr.new('fd00::/64')
219+
b = IpAddrOrCidr.new('fd00::1')
220+
expect(a.overlaps?(b)).to be true
221+
expect(b.overlaps?(a)).to be true
222+
end
223+
224+
it 'returns false for non-overlapping IPv6 ranges' do
225+
a = IpAddrOrCidr.new('fd00::/64')
226+
b = IpAddrOrCidr.new('fd01::1')
227+
expect(a.overlaps?(b)).to be false
228+
end
229+
end
230+
end
134231
end
135232
end

src/spec/integration/global_networking/ip_reservations/allocating_dynamic_ips_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,5 +249,27 @@ def deploy_with_static_ip(deployment_name, ip, range)
249249
expect(second_deploy_instances.first.ips).to eq(first_deploy_instances.first.ips)
250250
expect(second_deploy_instances.first.vm_cid).to eq(first_deploy_instances.first.vm_cid)
251251
end
252+
253+
it 'does not allocate IPs within a CIDR reserved range' do
254+
cloud_config_hash = SharedSupport::DeploymentManifestHelper.simple_cloud_config
255+
cloud_config_hash['networks'].first['subnets'] = [
256+
{
257+
'range' => '192.168.1.0/24',
258+
'gateway' => '192.168.1.1',
259+
'dns' => [],
260+
'static' => [],
261+
'reserved' => ['192.168.1.0/28'],
262+
'cloud_properties' => {},
263+
}
264+
]
265+
266+
manifest_hash = SharedSupport::DeploymentManifestHelper.deployment_manifest(name: 'my-deploy', instances: 2)
267+
268+
upload_cloud_config(cloud_config_hash: cloud_config_hash)
269+
deploy_simple_manifest(manifest_hash: manifest_hash)
270+
271+
deployed_ips = director.instances(deployment_name: 'my-deploy').map(&:ips).flatten
272+
expect(deployed_ips).to match_array(['192.168.1.16', '192.168.1.17'])
273+
end
252274
end
253275
end

0 commit comments

Comments
 (0)