Merge lp:~ltrager/maas/power_for_rackcontrollers into lp:~maas-committers/maas/trunk

Proposed by Lee Trager
Status: Merged
Approved by: Lee Trager
Approved revision: no longer in the source branch.
Merged at revision: 4983
Proposed branch: lp:~ltrager/maas/power_for_rackcontrollers
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 2353 lines (+825/-715)
24 files modified
src/maasserver/api/devices.py (+0/-16)
src/maasserver/api/machines.py (+4/-158)
src/maasserver/api/nodes.py (+176/-22)
src/maasserver/api/rackcontrollers.py (+4/-2)
src/maasserver/api/support.py (+6/-5)
src/maasserver/api/tests/test_machine.py (+1/-272)
src/maasserver/api/tests/test_machines.py (+0/-53)
src/maasserver/api/tests/test_node.py (+265/-20)
src/maasserver/api/tests/test_nodes.py (+63/-0)
src/maasserver/models/node.py (+6/-4)
src/maasserver/models/tests/test_node.py (+9/-1)
src/maasserver/node_action.py (+21/-18)
src/maasserver/static/js/angular/controllers/node_details.js (+1/-1)
src/maasserver/static/js/angular/controllers/nodes_list.js (+3/-2)
src/maasserver/static/js/angular/controllers/tests/test_node_details.js (+1/-1)
src/maasserver/static/js/angular/controllers/tests/test_nodes_list.js (+5/-1)
src/maasserver/static/js/angular/factories/general.js (+18/-4)
src/maasserver/static/js/angular/factories/tests/test_general.js (+107/-68)
src/maasserver/testing/sampledata.py (+34/-0)
src/maasserver/tests/test_node_action.py (+9/-1)
src/maasserver/websockets/handlers/general.py (+37/-33)
src/maasserver/websockets/handlers/tests/test_device.py (+2/-2)
src/maasserver/websockets/handlers/tests/test_general.py (+47/-28)
src/maasserver/websockets/handlers/tests/test_machine.py (+6/-3)
To merge this branch: bzr merge lp:~ltrager/maas/power_for_rackcontrollers
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Gavin Panella (community) Approve
Review via email: mp+292615@code.launchpad.net

Commit message

Add the ability to power rack controllers on and off through the API or UI

Description of the change

This adds the ability to power rack controllers on and off through the API or UI. The power options(power-on, power-off, query-power-state, mark-fixed, mark-broken) have all been moved onto the node API and are inherited by the machine API. I added a check on each of those calls to make sure they can only be used by machines and rack controllers. On the device and region API endpoints I've disabled these calls.

MAAS chooses whether a system should PXE or local boot based on its status. Because of this rack controllers now have their status set to deployed when they connect.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Not a full review, just one observation about:

+ # Disable power commands
+ query_power_state = power_on = power_off = mark_broken = mark_fixed = None

This is an abuse of inheritance. We do something like this for CRUD
methods when inheriting from OperationsHandler but that's because we
don't control the superclass, piston3.Resource.

Instead, break out query_power_state, power_on, power_off, mark_broken,
and mark_fixed to a mix-in and inherit only in the machine and rack
controller handler classes.

review: Needs Fixing
Revision history for this message
Lee Trager (ltrager) wrote :

Since all the power commands were originally on the node API endpoint I figured I'd add them back there now that two different node types support power options. This will expand on the concept that the node API is used for general options and be one less thing for people migrating from the 1.0 API to worry about. I disabled them on devices and region controllers as you pointed out since we currently don't support power options on those node types. I could do the mixin thing you are suggestion but then the node API endpoint won't have these options.

If we want to have the power options on the node endpoint we'll have to disable the power commands as I currently do. If we want the power operations only on the machine and rack controller endpoints I can do the mixin as you suggest.

Revision history for this message
Gavin Panella (allenap) wrote :

I don't think it's appropriate to have power control operations on the
generic node/nodes endpoint. That should probably only service the
common subset of operations.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

Agree with Gavin!

On Tuesday, April 26, 2016, Gavin Panella <email address hidden>
wrote:

> I don't think it's appropriate to have power control operations on the
> generic node/nodes endpoint. That should probably only service the
> common subset of operations.
>
> --
>
> https://code.launchpad.net/~ltrager/maas/power_for_rackcontrollers/+merge/292615
> You are subscribed to branch lp:maas.
>

--
Andres Rodriguez (RoAkSoAx)
Ubuntu Server Developer
MSc. Telecom & Networking
Systems Engineer

Revision history for this message
Lee Trager (ltrager) wrote :

I've moved all power commands to two new mixin classes, PowerMixin, and PowersMixin. Like the API endpoints the singular is used for individual object endpoints while the plural is for endpoints of multiple objects. Power commands now only show up for machines and rack-controllers but could be easily added to other node types.

I came across a bug in OperationsHandlerType where only methods defined in an
endpoints class, or its direct parent, would be automatically added to its exports list. Blake ran into the same bug when writing OwnerDataMixin. Since that mixin only has one method he redefined set_owner_data and called super(). As PowerMixin has a number of methods I modified support.py to add exportable methods from any base class, which are not defined as None in the child class, to the exports list. As this fixed the problem Blake was seeing I removed the set_owner_data methods which were just calling super().

Revision history for this message
Andres Rodriguez (andreserl) wrote :

Hold on. Let's first discuss this before you move forward. Let's do it
tomorrow at the standup!

On Wednesday, April 27, 2016, Lee Trager <email address hidden> wrote:

> I've moved all power commands to two new mixin classes, PowerMixin, and
> PowersMixin. Like the API endpoints the singular is used for individual
> object endpoints while the plural is for endpoints of multiple objects.
> Power commands now only show up for machines and rack-controllers but could
> be easily added to other node types.
>
> I came across a bug in OperationsHandlerType where only methods defined in
> an
> endpoints class, or its direct parent, would be automatically added to its
> exports list. Blake ran into the same bug when writing OwnerDataMixin.
> Since that mixin only has one method he redefined set_owner_data and called
> super(). As PowerMixin has a number of methods I modified support.py to add
> exportable methods from any base class, which are not defined as None in
> the child class, to the exports list. As this fixed the problem Blake was
> seeing I removed the set_owner_data methods which were just calling super().
> --
>
> https://code.launchpad.net/~ltrager/maas/power_for_rackcontrollers/+merge/292615
> You are subscribed to branch lp:maas.
>

--
Andres Rodriguez (RoAkSoAx)
Ubuntu Server Developer
MSc. Telecom & Networking
Systems Engineer

Revision history for this message
Gavin Panella (allenap) wrote :

Looks good. A few small comments, that's all. Thanks for making those changes.

review: Approve
Revision history for this message
Lee Trager (ltrager) wrote :

As per the discussion on IRC the rack controller status is no longer changed. Instead I modified the power commands to always boot locally when not a machine.

While testing I realized that all power options were being displayed in the UI on all controller types. This was because MAAS sent one action list for all controllers. I've split this into region, rack, and region and rack action lists. Power options are now only listed when only rack controllers are selected.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Lets fix the node actions like we discussed in the hangout.

review: Needs Fixing
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Okay this looks better, thanks for all the fixes. I know this branch was a lot of work.

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

The attempt to merge lp:~ltrager/maas/power_for_rackcontrollers into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://security.ubuntu.com/ubuntu xenial-security InRelease [92.2 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [93.3 kB]
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Fetched 185 kB in 0s (417 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 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-simplejson 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-2ubuntu3).
archdetect-deb is already the newest version (1.117ubuntu2).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
build-essential is already the newest version (12.1ubuntu2).
bzr is already the newest version (2.7.0-2ubuntu1).
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).
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-5ubuntu12).
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 is already the newest version (9.5.2-1).
make is already the newest version (4.1-6).
postgresql is already the newest v...

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

