Merge lp:~mpontillo/maas/suggested-range-1562198 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 4911
Proposed branch: lp:~mpontillo/maas/suggested-range-1562198
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 790 lines (+423/-83)
12 files modified
src/maasserver/api/subnets.py (+8/-1)
src/maasserver/api/tests/test_subnets.py (+19/-4)
src/maasserver/models/subnet.py (+12/-4)
src/maasserver/static/js/angular/controllers/tests/test_vlan_details.js (+57/-56)
src/maasserver/static/js/angular/controllers/vlan_details.js (+25/-6)
src/maasserver/static/partials/vlan-details.html (+3/-3)
src/maasserver/testing/factory.py (+3/-2)
src/maasserver/websockets/handlers/subnet.py (+2/-1)
src/maasserver/websockets/handlers/tests/test_subnet.py (+2/-1)
src/maastesting/factory.py (+4/-2)
src/provisioningserver/utils/network.py (+163/-3)
src/provisioningserver/utils/tests/test_network.py (+125/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/suggested-range-1562198
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+291699@code.launchpad.net

Commit message

Add suggested values for gateway and dynamic range to IP range calculations.

 * Calculate a good guess for a practical size for the dynamic range.
 * Present default ranges to the user when providing DHCP.
 * Make sure the placeholder matches the initial values of the suggestions.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks really good. Only issue is you should be used data-ng-placeholder instead of placing a template into the placeholder attribute. data-ng-placeholder is a directive that MAAS adds into angular.

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Thanks. Going to try to land this now; wish me luck. ;-)

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.0 MiB)

The attempt to merge lp:~mpontillo/maas/suggested-range-1562198 into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://security.ubuntu.com/ubuntu xenial-security InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease [247 kB]
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Sources [875 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main amd64 Packages [1,197 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe amd64 Packages [7,531 kB]
Fetched 9,850 kB in 1s (4,989 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind bash bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-bson python3-convoy python3-coverage python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-mock python3-netaddr python3-netifaces python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
apache2 is already the newest version (2.4.18-2ubuntu2).
archdetect-deb is already the newest version (1.117ubuntu1).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
bind9 is already the newest version (1:9.10.3.dfsg.P4-5).
bind9utils is already the newest version (1:9.10.3.dfsg.P4-5).
build-essential is already the newest version (12.1ubuntu2).
curl is already the newest version (7.47.0-1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
dnsutils is already the newest version (1:9.10.3.dfsg.P4-5).
firefox is already the newest version (45.0.1+build1-0ubuntu1).
freeipmi-tools is already the newest version (1.4.11-1ubuntu1).
git is already the newest version (1:...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (32.9 KiB)

The attempt to merge lp:~mpontillo/maas/suggested-range-1562198 into lp:maas failed. Below is the output from the failed tests.

Get:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease [247 kB]
Hit:2 http://security.ubuntu.com/ubuntu xenial-security InRelease
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Sources [875 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe Sources [7,751 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main amd64 Packages [1,197 kB]
Get:8 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Translation-en [567 kB]
Get:9 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe amd64 Packages [7,530 kB]
Get:10 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe Translation-en [4,358 kB]
Fetched 22.5 MB in 4s (4,841 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind bash bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-bson python3-convoy python3-coverage python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-mock python3-netaddr python3-netifaces python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
apache2 is already the newest version (2.4.18-2ubuntu2).
archdetect-deb is already the newest version (1.117ubuntu1).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
bind9 is already the newest version (1:9.10.3.dfsg.P4-5).
bind9utils is already the newest version (1:9.10.3.dfsg.P4-5).
build-essential is already the newest version (12.1ubuntu2).
curl is already the newest version (7.47.0-1ubuntu2).
debhelper is already the newest version (...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.0 MiB)

The attempt to merge lp:~mpontillo/maas/suggested-range-1562198 into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://security.ubuntu.com/ubuntu xenial-security InRelease
Hit:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind bash bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm postgresql pxelinux python3-all python3-apt python3-bson python3-convoy python3-coverage python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-mock python3-netaddr python3-netifaces python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
apache2 is already the newest version (2.4.18-2ubuntu2).
archdetect-deb is already the newest version (1.117ubuntu1).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
bind9 is already the newest version (1:9.10.3.dfsg.P4-5).
bind9utils is already the newest version (1:9.10.3.dfsg.P4-5).
build-essential is already the newest version (12.1ubuntu2).
curl is already the newest version (7.47.0-1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
dnsutils is already the newest version (1:9.10.3.dfsg.P4-5).
firefox is already the newest version (45.0.1+build1-0ubuntu1).
freeipmi-tools is already the newest version (1.4.11-1ubuntu1).
git is already the newest version (1:2.7.4-0ubuntu1).
isc-dhcp-common is already the newest version (4.3.3-5ubuntu11).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
libpq-dev...

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Random failure in:

maasserver.rpc.tests.test_regionservice.TestRegionService.test_start_up_logs_failure_if_all_endpoint_options_fail

Trying again.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/subnets.py'
2--- src/maasserver/api/subnets.py 2016-03-28 13:54:47 +0000
3+++ src/maasserver/api/subnets.py 2016-04-13 03:03:16 +0000
4@@ -185,6 +185,8 @@
5 Optional arguments:
6 include_ranges: if True, includes detailed information
7 about the usage of this range.
8+ include_suggestions: if True, includes the suggested gateway and
9+ dynamic range for this subnet, if it were to be configured.
10
11 Returns 404 if the subnet is not found.
12 """
13@@ -192,9 +194,14 @@
14 subnet_id, request.user, NODE_PERMISSION.VIEW)
15 include_ranges = get_optional_param(
16 request.GET, 'include_ranges', default=False, validator=StringBool)
17+ include_suggestions = get_optional_param(
18+ request.GET, 'include_suggestions', default=False,
19+ validator=StringBool)
20 full_iprange = subnet.get_iprange_usage()
21 statistics = IPRangeStatistics(full_iprange)
22- return statistics.render_json(include_ranges=include_ranges)
23+ return statistics.render_json(
24+ include_ranges=include_ranges,
25+ include_suggestions=include_suggestions)
26
27 @operation(idempotent=True)
28 def ip_addresses(self, request, subnet_id):
29
30=== modified file 'src/maasserver/api/tests/test_subnets.py'
31--- src/maasserver/api/tests/test_subnets.py 2016-04-11 09:55:54 +0000
32+++ src/maasserver/api/tests/test_subnets.py 2016-04-13 03:03:16 +0000
33@@ -29,8 +29,10 @@
34 IPRangeStatistics,
35 )
36 from testtools.matchers import (
37+ Contains,
38 ContainsDict,
39 Equals,
40+ HasLength,
41 )
42
43
44@@ -323,8 +325,8 @@
45
46 class TestSubnetReservedIPRangesAPI(APITestCase):
47
48- def test__returns_empty_list_for_empty_subnet(self):
49- subnet = factory.make_Subnet(dns_servers=[], gateway_ip='')
50+ def test__returns_empty_list_for_empty_ipv4_subnet(self):
51+ subnet = factory.make_Subnet(version=4, dns_servers=[], gateway_ip='')
52 response = self.client.get(
53 get_subnet_uri(subnet),
54 {'op': 'reserved_ip_ranges'})
55@@ -334,6 +336,19 @@
56 result = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
57 self.assertThat(result, Equals([]))
58
59+ def test__returns_reserved_anycast_for_empty_ipv6_subnet(self):
60+ subnet = factory.make_Subnet(version=6, dns_servers=[], gateway_ip='')
61+ response = self.client.get(
62+ get_subnet_uri(subnet),
63+ {'op': 'reserved_ip_ranges'})
64+ self.assertEqual(
65+ http.client.OK, response.status_code,
66+ explain_unexpected_response(http.client.OK, response))
67+ result = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
68+ self.assertThat(result, HasLength(1))
69+ self.assertThat(result[0]["num_addresses"], Equals(1))
70+ self.assertThat(result[0]["purpose"], Contains("rfc-4291-2.6.1"))
71+
72 def test__accounts_for_reserved_ip_address(self):
73 subnet = factory.make_Subnet(dns_servers=[], gateway_ip='')
74 ip = factory.pick_ip_in_network(subnet.get_ipnetwork())
75@@ -346,13 +361,13 @@
76 http.client.OK, response.status_code,
77 explain_unexpected_response(http.client.OK, response))
78 result = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
79- self.assertThat(result, Equals([
80+ self.assertThat(result, Contains(
81 {
82 "start": ip,
83 "end": ip,
84 "purpose": ["assigned-ip"],
85 "num_addresses": 1,
86- }]))
87+ }))
88
89
90 class TestSubnetUnreservedIPRangesAPI(APITestCase):
91
92=== modified file 'src/maasserver/models/subnet.py'
93--- src/maasserver/models/subnet.py 2016-04-11 09:39:42 +0000
94+++ src/maasserver/models/subnet.py 2016-04-13 03:03:16 +0000
95@@ -414,10 +414,10 @@
96 """
97 ranges = set()
98 network = self.get_ipnetwork()
99- if network.version == 6 and network.prefixlen == 64:
100+ if network.version == 6:
101 # For most IPv6 networks, automatically reserve the range:
102- # ::0 - ::ffff:ffff
103- # We expect the administrator will be using ::0 through ::ffff.
104+ # ::1 - ::ffff:ffff
105+ # We expect the administrator will be using ::1 through ::ffff.
106 # We plan to reserve ::1:0 through ::ffff:ffff for use by MAAS,
107 # so that we can allocate addresses in the form:
108 # ::<node>:<child>
109@@ -425,8 +425,16 @@
110 # *outside* both ranges, so that they won't conflict with addresses
111 # reserved from this scheme in the future.
112 first = str(IPAddress(network.first))
113+ first_plus_one = str(IPAddress(network.first + 1))
114 second = str(IPAddress(network.first + 0xFFFFFFFF))
115- ranges |= {make_iprange(first, second, purpose="reserved")}
116+ if network.prefixlen == 64:
117+ ranges |= {make_iprange(
118+ first_plus_one, second, purpose="reserved")}
119+ # Reserve the subnet router anycast address, except for /127 and
120+ # /128 networks. (See RFC 6164, and RFC 4291 section 2.6.1.)
121+ if network.prefixlen < 127:
122+ ranges |= {make_iprange(
123+ first, first, purpose="rfc-4291-2.6.1")}
124 ipnetwork = self.get_ipnetwork()
125 if not ranges_only:
126 assigned_ip_addresses = self.get_staticipaddresses_in_use()
127
128=== modified file 'src/maasserver/static/js/angular/controllers/tests/test_vlan_details.js'
129--- src/maasserver/static/js/angular/controllers/tests/test_vlan_details.js 2016-03-28 13:54:47 +0000
130+++ src/maasserver/static/js/angular/controllers/tests/test_vlan_details.js 2016-04-13 03:03:16 +0000
131@@ -451,75 +451,76 @@
132 });
133
134
135- it("prepares provideDHCPAction on actionOptionChanged", function() {
136- var controller = makeControllerResolveSetActiveItem();
137- controller.actionOption = controller.PROVIDE_DHCP_ACTION;
138- controller.actionOptionChanged();
139- expect(controller.provideDHCPAction).toEqual({
140- subnet: subnet.id,
141- primaryRack: "p1",
142- secondaryRack: "p2",
143- maxIPs: 0,
144- startIP: '',
145- endIP: '',
146- gatewayIP: '',
147- needsGatewayIP: false,
148- subnetMissingGatewayIP: true,
149- needsDynamicRange: true
150- });
151- });
152-
153- it("provideDHCPAction skips dynamic range if already present", function() {
154- var controller = makeControllerResolveSetActiveItem();
155- controller.subnets[0].statistics.ranges = [{purpose: ["dynamic"]}];
156- controller.actionOption = controller.PROVIDE_DHCP_ACTION;
157- controller.actionOptionChanged();
158- expect(controller.provideDHCPAction).toEqual({
159- subnet: subnet.id,
160- primaryRack: "p1",
161- secondaryRack: "p2",
162- maxIPs: 0,
163- startIP: null,
164- endIP: null,
165- gatewayIP: '',
166- needsGatewayIP: false,
167+ it("prepares provideDHCPAction on actionOptionChanged " +
168+ "and populates suggested gateway", function() {
169+ var controller = makeControllerResolveSetActiveItem();
170+ controller.subnets[0].gateway_ip = null;
171+ controller.subnets[0].statistics = {
172+ suggested_gateway: "192.168.0.1"
173+ };
174+ controller.updateSubnet();
175+ controller.actionOption = controller.PROVIDE_DHCP_ACTION;
176+ controller.actionOptionChanged();
177+ expect(controller.provideDHCPAction).toEqual({
178+ subnet: subnet.id,
179+ primaryRack: "p1",
180+ secondaryRack: "p2",
181+ maxIPs: 0,
182+ startIP: "",
183+ startPlaceholder: "(no available IPs)",
184+ endIP: "",
185+ endPlaceholder: "(no available IPs)",
186+ gatewayIP: '192.168.0.1',
187+ gatewayPlaceholder: '192.168.0.1',
188+ needsGatewayIP: true,
189 subnetMissingGatewayIP: true,
190 needsDynamicRange: false
191 });
192 });
193
194+ it("provideDHCPAction uses suggested dynamic range", function() {
195+ var controller = makeControllerResolveSetActiveItem();
196+ controller.subnets[0].statistics = {
197+ num_addresses: 26,
198+ suggested_dynamic_range: {
199+ num_addresses: 26,
200+ start: "192.168.0.200",
201+ end: "192.168.0.225"
202+ },
203+ first_address: "192.168.0.1"
204+ };
205+ controller.updateSubnet();
206+ controller.actionOption = controller.PROVIDE_DHCP_ACTION;
207+ controller.actionOptionChanged();
208+ expect(controller.provideDHCPAction).toEqual({
209+ subnet: subnet.id,
210+ primaryRack: "p1",
211+ secondaryRack: "p2",
212+ maxIPs: 26,
213+ startIP: "192.168.0.200",
214+ endIP: "192.168.0.225",
215+ startPlaceholder: "192.168.0.200",
216+ endPlaceholder: "192.168.0.225",
217+ gatewayIP: '',
218+ gatewayPlaceholder: '',
219+ needsGatewayIP: false,
220+ subnetMissingGatewayIP: true,
221+ needsDynamicRange: true
222+ });
223+ });
224+
225 it("prevents selection of a duplicate rack controller", function() {
226 var controller = makeControllerResolveSetActiveItem();
227 controller.actionOption = controller.PROVIDE_DHCP_ACTION;
228 controller.actionOptionChanged();
229 controller.provideDHCPAction.primaryRack = "p2";
230 controller.updatePrimaryRack();
231- expect(controller.provideDHCPAction).toEqual({
232- subnet: subnet.id,
233- primaryRack: "p2",
234- secondaryRack: null,
235- maxIPs: 0,
236- startIP: '',
237- endIP: '',
238- gatewayIP: '',
239- needsGatewayIP: false,
240- subnetMissingGatewayIP: true,
241- needsDynamicRange: true
242- });
243+ expect(controller.provideDHCPAction.primaryRack).toEqual("p2");
244+ expect(controller.provideDHCPAction.secondaryRack).toBe(null);
245 controller.provideDHCPAction.secondaryRack = "p2";
246 controller.updateSecondaryRack();
247- expect(controller.provideDHCPAction).toEqual({
248- subnet: subnet.id,
249- primaryRack: null,
250- secondaryRack: null,
251- maxIPs: 0,
252- startIP: '',
253- endIP: '',
254- gatewayIP: '',
255- needsGatewayIP: false,
256- subnetMissingGatewayIP: true,
257- needsDynamicRange: true
258- });
259+ expect(controller.provideDHCPAction.primaryRack).toBe(null);
260+ expect(controller.provideDHCPAction.secondaryRack).toBe(null);
261 });
262
263 describe("filterPrimaryRack", function() {
264
265=== modified file 'src/maasserver/static/js/angular/controllers/vlan_details.js'
266--- src/maasserver/static/js/angular/controllers/vlan_details.js 2016-03-28 13:54:47 +0000
267+++ src/maasserver/static/js/angular/controllers/vlan_details.js 2016-04-13 03:03:16 +0000
268@@ -102,8 +102,11 @@
269 for (i = 0; i < vm.relatedSubnets.length; i++) {
270 subnet = vm.relatedSubnets[i].subnet;
271 // If any related subnet already has a dynamic range, we
272- // cannot prompt the user to enter one here.
273- if (SubnetsManager.hasDynamicRange(subnet)) {
274+ // cannot prompt the user to enter one here. If a
275+ // suggestion does not exist, a range does not exist
276+ // already.
277+ var iprange = subnet.statistics.suggested_dynamic_range;
278+ if (!angular.isObject(iprange)) {
279 // If there is already a dynamic range on one of the
280 // subnets, it's the "subnet of least surprise" if
281 // the user is choosing to reconfigure their rack
282@@ -186,20 +189,36 @@
283 vm.updateSubnet = function() {
284 var dhcp = vm.provideDHCPAction;
285 var subnet = SubnetsManager.getItemFromList(dhcp.subnet);
286- if(angular.isObject(subnet) && dhcp.needsDynamicRange === true) {
287- var iprange = SubnetsManager.getLargestRange(subnet);
288- if(iprange.num_addresses > 0) {
289+ if(angular.isObject(subnet)) {
290+ var suggested_gateway = null;
291+ var iprange = null;
292+ if(angular.isObject(subnet.statistics)) {
293+ suggested_gateway = subnet.statistics.suggested_gateway;
294+ iprange = subnet.statistics.suggested_dynamic_range;
295+ }
296+ if(angular.isObject(iprange) && iprange.num_addresses > 0) {
297 dhcp.maxIPs = iprange.num_addresses;
298 dhcp.startIP = iprange.start;
299 dhcp.endIP = iprange.end;
300- dhcp.gatewayIP = iprange.start;
301+ dhcp.startPlaceholder = iprange.start;
302+ dhcp.endPlaceholder = iprange.end;
303 } else {
304 // Need to add a dynamic range, but according to our data,
305 // there is no room on the subnet for a dynamic range.
306 dhcp.maxIPs = 0;
307 dhcp.startIP = "";
308 dhcp.endIP = "";
309+ dhcp.startPlaceholder = "(no available IPs)";
310+ dhcp.endPlaceholder = "(no available IPs)";
311+ }
312+ if(angular.isString(suggested_gateway)) {
313+ dhcp.gatewayIP = suggested_gateway;
314+ dhcp.gatewayPlaceholder = suggested_gateway;
315+ } else {
316+ // This means the subnet already has a gateway, so don't
317+ // bother populating it.
318 dhcp.gatewayIP = "";
319+ dhcp.gatewayPlaceholder = "";
320 }
321 } else {
322 // Don't need to add a dynamic range, so ensure these fields
323
324=== modified file 'src/maasserver/static/partials/vlan-details.html'
325--- src/maasserver/static/partials/vlan-details.html 2016-04-11 13:52:21 +0000
326+++ src/maasserver/static/partials/vlan-details.html 2016-04-13 03:03:16 +0000
327@@ -78,7 +78,7 @@
328 name="start-ip"
329 size="39"
330 class="three-col"
331- placeholder="192.168.0.1"
332+ data-ng-placeholder="vlanDetails.provideDHCPAction.startPlaceholder"
333 data-ng-model="vlanDetails.provideDHCPAction.startIP"
334 data-ng-disabled="!vlanDetails.provideDHCPAction.subnet"
335 data-ng-change="vlanDetails.updateStartIP()">
336@@ -89,7 +89,7 @@
337 name="end-ip"
338 size="39"
339 class="three-col"
340- placeholder="192.168.0.10"
341+ data-ng-placeholder="vlanDetails.provideDHCPAction.endPlaceholder"
342 data-ng-model="vlanDetails.provideDHCPAction.endIP"
343 data-ng-disabled="!vlanDetails.provideDHCPAction.subnet"
344 data-ng-change="vlanDetails.updateEndIP()">
345@@ -101,7 +101,7 @@
346 name="gateway-ip"
347 size="39"
348 class="three-col"
349- placeholder="0.0.0.0"
350+ data-ng-placeholder="vlanDetails.provideDHCPAction.gatewayPlaceholder"
351 data-ng-model="vlanDetails.provideDHCPAction.gatewayIP"
352 data-ng-disabled="!vlanDetails.provideDHCPAction.subnet"
353 data-ng-change="vlanDetails.updatendIP()">
354
355=== modified file 'src/maasserver/testing/factory.py'
356--- src/maasserver/testing/factory.py 2016-04-11 20:42:12 +0000
357+++ src/maasserver/testing/factory.py 2016-04-13 03:03:16 +0000
358@@ -743,7 +743,7 @@
359
360 def make_Subnet(self, name=None, vlan=None, space=None, cidr=None,
361 gateway_ip=None, dns_servers=None, host_bits=None,
362- fabric=None, vid=None, dhcp_on=False,
363+ fabric=None, vid=None, dhcp_on=False, version=None,
364 rdns_mode=RDNS_MODE.DEFAULT, allow_proxy=True):
365 if name is None:
366 name = factory.make_name('name')
367@@ -753,7 +753,8 @@
368 space = factory.make_Space()
369 network = None
370 if cidr is None:
371- network = factory.make_ip4_or_6_network(host_bits=host_bits)
372+ network = factory.make_ip4_or_6_network(
373+ version=version, host_bits=host_bits)
374 cidr = str(network.cidr)
375 if gateway_ip is None:
376 network = IPNetwork(cidr) if network is None else network
377
378=== modified file 'src/maasserver/websockets/handlers/subnet.py'
379--- src/maasserver/websockets/handlers/subnet.py 2016-03-28 13:54:47 +0000
380+++ src/maasserver/websockets/handlers/subnet.py 2016-04-13 03:03:16 +0000
381@@ -39,7 +39,8 @@
382 def dehydrate(self, subnet, data, for_list=False):
383 full_range = subnet.get_iprange_usage()
384 metadata = IPRangeStatistics(full_range)
385- data['statistics'] = metadata.render_json(include_ranges=True)
386+ data['statistics'] = metadata.render_json(
387+ include_ranges=True, include_suggestions=True)
388 data['version'] = IPNetwork(subnet.cidr).version
389 if not for_list:
390 data["ip_addresses"] = subnet.render_json_for_related_ips(
391
392=== modified file 'src/maasserver/websockets/handlers/tests/test_subnet.py'
393--- src/maasserver/websockets/handlers/tests/test_subnet.py 2016-03-28 13:54:47 +0000
394+++ src/maasserver/websockets/handlers/tests/test_subnet.py 2016-04-13 03:03:16 +0000
395@@ -33,7 +33,8 @@
396 }
397 full_range = subnet.get_iprange_usage()
398 metadata = IPRangeStatistics(full_range)
399- data['statistics'] = metadata.render_json(include_ranges=True)
400+ data['statistics'] = metadata.render_json(
401+ include_ranges=True, include_suggestions=True)
402 data['version'] = IPNetwork(subnet.cidr).version
403 if not for_list:
404 data["ip_addresses"] = subnet.render_json_for_related_ips(
405
406=== modified file 'src/maastesting/factory.py'
407--- src/maastesting/factory.py 2016-03-28 13:54:47 +0000
408+++ src/maastesting/factory.py 2016-04-13 03:03:16 +0000
409@@ -273,10 +273,12 @@
410 slash=slash, but_not=but_not, disjoint_from=disjoint_from,
411 random_address_factory=self.make_ipv6_address)
412
413- def make_ip4_or_6_network(self, host_bits=None):
414+ def make_ip4_or_6_network(self, version=None, host_bits=None):
415 """Generate a random IPv4 or IPv6 network."""
416 slash = None
417- if random.randint(0, 1) == 0:
418+ if version is None:
419+ version = random.choice([4, 6])
420+ if version == 4:
421 if host_bits is not None:
422 slash = 32 - host_bits
423 return self.make_ipv4_network(slash=slash)
424
425=== modified file 'src/provisioningserver/utils/network.py'
426--- src/provisioningserver/utils/network.py 2016-03-28 13:54:47 +0000
427+++ src/provisioningserver/utils/network.py 2016-04-13 03:03:16 +0000
428@@ -54,6 +54,14 @@
429 ]
430
431
432+class IPRANGE_TYPE:
433+ """Well-known purpose types for IP ranges."""
434+ UNUSED = 'unused'
435+ GATEWAY_IP = 'gateway-ip'
436+ DYNAMIC = 'dynamic'
437+ PROPOSED_DYNAMIC = 'proposed-dynamic'
438+
439+
440 class MAASIPRange(IPRange):
441 """IPRange object whose default end address is the start address if not
442 specified. Capable of storing a string to indicate the purpose of
443@@ -144,17 +152,103 @@
444 to cover every possible IP address present in the desired range."""
445 def __init__(self, full_maasipset):
446 self.ranges = full_maasipset
447+ self.first_address_value = self.ranges.first
448+ self.last_address_value = self.ranges.last
449+ self.ip_version = IPAddress(self.ranges.last).version
450+ self.first_address = str(IPAddress(self.first_address_value))
451+ self.last_address = str(IPAddress(self.last_address_value))
452 self.num_available = 0
453 self.num_unavailable = 0
454 self.largest_available = 0
455+ self.suggested_gateway = None
456+ self.suggested_dynamic_range = None
457 for range in full_maasipset.ranges:
458- if 'unused' in range.purpose:
459+ if IPRANGE_TYPE.UNUSED in range.purpose:
460 self.num_available += range.num_addresses
461 if range.num_addresses > self.largest_available:
462 self.largest_available = range.num_addresses
463 else:
464 self.num_unavailable += range.num_addresses
465 self.total_addresses = self.num_available + self.num_unavailable
466+ if not self.ranges.includes_purpose(IPRANGE_TYPE.GATEWAY_IP):
467+ self.suggested_gateway = self.get_recommended_gateway()
468+ if not self.ranges.includes_purpose(IPRANGE_TYPE.DYNAMIC):
469+ self.suggested_dynamic_range = self.get_recommended_dynamic_range()
470+
471+ def get_recommended_gateway(self):
472+ """Returns a suggested gateway for the set of ranges in `self.ranges`.
473+ Will attempt to choose the first IP address available, then the last IP
474+ address available, then the first IP address in the first unused range,
475+ in that order of preference.
476+
477+ Must be called after the range usage has been calculated.
478+ """
479+ suggested_gateway = None
480+ first_address = self.first_address_value
481+ last_address = self.last_address_value
482+ if self.ip_version == 6:
483+ # For IPv6 addresses, always return the subnet-router anycast
484+ # address. (See RFC 4291 section 2.6.1 for more information.)
485+ return str(IPAddress(first_address - 1))
486+ if self.ranges.is_unused(first_address):
487+ suggested_gateway = str(IPAddress(first_address))
488+ elif self.ranges.is_unused(last_address):
489+ suggested_gateway = str(IPAddress(last_address))
490+ else:
491+ first_unused = self.ranges.get_first_unused_ip()
492+ if first_unused is not None:
493+ suggested_gateway = str(IPAddress(first_unused))
494+ return suggested_gateway
495+
496+ def get_recommended_dynamic_range(self):
497+ """Returns a recommended dynamic range for the set of ranges in
498+ `self.ranges`, or None if one could not be found.
499+
500+ Must be called after the recommended gateway is selected, the
501+ range usage has been calculated, and the number of total and available
502+ addresses have been determined.
503+ """
504+ largest_unused = self.ranges.get_largest_unused_block()
505+ if largest_unused is None:
506+ return None
507+ if self.suggested_gateway is not None and largest_unused.size == 1:
508+ # Can't suggest a range if we're also suggesting the only available
509+ # IP address as the gateway.
510+ return None
511+ candidate = MAASIPRange(
512+ largest_unused.first, largest_unused.last,
513+ purpose=IPRANGE_TYPE.PROPOSED_DYNAMIC)
514+ # Adjust the largest unused block if it contains the suggested gateway.
515+ if self.suggested_gateway is not None:
516+ gateway_value = IPAddress(self.suggested_gateway).value
517+ if gateway_value in candidate:
518+ # The suggested gateway is going to be either the first
519+ # or the last IP address in the range.
520+ if gateway_value == candidate.first:
521+ candidate = MAASIPRange(
522+ candidate.first + 1, candidate.last,
523+ purpose=IPRANGE_TYPE.PROPOSED_DYNAMIC)
524+ else:
525+ # Must be the last address.
526+ candidate = MAASIPRange(
527+ candidate.first, candidate.last - 1,
528+ purpose=IPRANGE_TYPE.PROPOSED_DYNAMIC)
529+ if candidate is not None:
530+ one_fourth_range = self.total_addresses >> 2
531+ half_remaining_space = self.num_available >> 1
532+ if candidate.size > one_fourth_range:
533+ # Prevent the proposed range from taking up too much available
534+ # space in the subnet.
535+ candidate = MAASIPRange(
536+ candidate.last - one_fourth_range, candidate.last,
537+ purpose=IPRANGE_TYPE.PROPOSED_DYNAMIC)
538+ elif candidate.size >= half_remaining_space:
539+ # Prevent the proposed range from taking up the remainder of
540+ # the available IP addresses. (take at most half.)
541+ candidate = MAASIPRange(
542+ candidate.last - half_remaining_space + 1, candidate.last,
543+ purpose=IPRANGE_TYPE.PROPOSED_DYNAMIC)
544+ return candidate
545
546 @property
547 def available_percentage(self):
548@@ -180,7 +274,7 @@
549 :return:unicode"""
550 return "{0:.0%}".format(self.usage_percentage)
551
552- def render_json(self, include_ranges=False):
553+ def render_json(self, include_ranges=False, include_suggestions=False):
554 """Returns a representation of the statistics suitable for rendering
555 into JSON format."""
556 data = {
557@@ -191,9 +285,17 @@
558 "usage": self.usage_percentage,
559 "usage_string": self.usage_percentage_string,
560 "available_string": self.available_percentage_string,
561+ "first_address": self.first_address,
562+ "last_address": self.last_address,
563+ "ip_version": self.ip_version
564 }
565 if include_ranges:
566 data["ranges"] = self.ranges.render_json()
567+ if include_suggestions:
568+ data["suggested_gateway"] = self.suggested_gateway
569+ data["suggested_dynamic_range"] = (
570+ self.suggested_dynamic_range.render_json()
571+ )
572 return data
573
574
575@@ -224,6 +326,64 @@
576 return item
577 return None
578
579+ @property
580+ def first(self):
581+ """Returns the first IP address in this set."""
582+ return self.ranges[0].first
583+
584+ @property
585+ def last(self):
586+ """Returns the last IP address in this set."""
587+ return self.ranges[-1].last
588+
589+ def ip_has_purpose(self, ip, purpose):
590+ """Returns True if the specified IP address has the specified purpose
591+ in this set; False otherwise.
592+ """
593+ range = self.find(ip)
594+ if range is None:
595+ raise ValueError(
596+ "IP address %s does not exist in range (%s-%s)." % (
597+ ip, self.first, self.last))
598+ return purpose in range.purpose
599+
600+ def is_unused(self, ip):
601+ """Returns True if the specified IP address (which must be within the
602+ ranges in this set) is unused; False otherwise."""
603+ return self.ip_has_purpose(ip, IPRANGE_TYPE.UNUSED)
604+
605+ def includes_purpose(self, purpose):
606+ """Returns True if the specified purpose is found inside any of the
607+ ranges in this set, otherwise returns False"""
608+ for item in self.ranges:
609+ if purpose in item.purpose:
610+ return True
611+ return False
612+
613+ def get_first_unused_ip(self):
614+ """Returns the integer value of the first unused IP address in the set.
615+ """
616+ for item in self.ranges:
617+ if IPRANGE_TYPE.UNUSED in item.purpose:
618+ return item.first
619+ return None
620+
621+ def get_largest_unused_block(self):
622+ """Find the largest unused block of addresses in this set."""
623+ class NullIPRange:
624+ """Throwaway class to represent an empty IP range."""
625+ def __init__(self):
626+ self.size = 0
627+
628+ largest = NullIPRange()
629+ for item in self.ranges:
630+ if IPRANGE_TYPE.UNUSED in item.purpose:
631+ if item.size >= largest.size:
632+ largest = item
633+ if largest.size == 0:
634+ return None
635+ return largest
636+
637 def render_json(self, *args, **kwargs):
638 return [
639 iprange.render_json(*args, **kwargs)
640@@ -236,7 +396,7 @@
641 def __contains__(self, item):
642 return bool(self.find(item))
643
644- def get_unused_ranges(self, outer_range, comment="unused"):
645+ def get_unused_ranges(self, outer_range, comment=IPRANGE_TYPE.UNUSED):
646 """Calculates and returns a list of unused IP ranges, based on
647 the supplied range of desired addresses.
648
649
650=== modified file 'src/provisioningserver/utils/tests/test_network.py'
651--- src/provisioningserver/utils/tests/test_network.py 2016-03-28 13:54:47 +0000
652+++ src/provisioningserver/utils/tests/test_network.py 2016-04-13 03:03:16 +0000
653@@ -51,6 +51,7 @@
654 from testtools.matchers import (
655 Contains,
656 Equals,
657+ HasLength,
658 Is,
659 MatchesDict,
660 MatchesSetwise,
661@@ -788,6 +789,130 @@
662 self.assertThat(json['available_string'], Equals("100%"))
663 self.assertThat(json, Not(Contains("ranges")))
664
665+ def test__suggests_subnet_anycast_address_for_ipv6(self):
666+ s = MAASIPSet([])
667+ u = s.get_full_range('2001:db8::/64')
668+ stats = IPRangeStatistics(u)
669+ self.assertThat(stats.suggested_gateway, Equals("2001:db8::"))
670+
671+ def test__suggests_first_ip_as_default_gateway_if_available(self):
672+ s = MAASIPSet(['10.0.0.2', '10.0.0.4', '10.0.0.6', '10.0.0.8'])
673+ u = s.get_full_range('10.0.0.0/24')
674+ stats = IPRangeStatistics(u)
675+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.1"))
676+
677+ def test__suggests_last_ip_as_default_gateway_if_needed(self):
678+ s = MAASIPSet(['10.0.0.1', '10.0.0.4', '10.0.0.6', '10.0.0.8'])
679+ u = s.get_full_range('10.0.0.0/24')
680+ stats = IPRangeStatistics(u)
681+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.254"))
682+
683+ def test__suggests_first_available_ip_as_default_gateway_if_needed(self):
684+ s = MAASIPSet(['10.0.0.1', '10.0.0.4', '10.0.0.6', '10.0.0.254'])
685+ u = s.get_full_range('10.0.0.0/24')
686+ stats = IPRangeStatistics(u)
687+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.2"))
688+
689+ def test__suggests_no_gateway_if_range_full(self):
690+ s = MAASIPSet(['10.0.0.1'])
691+ u = s.get_full_range('10.0.0.1/32')
692+ stats = IPRangeStatistics(u)
693+ self.assertThat(stats.suggested_gateway, Is(None))
694+
695+ def test__suggests_upper_one_fourth_range_for_dynamic_by_default(self):
696+ s = MAASIPSet([])
697+ u = s.get_full_range('10.0.0.0/24')
698+ stats = IPRangeStatistics(u)
699+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.1"))
700+ self.assertThat(stats.suggested_dynamic_range, HasLength(64))
701+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.191"))
702+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.254"))
703+ self.assertThat(
704+ stats.suggested_dynamic_range, Not(Contains("10.0.0.255")))
705+ self.assertThat(
706+ stats.suggested_dynamic_range, Not(Contains("10.0.0.190")))
707+
708+ def test__suggests_half_available_if_available_less_than_one_fourth(self):
709+ s = MAASIPSet([MAASIPRange("10.0.0.2", "10.0.0.205")])
710+ u = s.get_full_range('10.0.0.0/24')
711+ stats = IPRangeStatistics(u)
712+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.1"))
713+ self.assertThat(stats.num_available, Equals(50))
714+ self.assertThat(stats.suggested_dynamic_range, HasLength(25))
715+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.230"))
716+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.254"))
717+ self.assertThat(
718+ stats.suggested_dynamic_range, Not(Contains("10.0.0.255")))
719+ self.assertThat(
720+ stats.suggested_dynamic_range, Not(Contains("10.0.0.229")))
721+
722+ def test__suggested_range_excludes_suggested_gateway(self):
723+ s = MAASIPSet([MAASIPRange("10.0.0.1", "10.0.0.204")])
724+ u = s.get_full_range('10.0.0.0/24')
725+ stats = IPRangeStatistics(u)
726+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.254"))
727+ self.assertThat(stats.num_available, Equals(50))
728+ self.assertThat(stats.suggested_dynamic_range, HasLength(25))
729+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.229"))
730+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.253"))
731+ self.assertThat(
732+ stats.suggested_dynamic_range, Not(Contains("10.0.0.255")))
733+ self.assertThat(
734+ stats.suggested_dynamic_range, Not(Contains("10.0.0.228")))
735+
736+ def test__suggested_range_excludes_suggested_gateway_when_gw_first(self):
737+ s = MAASIPSet([MAASIPRange("10.0.0.1", "10.0.0.203"), "10.0.0.254"])
738+ u = s.get_full_range('10.0.0.0/24')
739+ stats = IPRangeStatistics(u)
740+ self.assertThat(stats.suggested_gateway, Equals("10.0.0.204"))
741+ self.assertThat(stats.num_available, Equals(50))
742+ self.assertThat(stats.suggested_dynamic_range, HasLength(25))
743+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.229"))
744+ self.assertThat(stats.suggested_dynamic_range, Contains("10.0.0.253"))
745+ self.assertThat(
746+ stats.suggested_dynamic_range, Not(Contains("10.0.0.255")))
747+ self.assertThat(
748+ stats.suggested_dynamic_range, Not(Contains("10.0.0.228")))
749+
750+ def test__suggests_upper_one_fourth_range_for_ipv6(self):
751+ s = MAASIPSet([])
752+ u = s.get_full_range('2001:db8::/64')
753+ stats = IPRangeStatistics(u)
754+ self.assertThat(stats.suggested_gateway, Equals("2001:db8::"))
755+ self.assertThat(
756+ stats.suggested_dynamic_range, HasLength((2 ** 64) >> 2))
757+ self.assertThat(
758+ stats.suggested_dynamic_range, Contains(
759+ "2001:db8:0:0:c000::"))
760+ self.assertThat(
761+ stats.suggested_dynamic_range, Contains(
762+ "2001:db8::ffff:ffff:ffff:ffff"))
763+ self.assertThat(
764+ stats.suggested_dynamic_range, Not(Contains("2001:db8::1")))
765+ self.assertThat(
766+ stats.suggested_dynamic_range, Not(Contains(
767+ "2001:db8::bfff:ffff:ffff:ffff")))
768+
769+ def test__suggests_half_available_for_ipv6(self):
770+ s = MAASIPSet([MAASIPRange(
771+ "2001:db8::1", "2001:db8::ffff:ffff:ffff:ff00")])
772+ u = s.get_full_range('2001:db8::/64')
773+ stats = IPRangeStatistics(u)
774+ self.assertThat(stats.suggested_gateway, Equals("2001:db8::"))
775+ self.assertThat(stats.num_available, Equals(255))
776+ self.assertThat(stats.suggested_dynamic_range, HasLength(127))
777+ self.assertThat(
778+ stats.suggested_dynamic_range, Contains(
779+ "2001:db8::ffff:ffff:ffff:ff81"))
780+ self.assertThat(
781+ stats.suggested_dynamic_range, Contains(
782+ "2001:db8::ffff:ffff:ffff:ffff"))
783+ self.assertThat(
784+ stats.suggested_dynamic_range, Not(Contains("2001:db8::1")))
785+ self.assertThat(
786+ stats.suggested_dynamic_range, Not(Contains(
787+ "2001:db8::ffff:ffff:ffff:ff80")))
788+
789
790 class TestParseInteger(MAASTestCase):
791