The attempt to merge lp:~ltrager/maas/power_for_rackcontrollers into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [93.3 kB]
Get:3 http://security.ubuntu.com/ubuntu xenial-security InRelease [92.2 kB]
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Fetched 185 kB in 0s (384 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 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-simplejson 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-2ubuntu3).
archdetect-deb is already the newest version (1.117ubuntu2).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
build-essential is already the newest version (12.1ubuntu2).
bzr is already the newest version (2.7.0-2ubuntu1).
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).
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-5ubuntu12).
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 is already the newest version (9.5.2-1).
make is already the newest version (4.1-6).
postgresql is already the newest v...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/devices.py'
2--- src/maasserver/api/devices.py 2016-04-14 15:25:36 +0000
3+++ src/maasserver/api/devices.py 2016-04-29 23:54:15 +0000
4@@ -12,7 +12,6 @@
5 NodesHandler,
6 OwnerDataMixin,
7 )
8-from maasserver.api.support import operation
9 from maasserver.enum import NODE_PERMISSION
10 from maasserver.exceptions import MAASAPIValidationError
11 from maasserver.forms import (
12@@ -117,21 +116,6 @@
13 device.delete()
14 return rc.DELETED
15
16- @operation(idempotent=False)
17- def set_owner_data(self, request, system_id):
18- """Set key/value data for the current owner.
19-
20- Pass any key/value data to this method to add, modify, or remove. A key
21- is removed when the value for that key is set to an empty string.
22-
23- This operation will not remove any previous keys unless explicitly
24- passed with an empty string.
25-
26- Returns 404 if the device is not found.
27- Returns 403 if the user does not have permission.
28- """
29- return super().set_owner_data(request, system_id)
30-
31 @classmethod
32 def resource_uri(cls, device=None):
33 # This method is called by piston in two different contexts:
34
35=== modified file 'src/maasserver/api/machines.py'
36--- src/maasserver/api/machines.py 2016-04-15 22:14:33 +0000
37+++ src/maasserver/api/machines.py 2016-04-29 23:54:15 +0000
38@@ -8,7 +8,6 @@
39 "get_storage_layout_params",
40 ]
41
42-from base64 import b64decode
43 import re
44
45 from django.conf import settings
46@@ -29,6 +28,8 @@
47 NodeHandler,
48 NodesHandler,
49 OwnerDataMixin,
50+ PowerMixin,
51+ PowersMixin,
52 store_node_power_parameters,
53 )
54 from maasserver.api.support import (
55@@ -50,7 +51,6 @@
56 MAASAPIValidationError,
57 NodesNotAvailable,
58 NodeStateViolation,
59- StaticIPAddressExhaustion,
60 Unauthorized,
61 )
62 from maasserver.forms import (
63@@ -178,7 +178,7 @@
64 return storage_layout, params
65
66
67-class MachineHandler(NodeHandler, OwnerDataMixin):
68+class MachineHandler(NodeHandler, OwnerDataMixin, PowerMixin):
69 """Manage an individual Machine.
70
71 The Machine is identified by its system_id.
72@@ -278,36 +278,6 @@
73 return ('machine_handler', (machine_system_id, ))
74
75 @operation(idempotent=False)
76- def power_off(self, request, system_id):
77- """Power off a machine.
78-
79- :param stop_mode: An optional power off mode. If 'soft',
80- perform a soft power down if the machine's power type supports
81- it, otherwise perform a hard power off. For all values other
82- than 'soft', and by default, perform a hard power off. A
83- soft power off generally asks the OS to shutdown the system
84- gracefully before powering off, while a hard power off
85- occurs immediately without any warning to the OS.
86- :type stop_mode: unicode
87- :param comment: Optional comment for the event log.
88- :type comment: unicode
89-
90- Returns 404 if the machine is not found.
91- Returns 403 if the user does not have permission to stop the machine.
92- """
93- stop_mode = request.POST.get('stop_mode', 'hard')
94- comment = get_optional_param(request.POST, 'comment')
95- machine = self.model.objects.get_node_or_404(
96- system_id=system_id, user=request.user,
97- perm=NODE_PERMISSION.EDIT)
98- power_action_sent = machine.stop(
99- request.user, stop_mode=stop_mode, comment=comment)
100- if power_action_sent:
101- return machine
102- else:
103- return None
104-
105- @operation(idempotent=False)
106 def deploy(self, request, system_id):
107 """Deploy an operating system to a machine.
108
109@@ -363,48 +333,6 @@
110 return self.power_on(request, system_id)
111
112 @operation(idempotent=False)
113- def power_on(self, request, system_id):
114- """Turn on a machine.
115-
116- :param user_data: If present, this blob of user-data to be made
117- available to the machines through the metadata service.
118- :type user_data: base64-encoded unicode
119- :param comment: Optional comment for the event log.
120- :type comment: unicode
121-
122- Ideally we'd have MIME multipart and content-transfer-encoding etc.
123- deal with the encapsulation of binary data, but couldn't make it work
124- with the framework in reasonable time so went for a dumb, manual
125- encoding instead.
126-
127- Returns 404 if the machine is not found.
128- Returns 403 if the user does not have permission to start the machine.
129- Returns 503 if the start-up attempted to allocate an IP address,
130- and there were no IP addresses available on the relevant cluster
131- interface.
132- """
133- user_data = request.POST.get('user_data', None)
134- comment = get_optional_param(request.POST, 'comment')
135-
136- machine = self.model.objects.get_node_or_404(
137- system_id=system_id, user=request.user,
138- perm=NODE_PERMISSION.EDIT)
139- if machine.owner is None:
140- raise NodeStateViolation(
141- "Can't start machine: it hasn't been allocated.")
142- if user_data is not None:
143- user_data = b64decode(user_data)
144- try:
145- machine.start(request.user, user_data=user_data, comment=comment)
146- except StaticIPAddressExhaustion:
147- # The API response should contain error text with the
148- # system_id in it, as that is the primary API key to a machine.
149- raise StaticIPAddressExhaustion(
150- "%s: Unable to allocate static IP due to address"
151- " exhaustion." % system_id)
152- return machine
153-
154- @operation(idempotent=False)
155 def release(self, request, system_id):
156 """Release a machine. Opposite of `Machines.allocate`.
157
158@@ -654,65 +582,6 @@
159 get_curtin_merged_config(machine), default_flow_style=False),
160 content_type='text/plain')
161
162- @operation(idempotent=False)
163- def mark_broken(self, request, system_id):
164- """Mark a machine as 'broken'.
165-
166- If the machine is allocated, release it first.
167-
168- :param comment: Optional comment for the event log. Will be
169- displayed on the Node as an error description until marked fixed.
170- :type comment: unicode
171-
172- Returns 404 if the machine is not found.
173- Returns 403 if the user does not have permission to mark the machine
174- broken.
175- """
176- node = self.model.objects.get_node_or_404(
177- user=request.user, system_id=system_id, perm=NODE_PERMISSION.EDIT)
178- comment = get_optional_param(request.POST, 'comment')
179- if not comment:
180- # read old error_description to for backward compatibility
181- comment = get_optional_param(request.POST, 'error_description')
182- node.mark_broken(request.user, comment)
183- return node
184-
185- @operation(idempotent=False)
186- def mark_fixed(self, request, system_id):
187- """Mark a broken machine as fixed and set its status as 'ready'.
188-
189- :param comment: Optional comment for the event log.
190- :type comment: unicode
191-
192- Returns 404 if the machine is not found.
193- Returns 403 if the user does not have permission to mark the machine
194- fixed.
195- """
196- comment = get_optional_param(request.POST, 'comment')
197- node = self.model.objects.get_node_or_404(
198- user=request.user, system_id=system_id, perm=NODE_PERMISSION.ADMIN)
199- node.mark_fixed(request.user, comment)
200- maaslog.info(
201- "%s: User %s marked machine as fixed", node.hostname,
202- request.user.username)
203- return node
204-
205- @operation(idempotent=False)
206- def set_owner_data(self, request, system_id):
207- """Set key/value data for the current owner.
208-
209- Pass any key/value data to this method to add, modify, or remove. A key
210- is removed when the value for that key is set to an empty string.
211-
212- This operation will not remove any previous keys unless explicitly
213- passed with an empty string. All owner data is removed when the machine
214- is no longer allocated to a user.
215-
216- Returns 404 if the machine is not found.
217- Returns 403 if the user does not have permission.
218- """
219- return super().set_owner_data(request, system_id)
220-
221
222 def create_machine(request):
223 """Service an http request to create a machine.
224@@ -857,7 +726,7 @@
225 return ('machines_handler', [])
226
227
228-class MachinesHandler(NodesHandler):
229+class MachinesHandler(NodesHandler, PowersMixin):
230 """Manage the collection of all the machines in the MAAS."""
231 api_doc_section_name = "Machines"
232 anonymous = AnonMachinesHandler
233@@ -1186,29 +1055,6 @@
234 return machine
235
236 @admin_method
237- @operation(idempotent=True)
238- def power_parameters(self, request):
239- """Retrieve power parameters for multiple machines.
240-
241- :param id: An optional list of system ids. Only machines with
242- matching system ids will be returned.
243- :type id: iterable
244-
245- :return: A dictionary of power parameters, keyed by machine system_id.
246-
247- Raises 403 if the user is not an admin.
248- """
249- match_ids = get_optional_list(request.GET, 'id')
250-
251- if match_ids is None:
252- machines = self.base_model.objects.all()
253- else:
254- machines = self.base_model.objects.filter(system_id__in=match_ids)
255-
256- return {machine.system_id: machine.power_parameters
257- for machine in machines}
258-
259- @admin_method
260 @operation(idempotent=False)
261 def add_chassis(self, request):
262 """Add special hardware types.
263
264=== modified file 'src/maasserver/api/nodes.py'
265--- src/maasserver/api/nodes.py 2016-04-15 22:14:33 +0000
266+++ src/maasserver/api/nodes.py 2016-04-29 23:54:15 +0000
267@@ -8,12 +8,14 @@
268 "store_node_power_parameters",
269 ]
270
271+from base64 import b64decode
272 from itertools import chain
273 import json
274
275 import bson
276 from django.http import HttpResponse
277 from django.shortcuts import get_object_or_404
278+from maasserver.api.logger import maaslog
279 from maasserver.api.support import (
280 admin_method,
281 AnonymousOperationsHandler,
282@@ -23,17 +25,21 @@
283 from maasserver.api.utils import (
284 get_mandatory_param,
285 get_optional_list,
286+ get_optional_param,
287 )
288 from maasserver.clusterrpc.power_parameters import get_power_types
289 from maasserver.enum import (
290 NODE_PERMISSION,
291 NODE_STATUS,
292+ NODE_TYPE,
293 NODE_TYPE_CHOICES,
294 )
295 from maasserver.exceptions import (
296 ClusterUnavailable,
297 MAASAPIBadRequest,
298 MAASAPIValidationError,
299+ NodeStateViolation,
300+ StaticIPAddressExhaustion,
301 )
302 from maasserver.fields import MAC_RE
303 from maasserver.forms import BulkNodeActionForm
304@@ -236,28 +242,6 @@
305 node = get_object_or_404(self.model, system_id=system_id)
306 return node.power_parameters
307
308- @operation(idempotent=True)
309- def query_power_state(self, request, system_id):
310- """Query the power state of a node.
311-
312- Send a request to the node's power controller which asks it about
313- the node's state. The reply to this could be delayed by up to
314- 30 seconds while waiting for the power controller to respond.
315- Use this method sparingly as it ties up an appserver thread
316- while waiting.
317-
318- :param system_id: The node to query.
319- :return: a dict whose key is "state" with a value of one of
320- 'on' or 'off'.
321-
322- Returns 404 if the node is not found.
323- Returns node's power state.
324- """
325- node = get_object_or_404(self.model, system_id=system_id)
326- return {
327- "state": node.power_query().wait(45),
328- }
329-
330
331 class AnonNodeHandler(AnonymousOperationsHandler):
332 """Anonymous access to Node."""
333@@ -413,3 +397,173 @@
334 }
335 OwnerData.objects.set_owner_data(node, owner_data)
336 return node
337+
338+
339+class PowerMixin:
340+ """Mixin which adds power commands to a node type."""
341+
342+ @operation(idempotent=True)
343+ def query_power_state(self, request, system_id):
344+ """Query the power state of a node.
345+
346+ Send a request to the node's power controller which asks it about
347+ the node's state. The reply to this could be delayed by up to
348+ 30 seconds while waiting for the power controller to respond.
349+ Use this method sparingly as it ties up an appserver thread
350+ while waiting.
351+
352+ :param system_id: The node to query.
353+ :return: a dict whose key is "state" with a value of one of
354+ 'on' or 'off'.
355+
356+ Returns 404 if the node is not found.
357+ Returns node's power state.
358+ """
359+ node = self.model.objects.get_node_or_404(
360+ system_id=system_id, user=request.user,
361+ perm=NODE_PERMISSION.VIEW)
362+ return {
363+ "state": node.power_query().wait(45),
364+ }
365+
366+ @operation(idempotent=False)
367+ def power_on(self, request, system_id):
368+ """Turn on a node.
369+
370+ :param user_data: If present, this blob of user-data to be made
371+ available to the nodes through the metadata service.
372+ :type user_data: base64-encoded unicode
373+ :param comment: Optional comment for the event log.
374+ :type comment: unicode
375+
376+ Ideally we'd have MIME multipart and content-transfer-encoding etc.
377+ deal with the encapsulation of binary data, but couldn't make it work
378+ with the framework in reasonable time so went for a dumb, manual
379+ encoding instead.
380+
381+ Returns 404 if the node is not found.
382+ Returns 403 if the user does not have permission to start the machine.
383+ Returns 503 if the start-up attempted to allocate an IP address,
384+ and there were no IP addresses available on the relevant cluster
385+ interface.
386+ """
387+ user_data = request.POST.get('user_data', None)
388+ comment = get_optional_param(request.POST, 'comment')
389+
390+ node = self.model.objects.get_node_or_404(
391+ system_id=system_id, user=request.user,
392+ perm=NODE_PERMISSION.EDIT)
393+ if node.owner is None and node.node_type != NODE_TYPE.RACK_CONTROLLER:
394+ raise NodeStateViolation(
395+ "Can't start node: it hasn't been allocated.")
396+ if user_data is not None:
397+ user_data = b64decode(user_data)
398+ try:
399+ node.start(request.user, user_data=user_data, comment=comment)
400+ except StaticIPAddressExhaustion:
401+ # The API response should contain error text with the
402+ # system_id in it, as that is the primary API key to a node.
403+ raise StaticIPAddressExhaustion(
404+ "%s: Unable to allocate static IP due to address"
405+ " exhaustion." % system_id)
406+ return node
407+
408+ @operation(idempotent=False)
409+ def power_off(self, request, system_id):
410+ """Power off a node.
411+
412+ :param stop_mode: An optional power off mode. If 'soft',
413+ perform a soft power down if the node's power type supports
414+ it, otherwise perform a hard power off. For all values other
415+ than 'soft', and by default, perform a hard power off. A
416+ soft power off generally asks the OS to shutdown the system
417+ gracefully before powering off, while a hard power off
418+ occurs immediately without any warning to the OS.
419+ :type stop_mode: unicode
420+ :param comment: Optional comment for the event log.
421+ :type comment: unicode
422+
423+ Returns 404 if the node is not found.
424+ Returns 403 if the user does not have permission to stop the node.
425+ """
426+ stop_mode = request.POST.get('stop_mode', 'hard')
427+ comment = get_optional_param(request.POST, 'comment')
428+ node = self.model.objects.get_node_or_404(
429+ system_id=system_id, user=request.user,
430+ perm=NODE_PERMISSION.EDIT)
431+ power_action_sent = node.stop(
432+ request.user, stop_mode=stop_mode, comment=comment)
433+ if power_action_sent:
434+ return node
435+ else:
436+ return None
437+
438+ @operation(idempotent=False)
439+ def mark_broken(self, request, system_id):
440+ """Mark a node as 'broken'.
441+
442+ If the node is allocated, release it first.
443+
444+ :param comment: Optional comment for the event log. Will be
445+ displayed on the node as an error description until marked fixed.
446+ :type comment: unicode
447+
448+ Returns 404 if the node is not found.
449+ Returns 403 if the user does not have permission to mark the node
450+ broken.
451+ """
452+ node = self.model.objects.get_node_or_404(
453+ user=request.user, system_id=system_id, perm=NODE_PERMISSION.EDIT)
454+ comment = get_optional_param(request.POST, 'comment')
455+ if not comment:
456+ # read old error_description to for backward compatibility
457+ comment = get_optional_param(request.POST, 'error_description')
458+ node.mark_broken(request.user, comment)
459+ return node
460+
461+ @operation(idempotent=False)
462+ def mark_fixed(self, request, system_id):
463+ """Mark a broken node as fixed and set its status as 'ready'.
464+
465+ :param comment: Optional comment for the event log.
466+ :type comment: unicode
467+
468+ Returns 404 if the machine is not found.
469+ Returns 403 if the user does not have permission to mark the machine
470+ fixed.
471+ """
472+ comment = get_optional_param(request.POST, 'comment')
473+ node = self.model.objects.get_node_or_404(
474+ user=request.user, system_id=system_id, perm=NODE_PERMISSION.ADMIN)
475+ node.mark_fixed(request.user, comment)
476+ maaslog.info(
477+ "%s: User %s marked node as fixed", node.hostname,
478+ request.user.username)
479+ return node
480+
481+
482+class PowersMixin:
483+ """Mixin which adds power commands to a nodes type."""
484+
485+ @admin_method
486+ @operation(idempotent=True)
487+ def power_parameters(self, request):
488+ """Retrieve power parameters for multiple machines.
489+
490+ :param id: An optional list of system ids. Only machines with
491+ matching system ids will be returned.
492+ :type id: iterable
493+
494+ :return: A dictionary of power parameters, keyed by machine system_id.
495+
496+ Raises 403 if the user is not an admin.
497+ """
498+ match_ids = get_optional_list(request.GET, 'id')
499+
500+ if match_ids is None:
501+ machines = self.base_model.objects.all()
502+ else:
503+ machines = self.base_model.objects.filter(system_id__in=match_ids)
504+
505+ return {machine.system_id: machine.power_parameters
506+ for machine in machines}
507
508=== modified file 'src/maasserver/api/rackcontrollers.py'
509--- src/maasserver/api/rackcontrollers.py 2016-04-15 22:14:33 +0000
510+++ src/maasserver/api/rackcontrollers.py 2016-04-29 23:54:15 +0000
511@@ -12,6 +12,8 @@
512 from maasserver.api.nodes import (
513 NodeHandler,
514 NodesHandler,
515+ PowerMixin,
516+ PowersMixin,
517 )
518 from maasserver.api.support import (
519 admin_method,
520@@ -68,7 +70,7 @@
521 )
522
523
524-class RackControllerHandler(NodeHandler):
525+class RackControllerHandler(NodeHandler, PowerMixin):
526 """Manage an individual rack controller.
527
528 The rack controller is identified by its system_id.
529@@ -173,7 +175,7 @@
530 return ('rackcontroller_handler', (rackcontroller_id, ))
531
532
533-class RackControllersHandler(NodesHandler):
534+class RackControllersHandler(NodesHandler, PowersMixin):
535 """Manage the collection of all rack controllers in MAAS."""
536 api_doc_section_name = "RackControllers"
537 base_model = RackController
538
539=== modified file 'src/maasserver/api/support.py'
540--- src/maasserver/api/support.py 2016-04-21 13:19:30 +0000
541+++ src/maasserver/api/support.py 2016-04-29 23:54:15 +0000
542@@ -169,12 +169,13 @@
543 # Add parent classes' exports if they still correspond to a valid
544 # method on the class we're considering. This allows subclasses to
545 # remove methods by defining an attribute of the same name as None.
546- if cls.exports is not None:
547- for key in cls.exports.keys():
548- if key[1] is not None:
549- new_func = getattr(cls, key[1], None)
550+ for base in bases:
551+ for key, value in vars(base).items():
552+ export = getattr(value, "export", None)
553+ if export is not None:
554+ new_func = getattr(cls, key, None)
555 if new_func is not None:
556- exports[key] = new_func
557+ exports[export] = new_func
558
559 # Export custom operations.
560 exports.update(operations)
561
562=== modified file 'src/maasserver/api/tests/test_machine.py'
563--- src/maasserver/api/tests/test_machine.py 2016-04-15 22:14:33 +0000
564+++ src/maasserver/api/tests/test_machine.py 2016-04-29 23:54:15 +0000
565@@ -58,7 +58,6 @@
566 Equals,
567 HasLength,
568 MockCalledOnceWith,
569- MockNotCalled,
570 )
571 from metadataserver.models import (
572 NodeKey,
573@@ -67,7 +66,6 @@
574 from metadataserver.nodeinituser import get_node_init_user
575 from mock import ANY
576 from netaddr import IPNetwork
577-from provisioningserver.rpc.exceptions import PowerActionAlreadyInProgress
578 from provisioningserver.utils.enum import map_enum
579 from testtools.matchers import (
580 ContainsDict,
581@@ -316,142 +314,6 @@
582 self.assertEqual(NODE_STATUS_CHOICES_DICT[status],
583 parsed_result['status_name'])
584
585- def test_POST_power_off_checks_permission(self):
586- machine = factory.make_Node()
587- machine_stop = self.patch(machine, 'stop')
588- response = self.client.post(
589- self.get_machine_uri(machine), {'op': 'power_off'})
590- self.assertEqual(http.client.FORBIDDEN, response.status_code)
591- self.assertThat(machine_stop, MockNotCalled())
592-
593- def test_POST_power_off_rejects_other_node_types(self):
594- node = factory.make_Node_with_Interface_on_Subnet(
595- owner=self.logged_in_user,
596- node_type=factory.pick_choice(
597- NODE_TYPE_CHOICES, but_not=[NODE_TYPE.MACHINE]),
598- )
599- response = self.client.post(
600- self.get_machine_uri(node), {'op': 'power_off'})
601- self.assertEqual(
602- http.client.NOT_FOUND, response.status_code, response.content)
603-
604- def test_POST_power_off_returns_nothing_if_machine_was_not_stopped(self):
605- # The machine may not be stopped because, for example, its power type
606- # does not support it. In this case the machine is not returned to the
607- # caller.
608- machine = factory.make_Node(owner=self.logged_in_user)
609- machine_stop = self.patch(node_module.Machine, 'stop')
610- machine_stop.return_value = False
611- response = self.client.post(
612- self.get_machine_uri(machine), {'op': 'power_off'})
613- self.assertEqual(http.client.OK, response.status_code)
614- self.assertIsNone(json_load_bytes(response.content))
615- self.assertThat(machine_stop, MockCalledOnceWith(
616- ANY, stop_mode=ANY, comment=None))
617-
618- def test_POST_power_off_returns_machine(self):
619- machine = factory.make_Node(owner=self.logged_in_user)
620- self.patch(node_module.Machine, 'stop').return_value = True
621- response = self.client.post(
622- self.get_machine_uri(machine), {'op': 'power_off'})
623- self.assertEqual(http.client.OK, response.status_code)
624- self.assertEqual(
625- machine.system_id, json_load_bytes(response.content)['system_id'])
626-
627- def test_POST_power_off_may_be_repeated(self):
628- machine = factory.make_Node(
629- owner=self.logged_in_user, interface=True,
630- power_type='manual')
631- self.patch(machine, 'stop')
632- self.client.post(self.get_machine_uri(machine), {'op': 'power_off'})
633- response = self.client.post(
634- self.get_machine_uri(machine), {'op': 'power_off'})
635- self.assertEqual(http.client.OK, response.status_code)
636-
637- def test_POST_power_off_power_offs_machines(self):
638- machine = factory.make_Node(owner=self.logged_in_user)
639- machine_stop = self.patch(node_module.Machine, 'stop')
640- stop_mode = factory.make_name('stop_mode')
641- comment = factory.make_name('comment')
642- self.client.post(
643- self.get_machine_uri(machine),
644- {'op': 'power_off', 'stop_mode': stop_mode, 'comment': comment})
645- self.assertThat(
646- machine_stop,
647- MockCalledOnceWith(
648- self.logged_in_user, stop_mode=stop_mode, comment=comment))
649-
650- def test_POST_power_off_handles_missing_comment(self):
651- machine = factory.make_Node(owner=self.logged_in_user)
652- machine_stop = self.patch(node_module.Machine, 'stop')
653- stop_mode = factory.make_name('stop_mode')
654- self.client.post(
655- self.get_machine_uri(machine),
656- {'op': 'power_off', 'stop_mode': stop_mode})
657- self.assertThat(
658- machine_stop,
659- MockCalledOnceWith(
660- self.logged_in_user, stop_mode=stop_mode, comment=None))
661-
662- def test_POST_power_off_returns_503_when_power_already_in_progress(self):
663- machine = factory.make_Node(owner=self.logged_in_user)
664- exc_text = factory.make_name("exc_text")
665- self.patch(
666- node_module.Machine,
667- 'stop').side_effect = PowerActionAlreadyInProgress(exc_text)
668- response = self.client.post(
669- self.get_machine_uri(machine), {'op': 'power_off'})
670- self.assertResponseCode(http.client.SERVICE_UNAVAILABLE, response)
671- self.assertIn(
672- exc_text, response.content.decode(settings.DEFAULT_CHARSET))
673-
674- def test_POST_power_on_checks_permission(self):
675- machine = factory.make_Node_with_Interface_on_Subnet(
676- owner=factory.make_User())
677- response = self.client.post(
678- self.get_machine_uri(machine), {'op': 'power_on'})
679- self.assertEqual(http.client.FORBIDDEN, response.status_code)
680-
681- def test_POST_power_on_checks_ownership(self):
682- self.become_admin()
683- machine = factory.make_Node_with_Interface_on_Subnet(
684- status=NODE_STATUS.READY)
685- response = self.client.post(
686- self.get_machine_uri(machine), {'op': 'power_on'})
687- self.assertEqual(http.client.CONFLICT, response.status_code)
688- self.assertEqual(
689- "Can't start machine: it hasn't been allocated.",
690- response.content.decode(settings.DEFAULT_CHARSET))
691-
692- def test_POST_power_on_returns_machine(self):
693- self.patch(node_module.Node, "_start")
694- machine = factory.make_Node(
695- owner=self.logged_in_user, interface=True,
696- power_type='manual',
697- architecture=make_usable_architecture(self))
698- osystem = make_usable_osystem(self)
699- distro_series = osystem['default_release']
700- response = self.client.post(
701- self.get_machine_uri(machine),
702- {
703- 'op': 'power_on',
704- 'distro_series': distro_series,
705- })
706- self.assertEqual(http.client.OK, response.status_code)
707- self.assertEqual(
708- machine.system_id, json_load_bytes(response.content)['system_id'])
709-
710- def test_POST_power_on_rejects_other_node_types(self):
711- node = factory.make_Node_with_Interface_on_Subnet(
712- owner=self.logged_in_user,
713- node_type=factory.pick_choice(
714- NODE_TYPE_CHOICES, but_not=[NODE_TYPE.MACHINE]),
715- )
716- response = self.client.post(
717- self.get_machine_uri(node), {'op': 'power_on'})
718- self.assertEqual(
719- http.client.NOT_FOUND, response.status_code, response.content)
720-
721 def test_POST_deploy_sets_osystem_and_distro_series(self):
722 self.patch(node_module.Node, "_start")
723 machine = factory.make_Node(
724@@ -1515,7 +1377,7 @@
725 def test_abort_changes_state(self):
726 machine = factory.make_Node(
727 status=NODE_STATUS.DISK_ERASING, owner=self.logged_in_user)
728- machine_stop = self.patch(node_module.Node, "_stop")
729+ machine_stop = self.patch(node_module.Machine, "_stop")
730 machine_stop.side_effect = lambda user: post_commit()
731
732 response = self.client.post(
733@@ -2021,136 +1883,3 @@
734 response.content.decode(settings.DEFAULT_CHARSET))
735 self.assertThat(
736 mock_get_curtin_merged_config, MockCalledOnceWith(machine))
737-
738-
739-class TestMarkBroken(APITestCase):
740- """Tests for /api/2.0/machines/<node>/?op=mark_broken"""
741-
742- def get_node_uri(self, node):
743- """Get the API URI for `node`."""
744- return reverse('machine_handler', args=[node.system_id])
745-
746- def test_mark_broken_changes_status(self):
747- node = factory.make_Node(
748- status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
749- response = self.client.post(
750- self.get_node_uri(node), {'op': 'mark_broken'})
751- self.assertEqual(http.client.OK, response.status_code)
752- self.assertEqual(NODE_STATUS.BROKEN, reload_object(node).status)
753-
754- def test_mark_broken_updates_error_description(self):
755- # 'error_description' parameter was renamed 'comment' for consistency
756- # make sure this comment updates the node's error_description
757- node = factory.make_Node(
758- status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
759- comment = factory.make_name('comment')
760- response = self.client.post(
761- self.get_node_uri(node),
762- {'op': 'mark_broken', 'comment': comment})
763- self.assertEqual(http.client.OK, response.status_code)
764- node = reload_object(node)
765- self.assertEqual(
766- (NODE_STATUS.BROKEN, comment),
767- (node.status, node.error_description)
768- )
769-
770- def test_mark_broken_updates_error_description_compatibility(self):
771- # test old 'error_description' parameter is honored for compatibility
772- node = factory.make_Node(
773- status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
774- error_description = factory.make_name('error_description')
775- response = self.client.post(
776- self.get_node_uri(node),
777- {'op': 'mark_broken', 'error_description': error_description})
778- self.assertEqual(http.client.OK, response.status_code)
779- node = reload_object(node)
780- self.assertEqual(
781- (NODE_STATUS.BROKEN, error_description),
782- (node.status, node.error_description)
783- )
784-
785- def test_mark_broken_passes_comment(self):
786- node = factory.make_Node(
787- status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
788- node_mark_broken = self.patch(node_module.Node, 'mark_broken')
789- comment = factory.make_name('comment')
790- self.client.post(
791- self.get_node_uri(node),
792- {'op': 'mark_broken', 'comment': comment})
793- self.assertThat(
794- node_mark_broken,
795- MockCalledOnceWith(self.logged_in_user, comment))
796-
797- def test_mark_broken_handles_missing_comment(self):
798- node = factory.make_Node(
799- status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
800- node_mark_broken = self.patch(node_module.Node, 'mark_broken')
801- self.client.post(
802- self.get_node_uri(node), {'op': 'mark_broken'})
803- self.assertThat(
804- node_mark_broken,
805- MockCalledOnceWith(self.logged_in_user, None))
806-
807- def test_mark_broken_requires_ownership(self):
808- node = factory.make_Node(status=NODE_STATUS.COMMISSIONING)
809- response = self.client.post(
810- self.get_node_uri(node), {'op': 'mark_broken'})
811- self.assertEqual(http.client.FORBIDDEN, response.status_code)
812-
813- def test_mark_broken_allowed_from_any_other_state(self):
814- self.patch(node_module.Node, "_stop")
815- for status, _ in NODE_STATUS_CHOICES:
816- if status == NODE_STATUS.BROKEN:
817- continue
818-
819- node = factory.make_Node(status=status, owner=self.logged_in_user)
820- response = self.client.post(
821- self.get_node_uri(node), {'op': 'mark_broken'})
822- self.expectThat(
823- response.status_code, Equals(http.client.OK), response)
824- node = reload_object(node)
825- self.expectThat(node.status, Equals(NODE_STATUS.BROKEN))
826-
827-
828-class TestMarkFixed(APITestCase):
829- """Tests for /api/2.0/machines/<node>/?op=mark_fixed"""
830-
831- def get_node_uri(self, node):
832- """Get the API URI for `node`."""
833- return reverse('machine_handler', args=[node.system_id])
834-
835- def test_mark_fixed_changes_status(self):
836- self.become_admin()
837- node = factory.make_Node(status=NODE_STATUS.BROKEN)
838- response = self.client.post(
839- self.get_node_uri(node), {'op': 'mark_fixed'})
840- self.assertEqual(http.client.OK, response.status_code)
841- self.assertEqual(NODE_STATUS.READY, reload_object(node).status)
842-
843- def test_mark_fixed_requires_admin(self):
844- node = factory.make_Node(status=NODE_STATUS.BROKEN)
845- response = self.client.post(
846- self.get_node_uri(node), {'op': 'mark_fixed'})
847- self.assertEqual(http.client.FORBIDDEN, response.status_code)
848-
849- def test_mark_fixed_passes_comment(self):
850- self.become_admin()
851- node = factory.make_Node(status=NODE_STATUS.BROKEN)
852- node_mark_fixed = self.patch(node_module.Node, 'mark_fixed')
853- comment = factory.make_name('comment')
854- self.client.post(
855- self.get_node_uri(node),
856- {'op': 'mark_fixed', 'comment': comment})
857- self.assertThat(
858- node_mark_fixed,
859- MockCalledOnceWith(self.logged_in_user, comment))
860-
861- def test_mark_fixed_handles_missing_comment(self):
862- self.become_admin()
863- node = factory.make_Node(status=NODE_STATUS.BROKEN)
864- node_mark_fixed = self.patch(node_module.Node, 'mark_fixed')
865- self.client.post(
866- self.get_node_uri(node), {'op': 'mark_fixed'})
867- self.assertThat(
868- node_mark_fixed,
869- MockCalledOnceWith(self.logged_in_user, None))
870
871=== modified file 'src/maasserver/api/tests/test_machines.py'
872--- src/maasserver/api/tests/test_machines.py 2016-04-14 15:25:36 +0000
873+++ src/maasserver/api/tests/test_machines.py 2016-04-29 23:54:15 +0000
874@@ -1464,59 +1464,6 @@
875 machine = reload_object(machine)
876 self.assertEqual(original_zone, machine.zone)
877
878- def test_GET_power_parameters_requires_admin(self):
879- response = self.client.get(
880- reverse('machines_handler'),
881- {
882- 'op': 'power_parameters',
883- })
884- self.assertEqual(
885- http.client.FORBIDDEN, response.status_code, response.content)
886-
887- def test_GET_power_parameters_without_ids_does_not_filter(self):
888- self.become_admin()
889- machines = [
890- factory.make_Node(power_parameters={factory.make_string():
891- factory.make_string()})
892- for _ in range(0, 3)
893- ]
894- response = self.client.get(
895- reverse('machines_handler'),
896- {
897- 'op': 'power_parameters',
898- })
899- self.assertEqual(
900- http.client.OK, response.status_code, response.content)
901- parsed = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
902- expected = {
903- machine.system_id: machine.power_parameters
904- for machine in machines
905- }
906- self.assertEqual(expected, parsed)
907-
908- def test_GET_power_parameters_with_ids_filters(self):
909- self.become_admin()
910- machines = [
911- factory.make_Node(power_parameters={factory.make_string():
912- factory.make_string()})
913- for _ in range(0, 6)
914- ]
915- expected_machines = random.sample(machines, 3)
916- response = self.client.get(
917- reverse('machines_handler'),
918- {
919- 'op': 'power_parameters',
920- 'id': [machine.system_id for machine in expected_machines],
921- })
922- self.assertEqual(
923- http.client.OK, response.status_code, response.content)
924- parsed = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
925- expected = {
926- machine.system_id: machine.power_parameters
927- for machine in expected_machines
928- }
929- self.assertEqual(expected, parsed)
930-
931 def test_POST_add_chassis_requires_admin(self):
932 response = self.client.post(
933 reverse('machines_handler'),
934
935=== modified file 'src/maasserver/api/tests/test_node.py'
936--- src/maasserver/api/tests/test_node.py 2016-04-15 22:14:33 +0000
937+++ src/maasserver/api/tests/test_node.py 2016-04-29 23:54:15 +0000
938@@ -12,6 +12,7 @@
939 from django.core.urlresolvers import reverse
940 from maasserver.enum import (
941 NODE_STATUS,
942+ NODE_STATUS_CHOICES,
943 POWER_STATE,
944 )
945 from maasserver.models import (
946@@ -22,15 +23,26 @@
947 from maasserver.testing.architecture import make_usable_architecture
948 from maasserver.testing.factory import factory
949 from maasserver.testing.oauthclient import OAuthAuthenticatedClient
950+from maasserver.testing.osystems import make_usable_osystem
951 from maasserver.testing.testcase import MAASServerTestCase
952 from maasserver.utils.converters import json_load_bytes
953+from maasserver.utils.orm import reload_object
954+from maastesting.matchers import (
955+ Equals,
956+ MockCalledOnceWith,
957+ MockNotCalled,
958+)
959 from metadataserver.models import NodeKey
960 from metadataserver.nodeinituser import get_node_init_user
961-from mock import Mock
962+from mock import (
963+ ANY,
964+ Mock,
965+)
966 from provisioningserver.refresh.node_info_scripts import (
967 LLDP_OUTPUT_NAME,
968 LSHW_OUTPUT_NAME,
969 )
970+from provisioningserver.rpc.exceptions import PowerActionAlreadyInProgress
971
972
973 class NodeAnonAPITest(MAASServerTestCase):
974@@ -280,25 +292,6 @@
975 http.client.FORBIDDEN, response.status_code, response.content)
976
977
978-class TestQueryPowerState(APITestCase):
979- """Tests for /api/2.0/nodes/<node>/?op=query_power_state"""
980-
981- def get_node_uri(self, node):
982- """Get the API URI for `node`."""
983- return reverse('node_handler', args=[node.system_id])
984-
985- def test_query_power_state(self):
986- node = factory.make_Node()
987- mock__power_control_node = self.patch(
988- node_module.Node, "power_query").return_value
989- mock__power_control_node.wait = Mock(return_value=POWER_STATE.ON)
990- response = self.client.get(
991- self.get_node_uri(node), {'op': 'query_power_state'})
992- self.assertEqual(http.client.OK, response.status_code)
993- parsed_result = json_load_bytes(response.content)
994- self.assertEqual(POWER_STATE.ON, parsed_result['state'])
995-
996-
997 class TestSetOwnerData(APITestCase):
998 """Tests for op=set_owner_data for both machines and devices."""
999
1000@@ -375,3 +368,255 @@
1001 self.assertEqual(http.client.OK, response.status_code)
1002 self.assertEqual(
1003 {}, json_load_bytes(response.content)['owner_data'])
1004+
1005+
1006+class TestPowerMixin(APITestCase):
1007+ """Test the power mixin."""
1008+
1009+ def get_node_uri(self, node):
1010+ """Get the API URI for `node`."""
1011+ # Use the machine handler to test as that will always support all
1012+ # power commands
1013+ return reverse('machine_handler', args=[node.system_id])
1014+
1015+ def test_POST_power_off_checks_permission(self):
1016+ machine = factory.make_Node()
1017+ machine_stop = self.patch(machine, 'stop')
1018+ response = self.client.post(
1019+ self.get_node_uri(machine), {'op': 'power_off'})
1020+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
1021+ self.assertThat(machine_stop, MockNotCalled())
1022+
1023+ def test_POST_power_off_returns_nothing_if_machine_was_not_stopped(self):
1024+ # The machine may not be stopped because, for example, its power type
1025+ # does not support it. In this case the machine is not returned to the
1026+ # caller.
1027+ machine = factory.make_Node(owner=self.logged_in_user)
1028+ machine_stop = self.patch(node_module.Machine, 'stop')
1029+ machine_stop.return_value = False
1030+ response = self.client.post(
1031+ self.get_node_uri(machine), {'op': 'power_off'})
1032+ self.assertEqual(http.client.OK, response.status_code)
1033+ self.assertIsNone(json_load_bytes(response.content))
1034+ self.assertThat(machine_stop, MockCalledOnceWith(
1035+ ANY, stop_mode=ANY, comment=None))
1036+
1037+ def test_POST_power_off_returns_machine(self):
1038+ machine = factory.make_Node(owner=self.logged_in_user)
1039+ self.patch(node_module.Machine, 'stop').return_value = True
1040+ response = self.client.post(
1041+ self.get_node_uri(machine), {'op': 'power_off'})
1042+ self.assertEqual(http.client.OK, response.status_code)
1043+ self.assertEqual(
1044+ machine.system_id, json_load_bytes(response.content)['system_id'])
1045+
1046+ def test_POST_power_off_may_be_repeated(self):
1047+ machine = factory.make_Node(
1048+ owner=self.logged_in_user, interface=True,
1049+ power_type='manual')
1050+ self.patch(machine, 'stop')
1051+ self.client.post(self.get_node_uri(machine), {'op': 'power_off'})
1052+ response = self.client.post(
1053+ self.get_node_uri(machine), {'op': 'power_off'})
1054+ self.assertEqual(http.client.OK, response.status_code)
1055+
1056+ def test_POST_power_off_power_offs_machines(self):
1057+ machine = factory.make_Node(owner=self.logged_in_user)
1058+ machine_stop = self.patch(node_module.Machine, 'stop')
1059+ stop_mode = factory.make_name('stop_mode')
1060+ comment = factory.make_name('comment')
1061+ self.client.post(
1062+ self.get_node_uri(machine),
1063+ {'op': 'power_off', 'stop_mode': stop_mode, 'comment': comment})
1064+ self.assertThat(
1065+ machine_stop,
1066+ MockCalledOnceWith(
1067+ self.logged_in_user, stop_mode=stop_mode, comment=comment))
1068+
1069+ def test_POST_power_off_handles_missing_comment(self):
1070+ machine = factory.make_Node(owner=self.logged_in_user)
1071+ machine_stop = self.patch(node_module.Machine, 'stop')
1072+ stop_mode = factory.make_name('stop_mode')
1073+ self.client.post(
1074+ self.get_node_uri(machine),
1075+ {'op': 'power_off', 'stop_mode': stop_mode})
1076+ self.assertThat(
1077+ machine_stop,
1078+ MockCalledOnceWith(
1079+ self.logged_in_user, stop_mode=stop_mode, comment=None))
1080+
1081+ def test_POST_power_off_returns_503_when_power_already_in_progress(self):
1082+ machine = factory.make_Node(owner=self.logged_in_user)
1083+ exc_text = factory.make_name("exc_text")
1084+ self.patch(
1085+ node_module.Machine,
1086+ 'stop').side_effect = PowerActionAlreadyInProgress(exc_text)
1087+ response = self.client.post(
1088+ self.get_node_uri(machine), {'op': 'power_off'})
1089+ self.assertResponseCode(http.client.SERVICE_UNAVAILABLE, response)
1090+ self.assertIn(
1091+ exc_text, response.content.decode(settings.DEFAULT_CHARSET))
1092+
1093+ def test_POST_power_on_checks_permission(self):
1094+ machine = factory.make_Node_with_Interface_on_Subnet(
1095+ owner=factory.make_User())
1096+ response = self.client.post(
1097+ self.get_node_uri(machine), {'op': 'power_on'})
1098+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
1099+
1100+ def test_POST_power_on_checks_ownership(self):
1101+ self.become_admin()
1102+ machine = factory.make_Node_with_Interface_on_Subnet(
1103+ status=NODE_STATUS.READY)
1104+ response = self.client.post(
1105+ self.get_node_uri(machine), {'op': 'power_on'})
1106+ self.assertEqual(http.client.CONFLICT, response.status_code)
1107+ self.assertEqual(
1108+ "Can't start node: it hasn't been allocated.",
1109+ response.content.decode(settings.DEFAULT_CHARSET))
1110+
1111+ def test_POST_power_on_returns_machine(self):
1112+ self.patch(node_module.Machine, "_start")
1113+ machine = factory.make_Node(
1114+ owner=self.logged_in_user, interface=True,
1115+ power_type='manual',
1116+ architecture=make_usable_architecture(self))
1117+ osystem = make_usable_osystem(self)
1118+ distro_series = osystem['default_release']
1119+ response = self.client.post(
1120+ self.get_node_uri(machine),
1121+ {
1122+ 'op': 'power_on',
1123+ 'distro_series': distro_series,
1124+ })
1125+ self.assertEqual(http.client.OK, response.status_code)
1126+ self.assertEqual(
1127+ machine.system_id, json_load_bytes(response.content)['system_id'])
1128+
1129+ def test_mark_broken_changes_status(self):
1130+ node = factory.make_Node(
1131+ status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
1132+ response = self.client.post(
1133+ self.get_node_uri(node), {'op': 'mark_broken'})
1134+ self.assertEqual(http.client.OK, response.status_code)
1135+ self.assertEqual(NODE_STATUS.BROKEN, reload_object(node).status)
1136+
1137+ def test_mark_broken_updates_error_description(self):
1138+ # 'error_description' parameter was renamed 'comment' for consistency
1139+ # make sure this comment updates the node's error_description
1140+ node = factory.make_Node(
1141+ status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
1142+ comment = factory.make_name('comment')
1143+ response = self.client.post(
1144+ self.get_node_uri(node),
1145+ {'op': 'mark_broken', 'comment': comment})
1146+ self.assertEqual(http.client.OK, response.status_code)
1147+ node = reload_object(node)
1148+ self.assertEqual(
1149+ (NODE_STATUS.BROKEN, comment),
1150+ (node.status, node.error_description)
1151+ )
1152+
1153+ def test_mark_broken_updates_error_description_compatibility(self):
1154+ # test old 'error_description' parameter is honored for compatibility
1155+ node = factory.make_Node(
1156+ status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
1157+ error_description = factory.make_name('error_description')
1158+ response = self.client.post(
1159+ self.get_node_uri(node),
1160+ {'op': 'mark_broken', 'error_description': error_description})
1161+ self.assertEqual(http.client.OK, response.status_code)
1162+ node = reload_object(node)
1163+ self.assertEqual(
1164+ (NODE_STATUS.BROKEN, error_description),
1165+ (node.status, node.error_description)
1166+ )
1167+
1168+ def test_mark_broken_passes_comment(self):
1169+ node = factory.make_Node(
1170+ status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
1171+ node_mark_broken = self.patch(node_module.Machine, 'mark_broken')
1172+ comment = factory.make_name('comment')
1173+ self.client.post(
1174+ self.get_node_uri(node),
1175+ {'op': 'mark_broken', 'comment': comment})
1176+ self.assertThat(
1177+ node_mark_broken,
1178+ MockCalledOnceWith(self.logged_in_user, comment))
1179+
1180+ def test_mark_broken_handles_missing_comment(self):
1181+ node = factory.make_Node(
1182+ status=NODE_STATUS.COMMISSIONING, owner=self.logged_in_user)
1183+ node_mark_broken = self.patch(node_module.Machine, 'mark_broken')
1184+ self.client.post(
1185+ self.get_node_uri(node), {'op': 'mark_broken'})
1186+ self.assertThat(
1187+ node_mark_broken,
1188+ MockCalledOnceWith(self.logged_in_user, None))
1189+
1190+ def test_mark_broken_requires_ownership(self):
1191+ node = factory.make_Node(status=NODE_STATUS.COMMISSIONING)
1192+ response = self.client.post(
1193+ self.get_node_uri(node), {'op': 'mark_broken'})
1194+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
1195+
1196+ def test_mark_broken_allowed_from_any_other_state(self):
1197+ self.patch(node_module.Machine, "_stop")
1198+ for status, _ in NODE_STATUS_CHOICES:
1199+ if status == NODE_STATUS.BROKEN:
1200+ continue
1201+
1202+ node = factory.make_Node(status=status, owner=self.logged_in_user)
1203+ response = self.client.post(
1204+ self.get_node_uri(node), {'op': 'mark_broken'})
1205+ self.expectThat(
1206+ response.status_code, Equals(http.client.OK), response)
1207+ node = reload_object(node)
1208+ self.expectThat(node.status, Equals(NODE_STATUS.BROKEN))
1209+
1210+ def test_mark_fixed_changes_status(self):
1211+ self.become_admin()
1212+ node = factory.make_Node(status=NODE_STATUS.BROKEN)
1213+ response = self.client.post(
1214+ self.get_node_uri(node), {'op': 'mark_fixed'})
1215+ self.assertEqual(http.client.OK, response.status_code)
1216+ self.assertEqual(NODE_STATUS.READY, reload_object(node).status)
1217+
1218+ def test_mark_fixed_requires_admin(self):
1219+ node = factory.make_Node(status=NODE_STATUS.BROKEN)
1220+ response = self.client.post(
1221+ self.get_node_uri(node), {'op': 'mark_fixed'})
1222+ self.assertEqual(http.client.FORBIDDEN, response.status_code)
1223+
1224+ def test_mark_fixed_passes_comment(self):
1225+ self.become_admin()
1226+ node = factory.make_Node(status=NODE_STATUS.BROKEN)
1227+ node_mark_fixed = self.patch(node_module.Machine, 'mark_fixed')
1228+ comment = factory.make_name('comment')
1229+ self.client.post(
1230+ self.get_node_uri(node),
1231+ {'op': 'mark_fixed', 'comment': comment})
1232+ self.assertThat(
1233+ node_mark_fixed,
1234+ MockCalledOnceWith(self.logged_in_user, comment))
1235+
1236+ def test_mark_fixed_handles_missing_comment(self):
1237+ self.become_admin()
1238+ node = factory.make_Node(status=NODE_STATUS.BROKEN)
1239+ node_mark_fixed = self.patch(node_module.Machine, 'mark_fixed')
1240+ self.client.post(
1241+ self.get_node_uri(node), {'op': 'mark_fixed'})
1242+ self.assertThat(
1243+ node_mark_fixed,
1244+ MockCalledOnceWith(self.logged_in_user, None))
1245+
1246+ def test_query_power_state(self):
1247+ node = factory.make_Node()
1248+ mock__power_control_node = self.patch(
1249+ node_module.Node, "power_query").return_value
1250+ mock__power_control_node.wait = Mock(return_value=POWER_STATE.ON)
1251+ response = self.client.get(
1252+ self.get_node_uri(node), {'op': 'query_power_state'})
1253+ self.assertEqual(http.client.OK, response.status_code)
1254+ parsed_result = json_load_bytes(response.content)
1255+ self.assertEqual(POWER_STATE.ON, parsed_result['state'])
1256
1257=== modified file 'src/maasserver/api/tests/test_nodes.py'
1258--- src/maasserver/api/tests/test_nodes.py 2016-04-11 16:23:26 +0000
1259+++ src/maasserver/api/tests/test_nodes.py 2016-04-29 23:54:15 +0000
1260@@ -576,3 +576,66 @@
1261 response = self.client.put(reverse('nodes_handler'), {})
1262 self.assertEqual(
1263 http.client.METHOD_NOT_ALLOWED, response.status_code)
1264+
1265+
1266+class TestPowersMixin(APITestCase):
1267+ """Test the powers mixin."""
1268+
1269+ def get_node_uri(self, node):
1270+ """Get the API URI for `node`."""
1271+ # Use the machine handler to test as that will always support all
1272+ # power commands
1273+ return reverse('machine_handler', args=[node.system_id])
1274+
1275+ def test_GET_power_parameters_requires_admin(self):
1276+ response = self.client.get(
1277+ reverse('machines_handler'),
1278+ {
1279+ 'op': 'power_parameters',
1280+ })
1281+ self.assertEqual(
1282+ http.client.FORBIDDEN, response.status_code, response.content)
1283+
1284+ def test_GET_power_parameters_without_ids_does_not_filter(self):
1285+ self.become_admin()
1286+ machines = [
1287+ factory.make_Node(power_parameters={factory.make_string():
1288+ factory.make_string()})
1289+ for _ in range(0, 3)
1290+ ]
1291+ response = self.client.get(
1292+ reverse('machines_handler'),
1293+ {
1294+ 'op': 'power_parameters',
1295+ })
1296+ self.assertEqual(
1297+ http.client.OK, response.status_code, response.content)
1298+ parsed = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
1299+ expected = {
1300+ machine.system_id: machine.power_parameters
1301+ for machine in machines
1302+ }
1303+ self.assertEqual(expected, parsed)
1304+
1305+ def test_GET_power_parameters_with_ids_filters(self):
1306+ self.become_admin()
1307+ machines = [
1308+ factory.make_Node(power_parameters={factory.make_string():
1309+ factory.make_string()})
1310+ for _ in range(0, 6)
1311+ ]
1312+ expected_machines = random.sample(machines, 3)
1313+ response = self.client.get(
1314+ reverse('machines_handler'),
1315+ {
1316+ 'op': 'power_parameters',
1317+ 'id': [machine.system_id for machine in expected_machines],
1318+ })
1319+ self.assertEqual(
1320+ http.client.OK, response.status_code, response.content)
1321+ parsed = json.loads(response.content.decode(settings.DEFAULT_CHARSET))
1322+ expected = {
1323+ machine.system_id: machine.power_parameters
1324+ for machine in expected_machines
1325+ }
1326+ self.assertEqual(expected, parsed)
1327
1328=== modified file 'src/maasserver/models/node.py'
1329--- src/maasserver/models/node.py 2016-04-29 18:26:59 +0000
1330+++ src/maasserver/models/node.py 2016-04-29 23:54:15 +0000
1331@@ -471,7 +471,7 @@
1332 node = get_object_or_404(
1333 self.model, system_id=system_id, **kwargs)
1334 if user.has_perm(perm, node):
1335- return node
1336+ return typecast_to_node_type(node)
1337 else:
1338 raise PermissionDenied()
1339
1340@@ -1823,7 +1823,8 @@
1341
1342 # boot_mode is something that tells the template whether this is
1343 # a PXE boot or a local HD boot.
1344- if self.status == NODE_STATUS.DEPLOYED:
1345+ if (self.status == NODE_STATUS.DEPLOYED or
1346+ self.node_type != NODE_TYPE.MACHINE):
1347 power_params['boot_mode'] = 'local'
1348 else:
1349 power_params['boot_mode'] = 'pxe'
1350@@ -1854,7 +1855,7 @@
1351 return PowerInfo(False, False, False, None, None)
1352 else:
1353 if power_type == 'manual' or self.node_type in (
1354- NODE_TYPE.RACK_CONTROLLER,
1355+ NODE_TYPE.REGION_CONTROLLER,
1356 NODE_TYPE.REGION_AND_RACK_CONTROLLER):
1357 can_be_started = False
1358 can_be_stopped = False
1359@@ -2598,7 +2599,8 @@
1360 return "xinstall"
1361 else:
1362 return "local"
1363- elif self.status == NODE_STATUS.DEPLOYED:
1364+ elif (self.status == NODE_STATUS.DEPLOYED or
1365+ self.node_type != NODE_TYPE.MACHINE):
1366 return "local"
1367 else:
1368 return "poweroff"
1369
1370=== modified file 'src/maasserver/models/tests/test_node.py'
1371--- src/maasserver/models/tests/test_node.py 2016-04-29 18:26:59 +0000
1372+++ src/maasserver/models/tests/test_node.py 2016-04-29 23:54:15 +0000
1373@@ -861,7 +861,7 @@
1374
1375 def test_get_effective_power_info_can_be_False_for_rack_controller(self):
1376 for node_type in (NODE_TYPE.REGION_AND_RACK_CONTROLLER,
1377- NODE_TYPE.RACK_CONTROLLER):
1378+ NODE_TYPE.REGION_CONTROLLER):
1379 node = factory.make_Node(node_type=node_type)
1380 gepp = self.patch(node, "get_effective_power_parameters")
1381 # For manual the power can never be turned off or on.
1382@@ -3426,6 +3426,14 @@
1383 Node.objects.get_node_or_404(
1384 node.system_id, user, NODE_PERMISSION.VIEW))
1385
1386+ def test_get_node_or_404_returns_proper_node_object(self):
1387+ user = factory.make_User()
1388+ node = self.make_node(user, node_type=NODE_TYPE.RACK_CONTROLLER)
1389+ rack = Node.objects.get_node_or_404(
1390+ node.system_id, user, NODE_PERMISSION.VIEW)
1391+ self.assertEqual(node, rack)
1392+ self.assertIsInstance(rack, RackController)
1393+
1394 def test_netboot_on(self):
1395 node = factory.make_Node(netboot=False)
1396 node.set_netboot(True)
1397
1398=== modified file 'src/maasserver/node_action.py'
1399--- src/maasserver/node_action.py 2015-12-05 01:43:40 +0000
1400+++ src/maasserver/node_action.py 2016-04-29 23:54:15 +0000
1401@@ -31,6 +31,7 @@
1402 NODE_STATUS,
1403 NODE_STATUS_CHOICES_DICT,
1404 NODE_TYPE,
1405+ NODE_TYPE_CHOICES,
1406 POWER_STATE,
1407 )
1408 from maasserver.exceptions import (
1409@@ -83,11 +84,10 @@
1410 Will be used as the label for the action's button.
1411 """)
1412
1413- node_only = abstractproperty("""
1414- Can only be performed when the node type is node.
1415+ for_type = abstractproperty("""
1416+ Can only be performed when the node type is in the for_type set.
1417
1418- A boolean value. True for only available for node_type node, false
1419- otherwise.
1420+ A list of NODE_TYPEs which are applicable for this action.
1421 """)
1422
1423 actionable_statuses = abstractproperty("""
1424@@ -124,9 +124,12 @@
1425 If the node is not node_type node then actionable_statuses will not
1426 be used, as the status doesn't matter for a non-node type.
1427 """
1428- if self.node_only and not self.node.node_type == NODE_TYPE.MACHINE:
1429- return False
1430- if self.node.node_type == NODE_TYPE.MACHINE:
1431+ if self.node.node_type not in self.for_type:
1432+ return False
1433+ elif (self.node_permission == NODE_PERMISSION.ADMIN and
1434+ not self.user.is_superuser):
1435+ return False
1436+ elif self.node.node_type == NODE_TYPE.MACHINE:
1437 return self.node.status in self.actionable_statuses
1438 return True
1439
1440@@ -181,7 +184,7 @@
1441 actionable_statuses = ALL_STATUSES
1442 permission = NODE_PERMISSION.EDIT
1443 node_permission = NODE_PERMISSION.ADMIN
1444- node_only = False
1445+ for_type = {i for i, _ in enumerate(NODE_TYPE_CHOICES)}
1446
1447 def execute(self):
1448 """Redirect to the delete view's confirmation page.
1449@@ -201,7 +204,7 @@
1450 actionable_statuses = ALL_STATUSES
1451 permission = NODE_PERMISSION.EDIT
1452 node_permission = NODE_PERMISSION.ADMIN
1453- node_only = False
1454+ for_type = {i for i, _ in enumerate(NODE_TYPE_CHOICES)}
1455
1456 def execute(self, zone_id=None):
1457 """See `NodeAction.execute`."""
1458@@ -225,7 +228,7 @@
1459 NODE_STATUS.BROKEN,
1460 )
1461 permission = NODE_PERMISSION.ADMIN
1462- node_only = True
1463+ for_type = {NODE_TYPE.MACHINE}
1464
1465 def execute(
1466 self, enable_ssh=False, skip_networking=False,
1467@@ -256,7 +259,7 @@
1468 NODE_STATUS.DEPLOYING
1469 )
1470 permission = NODE_PERMISSION.ADMIN
1471- node_only = True
1472+ for_type = {NODE_TYPE.MACHINE}
1473
1474 def execute(self):
1475 """See `NodeAction.execute`."""
1476@@ -273,7 +276,7 @@
1477 display_sentence = "acquired"
1478 actionable_statuses = (NODE_STATUS.READY, )
1479 permission = NODE_PERMISSION.VIEW
1480- node_only = True
1481+ for_type = {NODE_TYPE.MACHINE}
1482
1483 def execute(self):
1484 """See `NodeAction.execute`."""
1485@@ -288,7 +291,7 @@
1486 display_sentence = "deployed"
1487 actionable_statuses = (NODE_STATUS.READY, NODE_STATUS.ALLOCATED)
1488 permission = NODE_PERMISSION.VIEW
1489- node_only = True
1490+ for_type = {NODE_TYPE.MACHINE}
1491
1492 def execute(self, osystem=None, distro_series=None, hwe_kernel=None):
1493 """See `NodeAction.execute`."""
1494@@ -334,7 +337,7 @@
1495 NODE_STATUS.BROKEN,
1496 )
1497 permission = NODE_PERMISSION.EDIT
1498- node_only = True
1499+ for_type = {NODE_TYPE.MACHINE, NODE_TYPE.RACK_CONTROLLER}
1500
1501 def execute(self):
1502 """See `NodeAction.execute`."""
1503@@ -366,7 +369,7 @@
1504 # Let a user power off a node in any non-active status.
1505 actionable_statuses = NON_MONITORED_STATUSES
1506 permission = NODE_PERMISSION.EDIT
1507- node_only = True
1508+ for_type = {NODE_TYPE.MACHINE, NODE_TYPE.RACK_CONTROLLER}
1509
1510 def execute(self):
1511 """See `NodeAction.execute`."""
1512@@ -395,7 +398,7 @@
1513 NODE_STATUS.FAILED_DISK_ERASING,
1514 )
1515 permission = NODE_PERMISSION.EDIT
1516- node_only = True
1517+ for_type = {NODE_TYPE.MACHINE}
1518
1519 def execute(self):
1520 """See `NodeAction.execute`."""
1521@@ -423,7 +426,7 @@
1522 NODE_STATUS.DISK_ERASING,
1523 ] + FAILED_STATUSES
1524 permission = NODE_PERMISSION.EDIT
1525- node_only = True
1526+ for_type = {NODE_TYPE.MACHINE, NODE_TYPE.RACK_CONTROLLER}
1527
1528 def execute(self):
1529 """See `NodeAction.execute`."""
1530@@ -437,7 +440,7 @@
1531 display_sentence = "marked fixed"
1532 actionable_statuses = (NODE_STATUS.BROKEN, )
1533 permission = NODE_PERMISSION.ADMIN
1534- node_only = True
1535+ for_type = {NODE_TYPE.MACHINE, NODE_TYPE.RACK_CONTROLLER}
1536
1537 def execute(self):
1538 """See `NodeAction.execute`."""
1539
1540=== modified file 'src/maasserver/static/js/angular/controllers/node_details.js'
1541--- src/maasserver/static/js/angular/controllers/node_details.js 2016-04-26 19:52:34 +0000
1542+++ src/maasserver/static/js/angular/controllers/node_details.js 2016-04-29 23:54:15 +0000
1543@@ -22,7 +22,7 @@
1544 $scope.loaded = false;
1545 $scope.node = null;
1546 $scope.actionOption = null;
1547- $scope.allActionOptions = GeneralManager.getData("node_actions");
1548+ $scope.allActionOptions = GeneralManager.getData("machine_actions");
1549 $scope.availableActionOptions = [];
1550 $scope.actionError = null;
1551 $scope.power_types = GeneralManager.getData("power_types");
1552
1553=== modified file 'src/maasserver/static/js/angular/controllers/nodes_list.js'
1554--- src/maasserver/static/js/angular/controllers/nodes_list.js 2016-04-21 01:26:45 +0000
1555+++ src/maasserver/static/js/angular/controllers/nodes_list.js 2016-04-29 23:54:15 +0000
1556@@ -52,7 +52,7 @@
1557 $scope.tabs.nodes.column = 'fqdn';
1558 $scope.tabs.nodes.actionOption = null;
1559 $scope.tabs.nodes.takeActionOptions = GeneralManager.getData(
1560- "node_actions");
1561+ "machine_actions");
1562 $scope.tabs.nodes.actionErrorCount = 0;
1563 $scope.tabs.nodes.actionProgress = {
1564 total: 0,
1565@@ -114,8 +114,9 @@
1566 $scope.tabs.controllers.filters = SearchService.getEmptyFilter();
1567 $scope.tabs.controllers.column = 'fqdn';
1568 $scope.tabs.controllers.actionOption = null;
1569+ // Rack controllers contain all options
1570 $scope.tabs.controllers.takeActionOptions = GeneralManager.getData(
1571- "controller_actions");
1572+ "rack_controller_actions");
1573 $scope.tabs.controllers.actionErrorCount = 0;
1574 $scope.tabs.controllers.actionProgress = {
1575 total: 0,
1576
1577=== modified file 'src/maasserver/static/js/angular/controllers/tests/test_node_details.js'
1578--- src/maasserver/static/js/angular/controllers/tests/test_node_details.js 2016-04-26 19:52:34 +0000
1579+++ src/maasserver/static/js/angular/controllers/tests/test_node_details.js 2016-04-29 23:54:15 +0000
1580@@ -185,7 +185,7 @@
1581 expect($scope.node).toBeNull();
1582 expect($scope.actionOption).toBeNull();
1583 expect($scope.allActionOptions).toBe(
1584- GeneralManager.getData("node_actions"));
1585+ GeneralManager.getData("machine_actions"));
1586 expect($scope.availableActionOptions).toEqual([]);
1587 expect($scope.actionError).toBeNull();
1588 expect($scope.osinfo).toBe(GeneralManager.getData("osinfo"));
1589
1590=== modified file 'src/maasserver/static/js/angular/controllers/tests/test_nodes_list.js'
1591--- src/maasserver/static/js/angular/controllers/tests/test_nodes_list.js 2016-03-28 13:54:47 +0000
1592+++ src/maasserver/static/js/angular/controllers/tests/test_nodes_list.js 2016-04-29 23:54:15 +0000
1593@@ -306,7 +306,11 @@
1594 SearchService.getEmptyFilter());
1595 expect(tabScope.column).toBe("fqdn");
1596 expect(tabScope.actionOption).toBeNull();
1597- expect(tabScope.takeActionOptions).toEqual([]);
1598+ // The controllers page uses a function so it can handle
1599+ // different controller types
1600+ if(tab !== "controllers") {
1601+ expect(tabScope.takeActionOptions).toEqual([]);
1602+ }
1603 expect(tabScope.actionErrorCount).toBe(0);
1604 expect(tabScope.zoneSelection).toBeNull();
1605
1606
1607=== modified file 'src/maasserver/static/js/angular/factories/general.js'
1608--- src/maasserver/static/js/angular/factories/general.js 2016-04-11 16:23:26 +0000
1609+++ src/maasserver/static/js/angular/factories/general.js 2016-04-29 23:54:15 +0000
1610@@ -21,8 +21,8 @@
1611 function GeneralManager() {
1612 // Holds the available endpoints and its data.
1613 this._data = {
1614- node_actions: {
1615- method: "general.node_actions",
1616+ machine_actions: {
1617+ method: "general.machine_actions",
1618 data: [],
1619 loaded: false,
1620 polling: false,
1621@@ -35,8 +35,22 @@
1622 polling: false,
1623 nextPromise: null
1624 },
1625- controller_actions: {
1626- method: "general.controller_actions",
1627+ region_controller_actions: {
1628+ method: "general.region_controller_actions",
1629+ data: [],
1630+ loaded: false,
1631+ polling: false,
1632+ nextPromise: null
1633+ },
1634+ rack_controller_actions: {
1635+ method: "general.rack_controller_actions",
1636+ data: [],
1637+ loaded: false,
1638+ polling: false,
1639+ nextPromise: null
1640+ },
1641+ region_and_rack_controller_actions: {
1642+ method: "general.region_and_rack_controller_actions",
1643 data: [],
1644 loaded: false,
1645 polling: false,
1646
1647=== modified file 'src/maasserver/static/js/angular/factories/tests/test_general.js'
1648--- src/maasserver/static/js/angular/factories/tests/test_general.js 2016-04-11 16:23:26 +0000
1649+++ src/maasserver/static/js/angular/factories/tests/test_general.js 2016-04-29 23:54:15 +0000
1650@@ -50,18 +50,19 @@
1651
1652 it("_data has expected keys", function() {
1653 expect(Object.keys(GeneralManager._data)).toEqual(
1654- ["node_actions", "device_actions", "controller_actions",
1655+ ["machine_actions", "device_actions", "region_controller_actions",
1656+ "rack_controller_actions", "region_and_rack_controller_actions",
1657 "architectures", "hwe_kernels", "default_min_hwe_kernel", "osinfo",
1658 "bond_options", "version", "power_types"]);
1659 });
1660
1661- it("_data.node_actions has correct data", function() {
1662- var node_actions = GeneralManager._data.node_actions;
1663- expect(node_actions.method).toBe("general.node_actions");
1664- expect(node_actions.data).toEqual([]);
1665- expect(node_actions.loaded).toBe(false);
1666- expect(node_actions.polling).toBe(false);
1667- expect(node_actions.nextPromise).toBeNull();
1668+ it("_data.machine_actions has correct data", function() {
1669+ var machine_actions = GeneralManager._data.machine_actions;
1670+ expect(machine_actions.method).toBe("general.machine_actions");
1671+ expect(machine_actions.data).toEqual([]);
1672+ expect(machine_actions.loaded).toBe(false);
1673+ expect(machine_actions.polling).toBe(false);
1674+ expect(machine_actions.nextPromise).toBeNull();
1675 });
1676
1677 it("_data.device_actions has correct data", function() {
1678@@ -73,13 +74,37 @@
1679 expect(device_actions.nextPromise).toBeNull();
1680 });
1681
1682- it("_data.controller_actions has correct data", function() {
1683- var controller_actions = GeneralManager._data.controller_actions;
1684- expect(controller_actions.method).toBe("general.controller_actions");
1685- expect(controller_actions.data).toEqual([]);
1686- expect(controller_actions.loaded).toBe(false);
1687- expect(controller_actions.polling).toBe(false);
1688- expect(controller_actions.nextPromise).toBeNull();
1689+ it("_data.region_controller_actions has correct data", function() {
1690+ var region_controller_actions =
1691+ GeneralManager._data.region_controller_actions;
1692+ expect(region_controller_actions.method).toBe(
1693+ "general.region_controller_actions");
1694+ expect(region_controller_actions.data).toEqual([]);
1695+ expect(region_controller_actions.loaded).toBe(false);
1696+ expect(region_controller_actions.polling).toBe(false);
1697+ expect(region_controller_actions.nextPromise).toBeNull();
1698+ });
1699+
1700+ it("_data.rack_controller_actions has correct data", function() {
1701+ var rack_controller_actions =
1702+ GeneralManager._data.rack_controller_actions;
1703+ expect(rack_controller_actions.method).toBe(
1704+ "general.rack_controller_actions");
1705+ expect(rack_controller_actions.data).toEqual([]);
1706+ expect(rack_controller_actions.loaded).toBe(false);
1707+ expect(rack_controller_actions.polling).toBe(false);
1708+ expect(rack_controller_actions.nextPromise).toBeNull();
1709+ });
1710+
1711+ it("_data.region_and_rack_controller_actions has correct data", function() {
1712+ var region_and_rack_controller_actions =
1713+ GeneralManager._data.region_and_rack_controller_actions;
1714+ expect(region_and_rack_controller_actions.method).toBe(
1715+ "general.region_and_rack_controller_actions");
1716+ expect(region_and_rack_controller_actions.data).toEqual([]);
1717+ expect(region_and_rack_controller_actions.loaded).toBe(false);
1718+ expect(region_and_rack_controller_actions.polling).toBe(false);
1719+ expect(region_and_rack_controller_actions.nextPromise).toBeNull();
1720 });
1721
1722 it("_data.architectures has correct data", function() {
1723@@ -218,16 +243,16 @@
1724 });
1725
1726 it("returns data object", function() {
1727- expect(GeneralManager._getInternalData("node_actions")).toBe(
1728- GeneralManager._data.node_actions);
1729+ expect(GeneralManager._getInternalData("machine_actions")).toBe(
1730+ GeneralManager._data.machine_actions);
1731 });
1732 });
1733
1734 describe("getData", function() {
1735
1736 it("returns data from internal data", function() {
1737- expect(GeneralManager.getData("node_actions")).toBe(
1738- GeneralManager._data.node_actions.data);
1739+ expect(GeneralManager.getData("machine_actions")).toBe(
1740+ GeneralManager._data.machine_actions.data);
1741 });
1742 });
1743
1744@@ -238,9 +263,12 @@
1745 });
1746
1747 it("returns false if one false", function() {
1748- GeneralManager._data.node_actions.loaded = true;
1749+ GeneralManager._data.machine_actions.loaded = true;
1750 GeneralManager._data.device_actions.loaded = true;
1751- GeneralManager._data.controller_actions.loaded = true;
1752+ GeneralManager._data.rack_controller_actions.loaded = true;
1753+ GeneralManager._data.region_controller_actions.loaded = true;
1754+ GeneralManager._data.region_and_rack_controller_actions.loaded =
1755+ true;
1756 GeneralManager._data.architectures.loaded = true;
1757 GeneralManager._data.hwe_kernels.loaded = true;
1758 GeneralManager._data.osinfo.loaded = true;
1759@@ -250,9 +278,12 @@
1760 });
1761
1762 it("returns true if all true", function() {
1763- GeneralManager._data.node_actions.loaded = true;
1764+ GeneralManager._data.machine_actions.loaded = true;
1765 GeneralManager._data.device_actions.loaded = true;
1766- GeneralManager._data.controller_actions.loaded = true;
1767+ GeneralManager._data.rack_controller_actions.loaded = true;
1768+ GeneralManager._data.region_controller_actions.loaded = true;
1769+ GeneralManager._data.region_and_rack_controller_actions.loaded =
1770+ true;
1771 GeneralManager._data.architectures.loaded = true;
1772 GeneralManager._data.hwe_kernels.loaded = true;
1773 GeneralManager._data.default_min_hwe_kernel.loaded = true;
1774@@ -268,8 +299,8 @@
1775
1776 it("returns loaded from internal data", function() {
1777 var loaded = {};
1778- GeneralManager._data.node_actions.loaded = loaded;
1779- expect(GeneralManager.isDataLoaded("node_actions")).toBe(loaded);
1780+ GeneralManager._data.machine_actions.loaded = loaded;
1781+ expect(GeneralManager.isDataLoaded("machine_actions")).toBe(loaded);
1782 });
1783 });
1784
1785@@ -280,7 +311,7 @@
1786 });
1787
1788 it("returns true if one true", function() {
1789- GeneralManager._data.node_actions.polling = true;
1790+ GeneralManager._data.machine_actions.polling = true;
1791 GeneralManager._data.architectures.polling = false;
1792 GeneralManager._data.hwe_kernels.polling = false;
1793 GeneralManager._data.osinfo.polling = false;
1794@@ -288,7 +319,7 @@
1795 });
1796
1797 it("returns true if all true", function() {
1798- GeneralManager._data.node_actions.polling = true;
1799+ GeneralManager._data.machine_actions.polling = true;
1800 GeneralManager._data.architectures.polling = true;
1801 GeneralManager._data.hwe_kernels.polling = true;
1802 GeneralManager._data.osinfo.polling = true;
1803@@ -300,8 +331,9 @@
1804
1805 it("returns polling from internal data", function() {
1806 var polling = {};
1807- GeneralManager._data.node_actions.polling = polling;
1808- expect(GeneralManager.isDataPolling("node_actions")).toBe(polling);
1809+ GeneralManager._data.machine_actions.polling = polling;
1810+ expect(GeneralManager.isDataPolling("machine_actions")).toBe(
1811+ polling);
1812 });
1813 });
1814
1815@@ -309,16 +341,16 @@
1816
1817 it("sets polling to true and calls _poll", function() {
1818 spyOn(GeneralManager, "_poll");
1819- GeneralManager.startPolling("node_actions");
1820- expect(GeneralManager._data.node_actions.polling).toBe(true);
1821+ GeneralManager.startPolling("machine_actions");
1822+ expect(GeneralManager._data.machine_actions.polling).toBe(true);
1823 expect(GeneralManager._poll).toHaveBeenCalledWith(
1824- GeneralManager._data.node_actions);
1825+ GeneralManager._data.machine_actions);
1826 });
1827
1828 it("does nothing if already polling", function() {
1829 spyOn(GeneralManager, "_poll");
1830- GeneralManager._data.node_actions.polling = true;
1831- GeneralManager.startPolling("node_actions");
1832+ GeneralManager._data.machine_actions.polling = true;
1833+ GeneralManager.startPolling("machine_actions");
1834 expect(GeneralManager._poll).not.toHaveBeenCalled();
1835 });
1836 });
1837@@ -328,10 +360,10 @@
1838 it("sets polling to false and cancels promise", function() {
1839 spyOn($timeout, "cancel");
1840 var nextPromise = {};
1841- GeneralManager._data.node_actions.polling = true;
1842- GeneralManager._data.node_actions.nextPromise = nextPromise;
1843- GeneralManager.stopPolling("node_actions");
1844- expect(GeneralManager._data.node_actions.polling).toBe(false);
1845+ GeneralManager._data.machine_actions.polling = true;
1846+ GeneralManager._data.machine_actions.nextPromise = nextPromise;
1847+ GeneralManager.stopPolling("machine_actions");
1848+ expect(GeneralManager._data.machine_actions.polling).toBe(false);
1849 expect($timeout.cancel).toHaveBeenCalledWith(nextPromise);
1850 });
1851 });
1852@@ -341,32 +373,32 @@
1853 it("calls callMethod with method", function() {
1854 spyOn(RegionConnection, "callMethod").and.returnValue(
1855 $q.defer().promise);
1856- GeneralManager._loadData(GeneralManager._data.node_actions);
1857+ GeneralManager._loadData(GeneralManager._data.machine_actions);
1858 expect(RegionConnection.callMethod).toHaveBeenCalledWith(
1859- GeneralManager._data.node_actions.method);
1860+ GeneralManager._data.machine_actions.method);
1861 });
1862
1863 it("sets loaded to true", function() {
1864 var defer = $q.defer();
1865 spyOn(RegionConnection, "callMethod").and.returnValue(
1866 defer.promise);
1867- GeneralManager._loadData(GeneralManager._data.node_actions);
1868+ GeneralManager._loadData(GeneralManager._data.machine_actions);
1869 defer.resolve([]);
1870 $rootScope.$digest();
1871- expect(GeneralManager._data.node_actions.loaded).toBe(true);
1872+ expect(GeneralManager._data.machine_actions.loaded).toBe(true);
1873 });
1874
1875- it("sets node_actions data without changing reference", function() {
1876+ it("sets machine_actions data without changing reference", function() {
1877 var defer = $q.defer();
1878 spyOn(RegionConnection, "callMethod").and.returnValue(
1879 defer.promise);
1880- var actionsData = GeneralManager._data.node_actions.data;
1881+ var actionsData = GeneralManager._data.machine_actions.data;
1882 var newData = [makeName("action")];
1883- GeneralManager._loadData(GeneralManager._data.node_actions);
1884+ GeneralManager._loadData(GeneralManager._data.machine_actions);
1885 defer.resolve(newData);
1886 $rootScope.$digest();
1887- expect(GeneralManager._data.node_actions.data).toEqual(newData);
1888- expect(GeneralManager._data.node_actions.data).toBe(actionsData);
1889+ expect(GeneralManager._data.machine_actions.data).toEqual(newData);
1890+ expect(GeneralManager._data.machine_actions.data).toBe(actionsData);
1891 });
1892
1893 it("sets osinfo data without changing reference", function() {
1894@@ -388,7 +420,8 @@
1895 defer.promise);
1896 spyOn(ErrorService, "raiseError");
1897 var error = makeName("error");
1898- GeneralManager._loadData(GeneralManager._data.node_actions, true);
1899+ GeneralManager._loadData(
1900+ GeneralManager._data.machine_actions, true);
1901 defer.reject(error);
1902 $rootScope.$digest();
1903 expect(ErrorService.raiseError).toHaveBeenCalledWith(error);
1904@@ -400,7 +433,8 @@
1905 defer.promise);
1906 spyOn(ErrorService, "raiseError");
1907 var error = makeName("error");
1908- GeneralManager._loadData(GeneralManager._data.node_actions, false);
1909+ GeneralManager._loadData(
1910+ GeneralManager._data.machine_actions, false);
1911 defer.reject(error);
1912 $rootScope.$digest();
1913 expect(ErrorService.raiseError).not.toHaveBeenCalled();
1914@@ -412,7 +446,7 @@
1915 defer.promise);
1916 spyOn(ErrorService, "raiseError");
1917 var error = makeName("error");
1918- GeneralManager._loadData(GeneralManager._data.node_actions);
1919+ GeneralManager._loadData(GeneralManager._data.machine_actions);
1920 defer.reject(error);
1921 $rootScope.$digest();
1922 expect(ErrorService.raiseError).not.toHaveBeenCalled();
1923@@ -422,9 +456,10 @@
1924 describe("_pollAgain", function() {
1925
1926 it("sets nextPromise on data", function() {
1927- GeneralManager._pollAgain(GeneralManager._data.node_actions);
1928+ GeneralManager._pollAgain(GeneralManager._data.machine_actions);
1929 expect(
1930- GeneralManager._data.node_actions.nextPromise).not.toBeNull();
1931+ GeneralManager._data.machine_actions.nextPromise
1932+ ).not.toBeNull();
1933 });
1934 });
1935
1936@@ -433,29 +468,31 @@
1937 it("calls _pollAgain with error timeout if not connected", function() {
1938 spyOn(RegionConnection, "isConnected").and.returnValue(false);
1939 spyOn(GeneralManager, "_pollAgain");
1940- GeneralManager._poll(GeneralManager._data.node_actions);
1941+ GeneralManager._poll(GeneralManager._data.machine_actions);
1942 expect(GeneralManager._pollAgain).toHaveBeenCalledWith(
1943- GeneralManager._data.node_actions,
1944+ GeneralManager._data.machine_actions,
1945 GeneralManager._pollErrorTimeout);
1946 });
1947
1948 it("calls _loadData with raiseError false", function() {
1949 spyOn(GeneralManager, "_loadData").and.returnValue(
1950 $q.defer().promise);
1951- GeneralManager._poll(GeneralManager._data.node_actions);
1952+ GeneralManager._poll(GeneralManager._data.machine_actions);
1953 expect(GeneralManager._loadData).toHaveBeenCalledWith(
1954- GeneralManager._data.node_actions, false);
1955+ GeneralManager._data.machine_actions, false);
1956 });
1957
1958- it("calls _pollAgain with empty timeout for node_actions", function() {
1959+ it(
1960+ "calls _pollAgain with empty timeout for machine_actions",
1961+ function() {
1962 var defer = $q.defer();
1963 spyOn(GeneralManager, "_pollAgain");
1964 spyOn(GeneralManager, "_loadData").and.returnValue(defer.promise);
1965- GeneralManager._poll(GeneralManager._data.node_actions);
1966+ GeneralManager._poll(GeneralManager._data.machine_actions);
1967 defer.resolve([]);
1968 $rootScope.$digest();
1969 expect(GeneralManager._pollAgain).toHaveBeenCalledWith(
1970- GeneralManager._data.node_actions,
1971+ GeneralManager._data.machine_actions,
1972 GeneralManager._pollEmptyTimeout);
1973 });
1974
1975@@ -471,17 +508,17 @@
1976 GeneralManager._pollEmptyTimeout);
1977 });
1978
1979- it("calls _pollAgain with timeout for node_actions", function() {
1980+ it("calls _pollAgain with timeout for machine_actions", function() {
1981 var defer = $q.defer();
1982 spyOn(GeneralManager, "_pollAgain");
1983 spyOn(GeneralManager, "_loadData").and.returnValue(defer.promise);
1984- var node_actions = [makeName("action")];
1985- GeneralManager._data.node_actions.data = node_actions;
1986- GeneralManager._poll(GeneralManager._data.node_actions);
1987- defer.resolve(node_actions);
1988+ var machine_actions = [makeName("action")];
1989+ GeneralManager._data.machine_actions.data = machine_actions;
1990+ GeneralManager._poll(GeneralManager._data.machine_actions);
1991+ defer.resolve(machine_actions);
1992 $rootScope.$digest();
1993 expect(GeneralManager._pollAgain).toHaveBeenCalledWith(
1994- GeneralManager._data.node_actions,
1995+ GeneralManager._data.machine_actions,
1996 GeneralManager._pollTimeout);
1997 });
1998
1999@@ -491,12 +528,12 @@
2000 spyOn(GeneralManager, "_loadData").and.returnValue(defer.promise);
2001 var error = makeName("error");
2002 spyOn(console, "log");
2003- GeneralManager._poll(GeneralManager._data.node_actions);
2004+ GeneralManager._poll(GeneralManager._data.machine_actions);
2005 defer.reject(error);
2006 $rootScope.$digest();
2007 expect(console.log).toHaveBeenCalledWith(error);
2008 expect(GeneralManager._pollAgain).toHaveBeenCalledWith(
2009- GeneralManager._data.node_actions,
2010+ GeneralManager._data.machine_actions,
2011 GeneralManager._pollErrorTimeout);
2012 });
2013 });
2014@@ -507,7 +544,7 @@
2015 spyOn(GeneralManager, "_loadData").and.returnValue(
2016 $q.defer().promise);
2017 GeneralManager.loadItems();
2018- expect(GeneralManager._loadData.calls.count()).toBe(10);
2019+ expect(GeneralManager._loadData.calls.count()).toBe(12);
2020 });
2021
2022 it("resolve defer once all resolve", function(done) {
2023@@ -521,6 +558,8 @@
2024 $q.defer(),
2025 $q.defer(),
2026 $q.defer(),
2027+ $q.defer(),
2028+ $q.defer(),
2029 $q.defer()
2030 ];
2031 var i = 0;
2032
2033=== modified file 'src/maasserver/testing/sampledata.py'
2034--- src/maasserver/testing/sampledata.py 2016-04-14 15:25:36 +0000
2035+++ src/maasserver/testing/sampledata.py 2016-04-29 23:54:15 +0000
2036@@ -299,6 +299,40 @@
2037 alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.3.3",
2038 subnet=subnet_3, interface=bond0_10)
2039
2040+ # Region controller (happy-region)
2041+ # eth0 - fabric 0 - untagged
2042+ # eth1 - fabric 0 - untagged
2043+ # eth2 - fabric 1 - untagged - 172.16.2.4/24 - static
2044+ # bond0 - fabric 0 - untagged - 172.16.1.4/24 - static
2045+ # bond0.10 - fabric 0 - 10 - 172.16.3.4/24 - static
2046+ region = factory.make_Node(
2047+ node_type=NODE_TYPE.REGION_CONTROLLER,
2048+ hostname="happy-region", interface=False)
2049+ eth0 = factory.make_Interface(
2050+ INTERFACE_TYPE.PHYSICAL, name="eth0",
2051+ node=region, vlan=fabric0_untagged)
2052+ eth1 = factory.make_Interface(
2053+ INTERFACE_TYPE.PHYSICAL, name="eth1",
2054+ node=region, vlan=fabric0_untagged)
2055+ eth2 = factory.make_Interface(
2056+ INTERFACE_TYPE.PHYSICAL, name="eth2",
2057+ node=region, vlan=fabric1_untagged)
2058+ bond0 = factory.make_Interface(
2059+ INTERFACE_TYPE.BOND, name="bond0",
2060+ node=region, vlan=fabric0_untagged, parents=[eth0, eth1])
2061+ bond0_10 = factory.make_Interface(
2062+ INTERFACE_TYPE.VLAN, node=region,
2063+ vlan=fabric0_vlan10, parents=[bond0])
2064+ factory.make_StaticIPAddress(
2065+ alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.1.4",
2066+ subnet=subnet_1, interface=bond0)
2067+ factory.make_StaticIPAddress(
2068+ alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.2.4",
2069+ subnet=subnet_2, interface=eth2)
2070+ factory.make_StaticIPAddress(
2071+ alloc_type=IPADDRESS_TYPE.STICKY, ip="172.16.3.4",
2072+ subnet=subnet_3, interface=bond0_10)
2073+
2074 # Create one machine for every status. Each machine has a random interface
2075 # and storage configration.
2076 node_statuses = [
2077
2078=== modified file 'src/maasserver/tests/test_node_action.py'
2079--- src/maasserver/tests/test_node_action.py 2016-03-28 13:54:47 +0000
2080+++ src/maasserver/tests/test_node_action.py 2016-04-29 23:54:15 +0000
2081@@ -74,7 +74,7 @@
2082 display = "Action label"
2083 actionable_statuses = ALL_STATUSES
2084 permission = NODE_PERMISSION.VIEW
2085- node_only = False
2086+ for_type = [NODE_TYPE.MACHINE]
2087
2088 # For testing: an inhibition for inhibit() to return.
2089 fake_inhibition = None
2090@@ -243,6 +243,14 @@
2091 node = factory.make_Node(status=NODE_STATUS.BROKEN)
2092 self.assertFalse(MyAction(node, factory.make_User()).is_actionable())
2093
2094+ def test_is_actionable_checks_permission(self):
2095+
2096+ class MyAction(FakeNodeAction):
2097+ node_permission = NODE_PERMISSION.ADMIN
2098+
2099+ node = factory.make_Node()
2100+ self.assertFalse(MyAction(node, factory.make_User()).is_actionable())
2101+
2102
2103 class TestDeleteAction(MAASServerTestCase):
2104
2105
2106=== modified file 'src/maasserver/websockets/handlers/general.py'
2107--- src/maasserver/websockets/handlers/general.py 2016-03-28 13:54:47 +0000
2108+++ src/maasserver/websockets/handlers/general.py 2016-04-29 23:54:15 +0000
2109@@ -15,6 +15,7 @@
2110 BOND_MODE_CHOICES,
2111 BOND_XMIT_HASH_POLICY_CHOICES,
2112 NODE_PERMISSION,
2113+ NODE_TYPE,
2114 )
2115 from maasserver.models.bootresource import BootResource
2116 from maasserver.models.config import Config
2117@@ -42,9 +43,11 @@
2118 'hwe_kernels',
2119 'default_min_hwe_kernel',
2120 'osinfo',
2121- 'node_actions',
2122+ 'machine_actions',
2123 'device_actions',
2124- 'controller_actions',
2125+ 'rack_controller_actions',
2126+ 'region_controller_actions',
2127+ 'region_and_rack_controller_actions',
2128 'random_hostname',
2129 'bond_options',
2130 'version',
2131@@ -89,41 +92,42 @@
2132 for name, action in actions.items()
2133 ]
2134
2135- def node_actions(self, params):
2136- """Return all possible node actions."""
2137- if self.user.is_superuser:
2138- actions = ACTIONS_DICT
2139- else:
2140- # Standard users will not be able to use any admin actions. Hide
2141- # them as they will never be actionable on any node.
2142- actions = dict()
2143- for name, action in ACTIONS_DICT.items():
2144- permission = action.permission
2145- if action.node_permission is not None:
2146- permission = action.node_permission
2147- if permission != NODE_PERMISSION.ADMIN:
2148- actions[name] = action
2149+ def _node_actions(self, params, node_type):
2150+ # Only admins can perform controller actions
2151+ if (not self.user.is_superuser and node_type in [
2152+ NODE_TYPE.RACK_CONTROLLER, NODE_TYPE.REGION_CONTROLLER,
2153+ NODE_TYPE.REGION_AND_RACK_CONTROLLER]):
2154+ return {}
2155+
2156+ actions = dict()
2157+ for name, action in ACTIONS_DICT.items():
2158+ if (action.node_permission == NODE_PERMISSION.ADMIN and
2159+ not self.user.is_superuser):
2160+ continue
2161+ elif node_type in action.for_type:
2162+ actions[name] = action
2163 return self.dehydrate_actions(actions)
2164
2165+ def machine_actions(self, params):
2166+ """Return all possible machine actions."""
2167+ return self._node_actions(params, NODE_TYPE.MACHINE)
2168+
2169 def device_actions(self, params):
2170 """Return all possible device actions."""
2171- # Remove the actions that can only be performed on nodes.
2172- actions = {
2173- name: action
2174- for name, action in ACTIONS_DICT.items()
2175- if not action.node_only
2176- }
2177- return self.dehydrate_actions(actions)
2178-
2179- def controller_actions(self, params):
2180- """Return all possible device actions."""
2181- # Remove the actions that can only be performed on nodes.
2182- actions = {
2183- name: action
2184- for name, action in ACTIONS_DICT.items()
2185- if not action.node_only
2186- }
2187- return self.dehydrate_actions(actions)
2188+ return self._node_actions(params, NODE_TYPE.DEVICE)
2189+
2190+ def region_controller_actions(self, params):
2191+ """Return all possible region controller actions."""
2192+ return self._node_actions(params, NODE_TYPE.REGION_CONTROLLER)
2193+
2194+ def rack_controller_actions(self, params):
2195+ """Return all possible rack controller actions."""
2196+ return self._node_actions(params, NODE_TYPE.RACK_CONTROLLER)
2197+
2198+ def region_and_rack_controller_actions(self, params):
2199+ """Return all possible region and rack controller actions."""
2200+ return self._node_actions(
2201+ params, NODE_TYPE.REGION_AND_RACK_CONTROLLER)
2202
2203 def random_hostname(self, params):
2204 """Return a random hostname."""
2205
2206=== modified file 'src/maasserver/websockets/handlers/tests/test_device.py'
2207--- src/maasserver/websockets/handlers/tests/test_device.py 2016-03-28 13:54:47 +0000
2208+++ src/maasserver/websockets/handlers/tests/test_device.py 2016-04-29 23:54:15 +0000
2209@@ -460,14 +460,14 @@
2210 {"system_id": device.system_id, "action": "unknown"})
2211
2212 def test_action_performs_action(self):
2213- user = factory.make_User()
2214+ user = factory.make_admin()
2215 device = factory.make_Node(owner=user, node_type=NODE_TYPE.DEVICE)
2216 handler = DeviceHandler(user, {})
2217 handler.action({"system_id": device.system_id, "action": "delete"})
2218 self.assertIsNone(reload_object(device))
2219
2220 def test_action_performs_action_passing_extra(self):
2221- user = factory.make_User()
2222+ user = factory.make_admin()
2223 device = self.make_device_with_ip_address(owner=user)
2224 zone = factory.make_Zone()
2225 handler = DeviceHandler(user, {})
2226
2227=== modified file 'src/maasserver/websockets/handlers/tests/test_general.py'
2228--- src/maasserver/websockets/handlers/tests/test_general.py 2016-03-28 13:54:47 +0000
2229+++ src/maasserver/websockets/handlers/tests/test_general.py 2016-04-29 23:54:15 +0000
2230@@ -11,7 +11,6 @@
2231 BOND_MODE_CHOICES,
2232 BOND_XMIT_HASH_POLICY_CHOICES,
2233 BOOT_RESOURCE_TYPE,
2234- NODE_PERMISSION,
2235 )
2236 from maasserver.models import BootSourceCache
2237 from maasserver.models.config import Config
2238@@ -105,40 +104,60 @@
2239 }
2240 self.assertItemsEqual(expected_osinfo, handler.osinfo({}))
2241
2242- def test_node_actions_for_admin(self):
2243+ def test_machine_actions_for_admin(self):
2244 handler = GeneralHandler(factory.make_admin(), {})
2245 actions_expected = self.dehydrate_actions(ACTIONS_DICT)
2246- self.assertItemsEqual(actions_expected, handler.node_actions({}))
2247+ self.assertItemsEqual(actions_expected, handler.machine_actions({}))
2248
2249- def test_node_actions_for_non_admin(self):
2250+ def test_machine_actions_for_non_admin(self):
2251 handler = GeneralHandler(factory.make_User(), {})
2252- actions_expected = dict()
2253- for name, action in ACTIONS_DICT.items():
2254- permission = action.permission
2255- if action.node_permission is not None:
2256- permission = action.node_permission
2257- if permission != NODE_PERMISSION.ADMIN:
2258- actions_expected[name] = action
2259- actions_expected = self.dehydrate_actions(actions_expected)
2260- self.assertItemsEqual(actions_expected, handler.node_actions({}))
2261+ self.assertItemsEqual(
2262+ ['release', 'mark-broken', 'on', 'deploy', 'mark-fixed',
2263+ 'commission', 'abort', 'acquire', 'off'],
2264+ [action['name'] for action in handler.machine_actions({})])
2265+
2266+ def test_device_actions_for_admin(self):
2267+ handler = GeneralHandler(factory.make_admin(), {})
2268+ self.assertItemsEqual(
2269+ ['set-zone', 'delete'],
2270+ [action['name'] for action in handler.device_actions({})])
2271
2272 def test_device_actions_for_non_admin(self):
2273 handler = GeneralHandler(factory.make_User(), {})
2274- actions_expected = self.dehydrate_actions({
2275- name: action
2276- for name, action in ACTIONS_DICT.items()
2277- if not action.node_only
2278- })
2279- self.assertItemsEqual(actions_expected, handler.device_actions({}))
2280-
2281- def test_controller_actions_for_non_admin(self):
2282- handler = GeneralHandler(factory.make_User(), {})
2283- actions_expected = self.dehydrate_actions({
2284- name: action
2285- for name, action in ACTIONS_DICT.items()
2286- if not action.node_only
2287- })
2288- self.assertItemsEqual(actions_expected, handler.controller_actions({}))
2289+ self.assertItemsEqual([], handler.device_actions({}))
2290+
2291+ def test_region_controller_actions_for_admin(self):
2292+ handler = GeneralHandler(factory.make_admin(), {})
2293+ self.assertItemsEqual(
2294+ ['set-zone', 'delete'],
2295+ [action['name']
2296+ for action in handler.region_controller_actions({})])
2297+
2298+ def test_region_controller_actions_for_non_admin(self):
2299+ handler = GeneralHandler(factory.make_User(), {})
2300+ self.assertItemsEqual([], handler.region_controller_actions({}))
2301+
2302+ def test_rack_controller_actions_for_admin(self):
2303+ handler = GeneralHandler(factory.make_admin(), {})
2304+ self.assertItemsEqual(
2305+ ['delete', 'mark-broken', 'mark-fixed', 'off', 'on', 'set-zone'],
2306+ [action['name'] for action in handler.rack_controller_actions({})])
2307+
2308+ def test_rack_controller_actions_for_non_admin(self):
2309+ handler = GeneralHandler(factory.make_User(), {})
2310+ self.assertItemsEqual([], handler.rack_controller_actions({}))
2311+
2312+ def test_region_and_rack_controller_actions_for_admin(self):
2313+ handler = GeneralHandler(factory.make_admin(), {})
2314+ self.assertItemsEqual(
2315+ ['set-zone', 'delete'],
2316+ [action['name']
2317+ for action in handler.region_and_rack_controller_actions({})])
2318+
2319+ def test_region_and_rack_controller_actions_for_non_admin(self):
2320+ handler = GeneralHandler(factory.make_User(), {})
2321+ self.assertItemsEqual(
2322+ [], handler.region_and_rack_controller_actions({}))
2323
2324 def test_random_hostname_checks_hostname_existence(self):
2325 existing_node = factory.make_Node(hostname="hostname")
2326
2327=== modified file 'src/maasserver/websockets/handlers/tests/test_machine.py'
2328--- src/maasserver/websockets/handlers/tests/test_machine.py 2016-03-31 23:34:55 +0000
2329+++ src/maasserver/websockets/handlers/tests/test_machine.py 2016-04-29 23:54:15 +0000
2330@@ -45,7 +45,10 @@
2331 VolumeGroup,
2332 )
2333 from maasserver.models.interface import Interface
2334-from maasserver.models.node import Node
2335+from maasserver.models.node import (
2336+ Machine,
2337+ Node,
2338+)
2339 from maasserver.models.nodeprobeddetails import get_single_probed_details
2340 from maasserver.models.partition import (
2341 Partition,
2342@@ -1909,9 +1912,9 @@
2343 def test_action_performs_action_passing_extra(self):
2344 user = factory.make_User()
2345 factory.make_SSHKey(user)
2346- self.patch(Node, 'on_network').return_value = True
2347+ self.patch(Machine, 'on_network').return_value = True
2348 node = factory.make_Node(status=NODE_STATUS.ALLOCATED, owner=user)
2349- self.patch(Node, "_start").return_value = None
2350+ self.patch(Machine, "_start").return_value = None
2351 osystem = make_usable_osystem(self)
2352 handler = MachineHandler(user, {})
2353 handler.action({