Merge lp:~allenap/maas/power-poll-fewer--bug-1389007--1.8 into lp:maas/1.8

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 4022
Proposed branch: lp:~allenap/maas/power-poll-fewer--bug-1389007--1.8
Merge into: lp:maas/1.8
Diff against target: 1502 lines (+915/-169)
16 files modified
HACKING.txt (+9/-3)
src/maasserver/migrations/0139_power_parameters_and_state_updated_field.py (+453/-0)
src/maasserver/models/node.py (+13/-3)
src/maasserver/models/tests/test_node.py (+8/-0)
src/maasserver/models/timestampedmodel.py (+1/-0)
src/maasserver/rpc/nodes.py (+88/-22)
src/maasserver/rpc/tests/test_nodes.py (+133/-22)
src/maasserver/rpc/tests/test_regionservice.py (+9/-2)
src/maasserver/testing/factory.py (+23/-9)
src/maasserver/websockets/handlers/device.py (+1/-0)
src/maasserver/websockets/handlers/node.py (+3/-0)
src/provisioningserver/pserv_services/node_power_monitor_service.py (+31/-39)
src/provisioningserver/pserv_services/tests/test_node_power_monitor_service.py (+35/-47)
src/provisioningserver/rpc/power.py (+23/-17)
src/provisioningserver/rpc/region.py (+10/-5)
src/provisioningserver/rpc/tests/test_power.py (+75/-0)
To merge this branch: bzr merge lp:~allenap/maas/power-poll-fewer--bug-1389007--1.8
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+264832@code.launchpad.net

Commit message

Backport r4076 from lp:maas: Query node power states in smaller batches.

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

Selfie (backport).

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'HACKING.txt'
2--- HACKING.txt 2015-05-20 23:53:24 +0000
3+++ HACKING.txt 2015-07-15 12:15:32 +0000
4@@ -449,13 +449,19 @@
5
6 .. _schemamigration: http://south.aeracode.org/docs/commands.html#schemamigration
7
8-Once you've changed the code, run::
9+Once you've changed the code, ensure the database is running and
10+contains the starting schema::
11+
12+ $ make services/database/@start
13+ $ make syncdb
14+
15+then generate the migration script with::
16
17 $ ./bin/maas-region-admin schemamigration maasserver --auto description_of_the_change
18
19 This will generate a migration module named
20-``src/maasserver/migrations/<auto_number>_description_of_the_change.py``. Don't
21-forget to add that file to the project with::
22+``src/maasserver/migrations/<auto_number>_description_of_the_change.py``.
23+Don't forget to add that file to the project with::
24
25 $ bzr add src/maasserver/migrations/<auto_number>_description_of_the_change.py
26
27
28=== added file 'src/maasserver/migrations/0139_power_parameters_and_state_updated_field.py'
29--- src/maasserver/migrations/0139_power_parameters_and_state_updated_field.py 1970-01-01 00:00:00 +0000
30+++ src/maasserver/migrations/0139_power_parameters_and_state_updated_field.py 2015-07-15 12:15:32 +0000
31@@ -0,0 +1,453 @@
32+# -*- coding: utf-8 -*-
33+from django.db import models
34+from south.db import db
35+from south.utils import datetime_utils as datetime
36+from south.v2 import SchemaMigration
37+
38+
39+class Migration(SchemaMigration):
40+
41+ def forwards(self, orm):
42+ # Adding field 'Node.power_state_updated'
43+ db.add_column(u'maasserver_node', 'power_state_updated',
44+ self.gf('django.db.models.fields.DateTimeField')(default=None, null=True),
45+ keep_default=False)
46+
47+
48+ # Changing field 'Node.power_parameters'
49+ db.alter_column(u'maasserver_node', 'power_parameters', self.gf('maasserver.fields.JSONObjectField')(max_length=32768))
50+
51+ def backwards(self, orm):
52+ # Deleting field 'Node.power_state_updated'
53+ db.delete_column(u'maasserver_node', 'power_state_updated')
54+
55+
56+ # Changing field 'Node.power_parameters'
57+ db.alter_column(u'maasserver_node', 'power_parameters', self.gf('maasserver.fields.JSONObjectField')())
58+
59+ models = {
60+ u'auth.group': {
61+ 'Meta': {'object_name': 'Group'},
62+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
63+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
64+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
65+ },
66+ u'auth.permission': {
67+ 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
68+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
69+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
70+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
71+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
72+ },
73+ u'auth.user': {
74+ 'Meta': {'object_name': 'User'},
75+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
76+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
77+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
78+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
79+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
80+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
81+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
82+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
83+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
84+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
85+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
86+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
87+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
88+ },
89+ u'contenttypes.contenttype': {
90+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
91+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
92+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
93+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
94+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
95+ },
96+ u'maasserver.blockdevice': {
97+ 'Meta': {'ordering': "[u'id']", 'unique_together': "((u'node', u'path'),)", 'object_name': 'BlockDevice'},
98+ 'block_size': ('django.db.models.fields.IntegerField', [], {}),
99+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
100+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
101+ 'id_path': ('django.db.models.fields.FilePathField', [], {'max_length': '100', 'null': 'True', 'blank': 'True'}),
102+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
103+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
104+ 'path': ('django.db.models.fields.FilePathField', [], {'max_length': '100'}),
105+ 'size': ('django.db.models.fields.BigIntegerField', [], {}),
106+ 'tags': ('djorm_pgarray.fields.ArrayField', [], {'default': '[]', 'dbtype': "u'text'", 'blank': 'True'}),
107+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
108+ },
109+ u'maasserver.bootresource': {
110+ 'Meta': {'unique_together': "((u'name', u'architecture'),)", 'object_name': 'BootResource'},
111+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
112+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
113+ 'extra': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
114+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
115+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
116+ 'rtype': ('django.db.models.fields.IntegerField', [], {'max_length': '10'}),
117+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
118+ },
119+ u'maasserver.bootresourcefile': {
120+ 'Meta': {'unique_together': "((u'resource_set', u'filetype'),)", 'object_name': 'BootResourceFile'},
121+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
122+ 'extra': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
123+ 'filename': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
124+ 'filetype': ('django.db.models.fields.CharField', [], {'default': "u'root-tgz'", 'max_length': '20'}),
125+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
126+ 'largefile': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.LargeFile']"}),
127+ 'resource_set': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'files'", 'to': u"orm['maasserver.BootResourceSet']"}),
128+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
129+ },
130+ u'maasserver.bootresourceset': {
131+ 'Meta': {'unique_together': "((u'resource', u'version'),)", 'object_name': 'BootResourceSet'},
132+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
133+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
134+ 'label': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
135+ 'resource': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'sets'", 'to': u"orm['maasserver.BootResource']"}),
136+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
137+ 'version': ('django.db.models.fields.CharField', [], {'max_length': '255'})
138+ },
139+ u'maasserver.bootsource': {
140+ 'Meta': {'object_name': 'BootSource'},
141+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
142+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
143+ 'keyring_data': ('maasserver.fields.EditableBinaryField', [], {'blank': 'True'}),
144+ 'keyring_filename': ('django.db.models.fields.FilePathField', [], {'max_length': '100', 'blank': 'True'}),
145+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
146+ 'url': ('django.db.models.fields.URLField', [], {'unique': 'True', 'max_length': '200'})
147+ },
148+ u'maasserver.bootsourcecache': {
149+ 'Meta': {'object_name': 'BootSourceCache'},
150+ 'arch': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
151+ 'boot_source': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.BootSource']"}),
152+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
153+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
154+ 'label': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
155+ 'os': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
156+ 'release': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
157+ 'subarch': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
158+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
159+ },
160+ u'maasserver.bootsourceselection': {
161+ 'Meta': {'unique_together': "((u'boot_source', u'os', u'release'),)", 'object_name': 'BootSourceSelection'},
162+ 'arches': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'text'", 'null': 'True', 'blank': 'True'}),
163+ 'boot_source': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.BootSource']"}),
164+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
165+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
166+ 'labels': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'text'", 'null': 'True', 'blank': 'True'}),
167+ 'os': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '20', 'blank': 'True'}),
168+ 'release': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '20', 'blank': 'True'}),
169+ 'subarches': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'text'", 'null': 'True', 'blank': 'True'}),
170+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
171+ },
172+ u'maasserver.candidatename': {
173+ 'Meta': {'unique_together': "((u'name', u'position'),)", 'object_name': 'CandidateName'},
174+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
175+ 'name': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
176+ 'position': ('django.db.models.fields.IntegerField', [], {})
177+ },
178+ u'maasserver.componenterror': {
179+ 'Meta': {'object_name': 'ComponentError'},
180+ 'component': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}),
181+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
182+ 'error': ('django.db.models.fields.CharField', [], {'max_length': '1000'}),
183+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
184+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
185+ },
186+ u'maasserver.config': {
187+ 'Meta': {'object_name': 'Config'},
188+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
189+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
190+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
191+ },
192+ u'maasserver.dhcplease': {
193+ 'Meta': {'object_name': 'DHCPLease'},
194+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
195+ 'ip': ('maasserver.fields.MAASIPAddressField', [], {'unique': 'True', 'max_length': '39'}),
196+ 'mac': ('maasserver.fields.MACAddressField', [], {}),
197+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
198+ },
199+ u'maasserver.downloadprogress': {
200+ 'Meta': {'object_name': 'DownloadProgress'},
201+ 'bytes_downloaded': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
202+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
203+ 'error': ('django.db.models.fields.CharField', [], {'max_length': '1000', 'blank': 'True'}),
204+ 'filename': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
205+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
206+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
207+ 'size': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
208+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
209+ },
210+ u'maasserver.event': {
211+ 'Meta': {'object_name': 'Event'},
212+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
213+ 'description': ('django.db.models.fields.TextField', [], {'default': "u''", 'blank': 'True'}),
214+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
215+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
216+ 'type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.EventType']"}),
217+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
218+ },
219+ u'maasserver.eventtype': {
220+ 'Meta': {'object_name': 'EventType'},
221+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
222+ 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
223+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
224+ 'level': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
225+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
226+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
227+ },
228+ u'maasserver.filestorage': {
229+ 'Meta': {'unique_together': "((u'filename', u'owner'),)", 'object_name': 'FileStorage'},
230+ 'content': ('metadataserver.fields.BinaryField', [], {'blank': 'True'}),
231+ 'filename': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
232+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
233+ 'key': ('django.db.models.fields.CharField', [], {'default': "u'c4af88bc-2ae9-11e5-9139-00163edfc3e6'", 'unique': 'True', 'max_length': '36'}),
234+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'})
235+ },
236+ u'maasserver.filesystem': {
237+ 'Meta': {'object_name': 'Filesystem'},
238+ 'block_device': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.BlockDevice']", 'null': 'True', 'blank': 'True'}),
239+ 'create_params': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
240+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
241+ 'filesystem_group': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'filesystems'", 'null': 'True', 'to': u"orm['maasserver.FilesystemGroup']"}),
242+ 'fstype': ('django.db.models.fields.CharField', [], {'default': "u'ext4'", 'max_length': '20'}),
243+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
244+ 'mount_params': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
245+ 'mount_point': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
246+ 'partition': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Partition']", 'null': 'True', 'blank': 'True'}),
247+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
248+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
249+ },
250+ u'maasserver.filesystemgroup': {
251+ 'Meta': {'object_name': 'FilesystemGroup'},
252+ 'create_params': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
253+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
254+ 'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
255+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
256+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
257+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
258+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
259+ },
260+ u'maasserver.largefile': {
261+ 'Meta': {'object_name': 'LargeFile'},
262+ 'content': ('maasserver.fields.LargeObjectField', [], {}),
263+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
264+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
265+ 'sha256': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'}),
266+ 'total_size': ('django.db.models.fields.BigIntegerField', [], {}),
267+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
268+ },
269+ u'maasserver.licensekey': {
270+ 'Meta': {'unique_together': "((u'osystem', u'distro_series'),)", 'object_name': 'LicenseKey'},
271+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
272+ 'distro_series': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
273+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
274+ 'license_key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
275+ 'osystem': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
276+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
277+ },
278+ u'maasserver.macaddress': {
279+ 'Meta': {'ordering': "(u'created',)", 'object_name': 'MACAddress'},
280+ 'cluster_interface': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['maasserver.NodeGroupInterface']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
281+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
282+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
283+ 'ip_addresses': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.StaticIPAddress']", 'symmetrical': 'False', 'through': u"orm['maasserver.MACStaticIPAddressLink']", 'blank': 'True'}),
284+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
285+ 'networks': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Network']", 'symmetrical': 'False', 'blank': 'True'}),
286+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']", 'null': 'True', 'blank': 'True'}),
287+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
288+ },
289+ u'maasserver.macstaticipaddresslink': {
290+ 'Meta': {'unique_together': "((u'ip_address', u'mac_address'),)", 'object_name': 'MACStaticIPAddressLink'},
291+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
292+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
293+ 'ip_address': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.StaticIPAddress']", 'unique': 'True'}),
294+ 'mac_address': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.MACAddress']"}),
295+ 'nic_alias': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
296+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
297+ },
298+ u'maasserver.network': {
299+ 'Meta': {'object_name': 'Network'},
300+ 'default_gateway': ('maasserver.fields.MAASIPAddressField', [], {'max_length': '39', 'null': 'True', 'blank': 'True'}),
301+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
302+ 'dns_servers': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
303+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
304+ 'ip': ('maasserver.fields.MAASIPAddressField', [], {'unique': 'True', 'max_length': '39'}),
305+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
306+ 'netmask': ('maasserver.fields.MAASIPAddressField', [], {'max_length': '39'}),
307+ 'vlan_tag': ('django.db.models.fields.PositiveSmallIntegerField', [], {'unique': 'True', 'null': 'True', 'blank': 'True'})
308+ },
309+ u'maasserver.node': {
310+ 'Meta': {'object_name': 'Node'},
311+ 'agent_name': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'null': 'True', 'blank': 'True'}),
312+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '31', 'null': 'True', 'blank': 'True'}),
313+ 'boot_type': ('django.db.models.fields.CharField', [], {'default': "u'fastpath'", 'max_length': '20'}),
314+ 'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
315+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
316+ 'disable_ipv4': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
317+ 'distro_series': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '20', 'blank': 'True'}),
318+ 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
319+ 'error_description': ('django.db.models.fields.TextField', [], {'default': "u''", 'blank': 'True'}),
320+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'unique': 'True', 'max_length': '255', 'blank': 'True'}),
321+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
322+ 'installable': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
323+ 'license_key': ('django.db.models.fields.CharField', [], {'max_length': '30', 'null': 'True', 'blank': 'True'}),
324+ 'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
325+ 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
326+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
327+ 'osystem': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '20', 'blank': 'True'}),
328+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}),
329+ 'parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "u'children'", 'null': 'True', 'blank': 'True', 'to': u"orm['maasserver.Node']"}),
330+ 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'max_length': '32768', 'blank': 'True'}),
331+ 'power_state': ('django.db.models.fields.CharField', [], {'default': "u'unknown'", 'max_length': '10'}),
332+ 'power_state_updated': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}),
333+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
334+ 'pxe_mac': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': u"orm['maasserver.MACAddress']", 'blank': 'True', 'null': 'True'}),
335+ 'routers': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'macaddr'", 'null': 'True', 'blank': 'True'}),
336+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
337+ 'swap_size': ('django.db.models.fields.BigIntegerField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
338+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-c4b17d70-2ae9-11e5-9139-00163edfc3e6'", 'unique': 'True', 'max_length': '41'}),
339+ 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
340+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['piston.Token']", 'null': 'True'}),
341+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
342+ 'zone': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Zone']", 'on_delete': 'models.SET_DEFAULT'})
343+ },
344+ u'maasserver.nodegroup': {
345+ 'Meta': {'object_name': 'NodeGroup'},
346+ 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
347+ 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['piston.Token']", 'unique': 'True'}),
348+ 'cluster_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100', 'blank': 'True'}),
349+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
350+ 'default_disable_ipv4': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
351+ 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
352+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
353+ 'maas_url': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
354+ 'name': ('maasserver.models.nodegroup.DomainNameField', [], {'max_length': '80', 'blank': 'True'}),
355+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
356+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
357+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
358+ },
359+ u'maasserver.nodegroupinterface': {
360+ 'Meta': {'unique_together': "((u'nodegroup', u'name'),)", 'object_name': 'NodeGroupInterface'},
361+ 'broadcast_ip': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
362+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
363+ 'foreign_dhcp_ip': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
364+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
365+ 'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
366+ 'ip': ('maasserver.fields.MAASIPAddressField', [], {'max_length': '39'}),
367+ 'ip_range_high': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
368+ 'ip_range_low': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
369+ 'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
370+ 'name': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
371+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
372+ 'router_ip': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
373+ 'static_ip_range_high': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
374+ 'static_ip_range_low': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
375+ 'subnet_mask': ('maasserver.fields.MAASIPAddressField', [], {'default': 'None', 'max_length': '39', 'null': 'True', 'blank': 'True'}),
376+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
377+ },
378+ u'maasserver.partition': {
379+ 'Meta': {'object_name': 'Partition'},
380+ 'bootable': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
381+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
382+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
383+ 'partition_table': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'partitions'", 'to': u"orm['maasserver.PartitionTable']"}),
384+ 'size': ('django.db.models.fields.BigIntegerField', [], {}),
385+ 'start_offset': ('django.db.models.fields.BigIntegerField', [], {}),
386+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
387+ 'uuid': ('django.db.models.fields.CharField', [], {'max_length': '36', 'unique': 'True', 'null': 'True', 'blank': 'True'})
388+ },
389+ u'maasserver.partitiontable': {
390+ 'Meta': {'object_name': 'PartitionTable'},
391+ 'block_device': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.BlockDevice']"}),
392+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
393+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
394+ 'table_type': ('django.db.models.fields.CharField', [], {'default': "u'GPT'", 'max_length': '20'}),
395+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
396+ },
397+ u'maasserver.physicalblockdevice': {
398+ 'Meta': {'ordering': "[u'id']", 'object_name': 'PhysicalBlockDevice', '_ormbases': [u'maasserver.BlockDevice']},
399+ u'blockdevice_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': u"orm['maasserver.BlockDevice']", 'unique': 'True', 'primary_key': 'True'}),
400+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
401+ 'serial': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'})
402+ },
403+ u'maasserver.sshkey': {
404+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
405+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
406+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
407+ 'key': ('django.db.models.fields.TextField', [], {}),
408+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
409+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"})
410+ },
411+ u'maasserver.sslkey': {
412+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSLKey'},
413+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
414+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
415+ 'key': ('django.db.models.fields.TextField', [], {}),
416+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
417+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']"})
418+ },
419+ u'maasserver.staticipaddress': {
420+ 'Meta': {'object_name': 'StaticIPAddress'},
421+ 'alloc_type': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
422+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
423+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'null': 'True', 'blank': 'True'}),
424+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
425+ 'ip': ('maasserver.fields.MAASIPAddressField', [], {'unique': 'True', 'max_length': '39'}),
426+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
427+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'})
428+ },
429+ u'maasserver.tag': {
430+ 'Meta': {'object_name': 'Tag'},
431+ 'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
432+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
433+ 'definition': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
434+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
435+ 'kernel_opts': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
436+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
437+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
438+ },
439+ u'maasserver.userprofile': {
440+ 'Meta': {'object_name': 'UserProfile'},
441+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
442+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': u"orm['auth.User']", 'unique': 'True'})
443+ },
444+ u'maasserver.virtualblockdevice': {
445+ 'Meta': {'ordering': "[u'id']", 'object_name': 'VirtualBlockDevice', '_ormbases': [u'maasserver.BlockDevice']},
446+ u'blockdevice_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': u"orm['maasserver.BlockDevice']", 'unique': 'True', 'primary_key': 'True'}),
447+ 'filesystem_group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'virtual_devices'", 'to': u"orm['maasserver.FilesystemGroup']"}),
448+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
449+ },
450+ u'maasserver.zone': {
451+ 'Meta': {'ordering': "[u'name']", 'object_name': 'Zone'},
452+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
453+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
454+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
455+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
456+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
457+ },
458+ u'piston.consumer': {
459+ 'Meta': {'object_name': 'Consumer'},
460+ 'description': ('django.db.models.fields.TextField', [], {}),
461+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
462+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
463+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
464+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
465+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
466+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': u"orm['auth.User']"})
467+ },
468+ u'piston.token': {
469+ 'Meta': {'object_name': 'Token'},
470+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
471+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
472+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['piston.Consumer']"}),
473+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
474+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
475+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
476+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
477+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1436961921L'}),
478+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
479+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': u"orm['auth.User']"}),
480+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
481+ }
482+ }
483+
484+ complete_apps = ['maasserver']
485
486=== modified file 'src/maasserver/models/node.py'
487--- src/maasserver/models/node.py 2015-05-22 15:03:29 +0000
488+++ src/maasserver/models/node.py 2015-07-15 12:15:32 +0000
489@@ -38,6 +38,7 @@
490 BooleanField,
491 CASCADE,
492 CharField,
493+ DateTimeField,
494 ForeignKey,
495 IntegerField,
496 Manager,
497@@ -91,7 +92,10 @@
498 from maasserver.models.physicalblockdevice import PhysicalBlockDevice
499 from maasserver.models.staticipaddress import StaticIPAddress
500 from maasserver.models.tag import Tag
501-from maasserver.models.timestampedmodel import TimestampedModel
502+from maasserver.models.timestampedmodel import (
503+ now,
504+ TimestampedModel,
505+)
506 from maasserver.models.zone import Zone
507 from maasserver.node_status import (
508 COMMISSIONING_LIKE_STATUSES,
509@@ -457,14 +461,19 @@
510 power_type = CharField(
511 max_length=10, null=False, blank=True, default='')
512
513- # JSON-encoded set of parameters for power control.
514- power_parameters = JSONObjectField(blank=True, default="")
515+ # JSON-encoded set of parameters for power control, limited to 32kiB when
516+ # encoded as JSON.
517+ power_parameters = JSONObjectField(
518+ max_length=(2 ** 15), blank=True, default="")
519
520 power_state = CharField(
521 max_length=10, null=False, blank=False,
522 choices=POWER_STATE_CHOICES, default=POWER_STATE.UNKNOWN,
523 editable=False)
524
525+ power_state_updated = DateTimeField(
526+ null=True, blank=False, default=None, editable=False)
527+
528 token = ForeignKey(
529 Token, db_index=True, null=True, editable=False, unique=False)
530
531@@ -1728,6 +1737,7 @@
532 def update_power_state(self, power_state):
533 """Update a node's power state """
534 self.power_state = power_state
535+ self.power_state_updated = now()
536 mark_ready = (
537 self.status == NODE_STATUS.RELEASING and
538 power_state == POWER_STATE.OFF)
539
540=== modified file 'src/maasserver/models/tests/test_node.py'
541--- src/maasserver/models/tests/test_node.py 2015-05-26 07:38:51 +0000
542+++ src/maasserver/models/tests/test_node.py 2015-07-15 12:15:32 +0000
543@@ -63,6 +63,7 @@
544 )
545 from maasserver.models.node import PowerInfo
546 from maasserver.models.staticipaddress import StaticIPAddress
547+from maasserver.models.timestampedmodel import now
548 from maasserver.models.user import create_auth_token
549 from maasserver.node_status import (
550 get_failed_status,
551@@ -1837,6 +1838,13 @@
552 node.update_power_state(state)
553 self.assertEqual(state, reload_object(node).power_state)
554
555+ def test_update_power_state_sets_last_updated_field(self):
556+ node = factory.make_Node(power_state_updated=None)
557+ self.assertIsNone(node.power_state_updated)
558+ state = factory.pick_enum(POWER_STATE)
559+ node.update_power_state(state)
560+ self.assertEqual(now(), reload_object(node).power_state_updated)
561+
562 def test_update_power_state_readies_node_if_releasing(self):
563 node = factory.make_Node(
564 power_state=POWER_STATE.ON, status=NODE_STATUS.RELEASING,
565
566=== modified file 'src/maasserver/models/timestampedmodel.py'
567--- src/maasserver/models/timestampedmodel.py 2015-05-07 18:14:38 +0000
568+++ src/maasserver/models/timestampedmodel.py 2015-07-15 12:15:32 +0000
569@@ -13,6 +13,7 @@
570
571 __metaclass__ = type
572 __all__ = [
573+ 'now',
574 'TimestampedModel',
575 ]
576
577
578=== modified file 'src/maasserver/rpc/nodes.py'
579--- src/maasserver/rpc/nodes.py 2015-05-07 18:14:38 +0000
580+++ src/maasserver/rpc/nodes.py 2015-07-15 12:15:32 +0000
581@@ -19,6 +19,9 @@
582 "create_node",
583 ]
584
585+from datetime import timedelta
586+from itertools import chain
587+
588 from django.contrib.auth.models import User
589 from django.core.exceptions import ValidationError
590 from maasserver import exceptions
591@@ -30,6 +33,7 @@
592 Node,
593 NodeGroup,
594 )
595+from maasserver.models.timestampedmodel import now
596 from maasserver.utils.orm import transactional
597 from provisioningserver.rpc.exceptions import (
598 CommissionNodeFailed,
599@@ -59,35 +63,97 @@
600 raise NodeStateViolation(e)
601
602
603-@synchronous
604-@transactional
605-def list_cluster_nodes_power_parameters(uuid):
606- """Query a cluster controller and return all of its nodes
607- power parameters
608-
609- for :py:class:`~provisioningserver.rpc.region.ListNodePowerParameters`.
610+def _gen_cluster_nodes_power_parameters(nodes):
611+ """Generate power parameters for `nodes`.
612+
613+ These fulfil a subset of the return schema for the RPC call for
614+ :py:class:`~provisioningserver.rpc.region.ListNodePowerParameters`.
615+
616+ :return: A generator yielding `dict`s.
617 """
618- try:
619- nodegroup = NodeGroup.objects.get_by_natural_key(uuid)
620- except NodeGroup.DoesNotExist:
621- raise NoSuchCluster.from_uuid(uuid)
622- else:
623- power_info_by_node = (
624- (node, node.get_effective_power_info())
625- for node in nodegroup.node_set.exclude(
626- status=NODE_STATUS.BROKEN).exclude(installable=False)
627- )
628- return [
629- {
630+ five_minutes_ago = now() - timedelta(minutes=5)
631+
632+ # This is meant to be temporary until all the power types support querying
633+ # the power state of a node. See the definition of QUERY_POWER_TYPES for
634+ # more information.
635+ from provisioningserver.rpc.power import QUERY_POWER_TYPES
636+
637+ nodes_unchecked = (
638+ nodes
639+ .filter(power_state_updated=None)
640+ .filter(power_type__in=QUERY_POWER_TYPES)
641+ .exclude(status=NODE_STATUS.BROKEN)
642+ .exclude(installable=False)
643+ )
644+ nodes_checked = (
645+ nodes
646+ .exclude(power_state_updated=None)
647+ .exclude(power_state_updated__gt=five_minutes_ago)
648+ .filter(power_type__in=QUERY_POWER_TYPES)
649+ .exclude(status=NODE_STATUS.BROKEN)
650+ .exclude(installable=False)
651+ .order_by("power_state_updated", "system_id")
652+ )
653+
654+ for node in chain(nodes_unchecked, nodes_checked):
655+ power_info = node.get_effective_power_info()
656+ if power_info.power_type is not None:
657+ yield {
658 'system_id': node.system_id,
659 'hostname': node.hostname,
660 'power_state': node.power_state,
661 'power_type': power_info.power_type,
662 'context': power_info.power_parameters,
663 }
664- for node, power_info in power_info_by_node
665- if power_info.power_type is not None
666- ]
667+
668+
669+def _gen_up_to_json_limit(things, limit):
670+ """Yield until the combined JSON dump of those things would exceed `limit`.
671+
672+ :param things: Any iterable whose elements can dumped as JSON.
673+ :return: A generator that yields items from `things` unmodified, and in
674+ order, though maybe not all of them.
675+ """
676+ # Deduct the space required for brackets. json.dumps(), by default, does
677+ # not add padding, so it's just the opening and closing brackets.
678+ limit -= 2
679+
680+ for index, thing in enumerate(things):
681+ # Adjust the limit according the the size of thing.
682+ if index == 0:
683+ # A sole element does not need a delimiter.
684+ limit -= len(json.dumps(thing))
685+ else:
686+ # There is a delimiter between this and the preceeding element.
687+ # json.dumps(), by default, uses ", ", i.e. 2 characters.
688+ limit -= len(json.dumps(thing)) + 2
689+
690+ # Check if we've reached the limit.
691+ if limit == 0:
692+ yield thing
693+ break
694+ elif limit > 0:
695+ yield thing
696+ else:
697+ break
698+
699+
700+@synchronous
701+@transactional
702+def list_cluster_nodes_power_parameters(uuid):
703+ """Return power parameters for a cluster's nodes, in priority order.
704+
705+ For :py:class:`~provisioningserver.rpc.region.ListNodePowerParameters`.
706+ """
707+ try:
708+ nodegroup = NodeGroup.objects.get_by_natural_key(uuid)
709+ except NodeGroup.DoesNotExist:
710+ raise NoSuchCluster.from_uuid(uuid)
711+ else:
712+ nodes = nodegroup.node_set.all()
713+ details = _gen_cluster_nodes_power_parameters(nodes)
714+ details = _gen_up_to_json_limit(details, 60 * (2 ** 10)) # 60kiB
715+ return list(details)
716
717
718 @synchronous
719
720=== modified file 'src/maasserver/rpc/tests/test_nodes.py'
721--- src/maasserver/rpc/tests/test_nodes.py 2015-05-22 15:52:13 +0000
722+++ src/maasserver/rpc/tests/test_nodes.py 2015-07-15 12:15:32 +0000
723@@ -14,10 +14,16 @@
724 __metaclass__ = type
725 __all__ = []
726
727+from datetime import timedelta
728+from itertools import imap
729+import json
730+from operator import attrgetter
731 import random
732+from random import randint
733
734 from django.core.exceptions import ValidationError
735 from maasserver.enum import NODE_STATUS
736+from maasserver.models.timestampedmodel import now
737 from maasserver.rpc.nodes import (
738 commission_node,
739 create_node,
740@@ -47,12 +53,15 @@
741 NodeStateViolation,
742 NoSuchNode,
743 )
744+from provisioningserver.rpc.power import QUERY_POWER_TYPES
745 from simplejson import dumps
746 from testtools import ExpectedException
747 from testtools.matchers import (
748 Contains,
749 Equals,
750+ GreaterThan,
751 Is,
752+ LessThan,
753 Not,
754 )
755
756@@ -321,25 +330,127 @@
757 # Those tests have been left there for now because they also check
758 # that the return values are being formatted correctly for RPC.
759
760- def test_does_not_return_power_info_for_broken_nodes(self):
761- cluster = factory.make_NodeGroup()
762- broken_node = factory.make_Node(
763- nodegroup=cluster, status=NODE_STATUS.BROKEN)
764-
765- power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
766- returned_system_ids = [
767- power_params['system_id'] for power_params in power_parameters]
768-
769- self.assertThat(
770- returned_system_ids, Not(Contains(broken_node.system_id)))
771-
772- def test_does_not_return_power_info_for_devices(self):
773- cluster = factory.make_NodeGroup()
774- device = factory.make_Device()
775-
776- power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
777- returned_system_ids = [
778- power_params['system_id'] for power_params in power_parameters]
779-
780- self.assertThat(
781- returned_system_ids, Not(Contains(device.system_id)))
782+ def make_Node(
783+ self, cluster, power_type=None, power_state_updated=None,
784+ **kwargs):
785+ if power_type is None:
786+ # Ensure that this node's power status can be queried.
787+ power_type = random.choice(QUERY_POWER_TYPES)
788+ if power_state_updated is None:
789+ # Ensure that this node was last queried at least 5 minutes ago.
790+ power_state_updated = now() - timedelta(minutes=randint(6, 16))
791+ return factory.make_Node(
792+ nodegroup=cluster, power_type=power_type,
793+ power_state_updated=power_state_updated, **kwargs)
794+
795+ def test__returns_unchecked_nodes_first(self):
796+ cluster = factory.make_NodeGroup()
797+ nodes = [self.make_Node(cluster) for _ in xrange(5)]
798+ node_unchecked = random.choice(nodes)
799+ node_unchecked.power_state_updated = None
800+ node_unchecked.save()
801+
802+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
803+ system_ids = [params["system_id"] for params in power_parameters]
804+
805+ # The unchecked node is always the first out.
806+ self.assertEqual(node_unchecked.system_id, system_ids[0])
807+
808+ def test__excludes_recently_checked_nodes(self):
809+ cluster = factory.make_NodeGroup()
810+
811+ node_unchecked = self.make_Node(cluster)
812+ node_unchecked.power_state_updated = None
813+ node_unchecked.save()
814+
815+ datetime_now = now()
816+ node_checked_recently = self.make_Node(cluster)
817+ node_checked_recently.power_state_updated = datetime_now
818+ node_checked_recently.save()
819+
820+ datetime_10_minutes_ago = datetime_now - timedelta(minutes=10)
821+ node_checked_long_ago = self.make_Node(cluster)
822+ node_checked_long_ago.power_state_updated = datetime_10_minutes_ago
823+ node_checked_long_ago.save()
824+
825+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
826+ system_ids = [params["system_id"] for params in power_parameters]
827+
828+ self.assertItemsEqual(
829+ {node_unchecked.system_id, node_checked_long_ago.system_id},
830+ system_ids)
831+
832+ def test__excludes_unqueryable_power_types(self):
833+ cluster = factory.make_NodeGroup()
834+ node_queryable = self.make_Node(cluster)
835+ self.make_Node(cluster, "foobar") # Unqueryable power type.
836+
837+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
838+ system_ids = [params["system_id"] for params in power_parameters]
839+
840+ self.assertItemsEqual([node_queryable.system_id], system_ids)
841+
842+ def test__excludes_broken_nodes(self):
843+ cluster = factory.make_NodeGroup()
844+ node_queryable = self.make_Node(cluster)
845+
846+ self.make_Node(cluster, status=NODE_STATUS.BROKEN)
847+ self.make_Node(
848+ cluster, status=NODE_STATUS.BROKEN, power_state_updated=(
849+ now() - timedelta(minutes=10)))
850+
851+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
852+ system_ids = [params["system_id"] for params in power_parameters]
853+
854+ self.assertItemsEqual([node_queryable.system_id], system_ids)
855+
856+ def test__excludes_devices(self):
857+ cluster = factory.make_NodeGroup()
858+ node_queryable = self.make_Node(cluster)
859+
860+ factory.make_Device(nodegroup=cluster)
861+ factory.make_Device(nodegroup=cluster, power_type="ipmi")
862+ factory.make_Device(
863+ nodegroup=cluster, power_type="ipmi", power_state_updated=(
864+ now() - timedelta(minutes=10)))
865+
866+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
867+ system_ids = [params["system_id"] for params in power_parameters]
868+
869+ self.assertItemsEqual([node_queryable.system_id], system_ids)
870+
871+ def test__returns_checked_nodes_in_last_checked_order(self):
872+ cluster = factory.make_NodeGroup()
873+ nodes = [self.make_Node(cluster) for _ in xrange(5)]
874+
875+ power_parameters = list_cluster_nodes_power_parameters(cluster.uuid)
876+ system_ids = [params["system_id"] for params in power_parameters]
877+
878+ # Checked nodes are always sorted from least recently checked to most.
879+ node_sort_key = attrgetter("power_state_updated", "system_id")
880+ nodes_in_order = sorted(nodes, key=node_sort_key)
881+ self.assertEqual(
882+ [node.system_id for node in nodes_in_order],
883+ system_ids)
884+
885+ def test__returns_at_most_60kiB_of_JSON(self):
886+ cluster = factory.make_NodeGroup()
887+
888+ # Ensure that there are at least 64kiB of power parameters (when
889+ # converted to JSON) in the database.
890+ example_parameters = {"key%d" % i: "value%d" % i for i in xrange(100)}
891+ remaining = 2 ** 16
892+ while remaining > 0:
893+ node = self.make_Node(cluster, power_parameters=example_parameters)
894+ remaining -= len(json.dumps(node.get_effective_power_parameters()))
895+
896+ nodes = list_cluster_nodes_power_parameters(cluster.uuid)
897+
898+ # The total size of the JSON is less than 60kiB, but only a bit.
899+ nodes_json = imap(json.dumps, nodes)
900+ nodes_json_lengths = imap(len, nodes_json)
901+ nodes_json_length = sum(nodes_json_lengths)
902+ expected_maximum = 60 * (2 ** 10) # 60kiB
903+ self.expectThat(nodes_json_length, LessThan(expected_maximum + 1))
904+ expected_minimum = 50 * (2 ** 10) # 50kiB
905+ self.expectThat(nodes_json_length, GreaterThan(expected_minimum - 1))
906
907=== modified file 'src/maasserver/rpc/tests/test_regionservice.py'
908--- src/maasserver/rpc/tests/test_regionservice.py 2015-06-05 13:21:13 +0000
909+++ src/maasserver/rpc/tests/test_regionservice.py 2015-07-15 12:15:32 +0000
910@@ -104,6 +104,7 @@
911 NoSuchNode,
912 )
913 from provisioningserver.rpc.interfaces import IConnection
914+from provisioningserver.rpc.power import QUERY_POWER_TYPES
915 from provisioningserver.rpc.region import (
916 Authenticate,
917 CommissionNode,
918@@ -688,7 +689,10 @@
919 nodegroup = yield deferToThread(self.create_nodegroup)
920 nodes = []
921 for _ in range(3):
922- node = yield deferToThread(self.create_node, nodegroup)
923+ node = yield deferToThread(
924+ self.create_node, nodegroup,
925+ power_type=random.choice(QUERY_POWER_TYPES),
926+ power_state_updated=None)
927 power_params = yield deferToThread(
928 self.get_node_power_parameters, node)
929 nodes.append({
930@@ -701,12 +705,15 @@
931
932 # Create a node with an invalid power type (i.e. the empty string).
933 # This will not be reported by the call to ListNodePowerParameters.
934- yield deferToThread(self.create_node, nodegroup, power_type="")
935+ yield deferToThread(
936+ self.create_node, nodegroup, power_type="",
937+ power_state_updated=None)
938
939 response = yield call_responder(
940 Region(), ListNodePowerParameters,
941 {b'uuid': nodegroup.uuid})
942
943+ self.maxDiff = None
944 self.assertItemsEqual(nodes, response['nodes'])
945
946 @wait_for_reactor
947
948=== modified file 'src/maasserver/testing/factory.py'
949--- src/maasserver/testing/factory.py 2015-05-20 13:51:49 +0000
950+++ src/maasserver/testing/factory.py 2015-07-15 12:15:32 +0000
951@@ -17,6 +17,7 @@
952 "Messages",
953 ]
954
955+from datetime import timedelta
956 import hashlib
957 from io import BytesIO
958 import logging
959@@ -25,6 +26,7 @@
960
961 from django.contrib.auth.models import User
962 from django.test.client import RequestFactory
963+from django.utils import timezone
964 from maasserver.clusterrpc.power_parameters import get_power_types
965 from maasserver.enum import (
966 BOOT_RESOURCE_FILE_TYPE,
967@@ -107,6 +109,11 @@
968 ALL_NODE_STATES = map_enum(NODE_STATUS).values()
969
970
971+# Use `undefined` instead of `None` for default factory arguments when `None`
972+# is a reasonable value for the argument.
973+undefined = object()
974+
975+
976 class Messages:
977 """A class to record messages published by Django messaging
978 framework.
979@@ -231,12 +238,13 @@
980 device.save()
981 return device
982
983- def make_Node(self, mac=False, hostname=None, status=None,
984- architecture="i386/generic", installable=True, updated=None,
985- created=None, nodegroup=None, routers=None, zone=None,
986- power_type=None, networks=None, boot_type=None,
987- sortable_name=False, parent=None, power_state=None,
988- disable_ipv4=None, **kwargs):
989+ def make_Node(
990+ self, mac=False, hostname=None, status=None,
991+ architecture="i386/generic", installable=True, updated=None,
992+ created=None, nodegroup=None, routers=None, zone=None,
993+ networks=None, boot_type=None, sortable_name=False,
994+ power_type=None, power_parameters=None, power_state=None,
995+ power_state_updated=undefined, disable_ipv4=None, **kwargs):
996 """Make a :class:`Node`.
997
998 :param sortable_name: If `True`, use a that will sort consistently
999@@ -260,8 +268,13 @@
1000 zone = self.make_Zone()
1001 if power_type is None:
1002 power_type = 'ether_wake'
1003+ if power_parameters is None:
1004+ power_parameters = ""
1005 if power_state is None:
1006 power_state = self.pick_enum(POWER_STATE)
1007+ if power_state_updated is undefined:
1008+ power_state_updated = (
1009+ timezone.now() - timedelta(minutes=random.randint(0, 15)))
1010 if disable_ipv4 is None:
1011 disable_ipv4 = self.pick_bool()
1012 if boot_type is None:
1013@@ -269,9 +282,10 @@
1014 node = Node(
1015 hostname=hostname, status=status, architecture=architecture,
1016 installable=installable, nodegroup=nodegroup, routers=routers,
1017- zone=zone, power_type=power_type, disable_ipv4=disable_ipv4,
1018- parent=parent, boot_type=boot_type, power_state=power_state,
1019- **kwargs)
1020+ zone=zone, boot_type=boot_type, power_type=power_type,
1021+ power_parameters=power_parameters, power_state=power_state,
1022+ power_state_updated=power_state_updated,
1023+ disable_ipv4=disable_ipv4, **kwargs)
1024 self._save_node_unchecked(node)
1025 # We do not generate random networks by default because the limited
1026 # number of VLAN identifiers (4,094) makes it very likely to
1027
1028=== modified file 'src/maasserver/websockets/handlers/device.py'
1029--- src/maasserver/websockets/handlers/device.py 2015-05-27 16:17:55 +0000
1030+++ src/maasserver/websockets/handlers/device.py 2015-07-15 12:15:32 +0000
1031@@ -139,6 +139,7 @@
1032 "boot_type",
1033 "status",
1034 "power_parameters",
1035+ "power_state_updated",
1036 "disable_ipv4",
1037 "osystem",
1038 "power_type",
1039
1040=== modified file 'src/maasserver/websockets/handlers/node.py'
1041--- src/maasserver/websockets/handlers/node.py 2015-07-10 09:08:58 +0000
1042+++ src/maasserver/websockets/handlers/node.py 2015-07-15 12:15:32 +0000
1043@@ -95,6 +95,9 @@
1044 "token",
1045 "netboot",
1046 "agent_name",
1047+ # power_state_updated isn't needed in the client yet, plus it's
1048+ # not native to JSON. Omit for now.
1049+ "power_state_updated",
1050 ]
1051 list_fields = [
1052 "system_id",
1053
1054=== modified file 'src/provisioningserver/pserv_services/node_power_monitor_service.py'
1055--- src/provisioningserver/pserv_services/node_power_monitor_service.py 2015-05-07 18:14:38 +0000
1056+++ src/provisioningserver/pserv_services/node_power_monitor_service.py 2015-07-15 12:15:32 +0000
1057@@ -27,10 +27,6 @@
1058 )
1059 from provisioningserver.rpc.power import query_all_nodes
1060 from provisioningserver.rpc.region import ListNodePowerParameters
1061-from provisioningserver.utils.twisted import (
1062- pause,
1063- retries,
1064-)
1065 from twisted.application.internet import TimerService
1066 from twisted.internet.defer import inlineCallbacks
1067 from twisted.python import log
1068@@ -42,7 +38,7 @@
1069 class NodePowerMonitorService(TimerService, object):
1070 """Service to monitor the power status of all nodes in this cluster."""
1071
1072- check_interval = timedelta(minutes=5).total_seconds()
1073+ check_interval = timedelta(seconds=15).total_seconds()
1074 max_nodes_at_once = 5
1075
1076 def __init__(self, cluster_uuid, clock=None):
1077@@ -57,42 +53,38 @@
1078 Log errors on failure, but do not propagate them up; that will
1079 stop the timed loop from running.
1080 """
1081- def query_nodes_failed(failure):
1082+ try:
1083+ client = getRegionClient()
1084+ except NoConnectionsAvailable:
1085+ maaslog.debug(
1086+ "Cannot monitor nodes' power status; "
1087+ "region not available.")
1088+ else:
1089+ d = self.query_nodes(client, uuid)
1090+ d.addErrback(self.query_nodes_failed, uuid)
1091+ return d
1092+
1093+ @inlineCallbacks
1094+ def query_nodes(self, client, uuid):
1095+ # Get the nodes' power parameters from the region. Keep getting more
1096+ # power parameters until the region returns an empty list.
1097+ while True:
1098+ response = yield client(ListNodePowerParameters, uuid=uuid)
1099+ power_parameters = response['nodes']
1100+ if len(power_parameters) > 0:
1101+ yield query_all_nodes(
1102+ power_parameters, max_concurrency=self.max_nodes_at_once,
1103+ clock=self.clock)
1104+ else:
1105+ break
1106+
1107+ def query_nodes_failed(self, failure, uuid):
1108+ if failure.check(NoSuchCluster):
1109+ maaslog.error("Cluster %s is not recognised.", uuid)
1110+ else:
1111 # Log the error in full to the Twisted log.
1112- log.err(failure)
1113+ log.err(failure, "Querying node power states.")
1114 # Log something concise to the MAAS log.
1115 maaslog.error(
1116 "Failed to query nodes' power status: %s",
1117 failure.getErrorMessage())
1118-
1119- return self.query_nodes(uuid).addErrback(query_nodes_failed)
1120-
1121- @inlineCallbacks
1122- def query_nodes(self, uuid):
1123- # Retry a few times, since this service usually comes up before
1124- # the RPC service.
1125- for elapsed, remaining, wait in retries(15, 5, self.clock):
1126- try:
1127- client = getRegionClient()
1128- except NoConnectionsAvailable:
1129- yield pause(wait, self.clock)
1130- else:
1131- break
1132- else:
1133- maaslog.error(
1134- "Cannot monitor nodes' power status; "
1135- "region not available.")
1136- return
1137-
1138- # Get the nodes' power parameters from the region.
1139- try:
1140- response = yield client(ListNodePowerParameters, uuid=uuid)
1141- except NoSuchCluster:
1142- maaslog.error(
1143- "This cluster (%s) is not recognised by the region.",
1144- uuid)
1145- else:
1146- node_power_parameters = response['nodes']
1147- yield query_all_nodes(
1148- node_power_parameters,
1149- max_concurrency=self.max_nodes_at_once, clock=self.clock)
1150
1151=== modified file 'src/provisioningserver/pserv_services/tests/test_node_power_monitor_service.py'
1152--- src/provisioningserver/pserv_services/tests/test_node_power_monitor_service.py 2015-05-22 15:52:13 +0000
1153+++ src/provisioningserver/pserv_services/tests/test_node_power_monitor_service.py 2015-07-15 12:15:32 +0000
1154@@ -17,10 +17,7 @@
1155
1156 from fixtures import FakeLogger
1157 from maastesting.factory import factory
1158-from maastesting.matchers import (
1159- MockCalledOnceWith,
1160- MockCallsMatch,
1161-)
1162+from maastesting.matchers import MockCalledOnceWith
1163 from maastesting.testcase import (
1164 MAASTestCase,
1165 MAASTwistedRunTest,
1166@@ -28,13 +25,14 @@
1167 from maastesting.twisted import TwistedLoggerFixture
1168 from mock import (
1169 ANY,
1170- call,
1171+ sentinel,
1172 )
1173 from provisioningserver.pserv_services import (
1174 node_power_monitor_service as npms,
1175 )
1176 from provisioningserver.rpc import (
1177 exceptions,
1178+ getRegionClient,
1179 region,
1180 )
1181 from provisioningserver.rpc.testing import MockClusterToRegionRPCFixture
1182@@ -56,94 +54,84 @@
1183 service = npms.NodePowerMonitorService(cluster_uuid)
1184 self.assertThat(service, MatchesStructure.byEquality(
1185 call=(service.try_query_nodes, (cluster_uuid,), {}),
1186- step=(5 * 60), clock=None))
1187+ step=15, clock=None))
1188
1189 def make_monitor_service(self):
1190 cluster_uuid = factory.make_UUID()
1191 service = npms.NodePowerMonitorService(cluster_uuid, Clock())
1192 return cluster_uuid, service
1193
1194- def test_query_nodes_retries_getting_client(self):
1195- cluster_uuid, service = self.make_monitor_service()
1196-
1197- getRegionClient = self.patch(npms, "getRegionClient")
1198- getRegionClient.side_effect = exceptions.NoConnectionsAvailable
1199-
1200- def has_been_called_n_times(n):
1201- calls = [call()] * n
1202- return MockCallsMatch(*calls)
1203-
1204- maaslog = self.useFixture(FakeLogger("maas"))
1205-
1206- d = service.query_nodes(cluster_uuid)
1207- # Immediately the first attempt to get a client happens.
1208- self.assertThat(getRegionClient, has_been_called_n_times(1))
1209- self.assertFalse(d.called)
1210- # Followed by 3 more attempts as time passes.
1211- service.clock.pump((5, 5, 5))
1212- self.assertThat(getRegionClient, has_been_called_n_times(4))
1213- # query_nodes returns after 15 seconds.
1214- self.assertTrue(d.called)
1215- self.assertIsNone(extract_result(d))
1216-
1217- # A simple message is logged, but even this may be too noisy.
1218- self.assertIn(
1219- "Cannot monitor nodes' power status; region not available.",
1220- maaslog.output)
1221-
1222 def test_query_nodes_calls_the_region(self):
1223 cluster_uuid, service = self.make_monitor_service()
1224
1225 rpc_fixture = self.useFixture(MockClusterToRegionRPCFixture())
1226- client, io = rpc_fixture.makeEventLoop(region.ListNodePowerParameters)
1227- client.ListNodePowerParameters.return_value = succeed({"nodes": []})
1228+ proto_region, io = rpc_fixture.makeEventLoop(
1229+ region.ListNodePowerParameters)
1230+ proto_region.ListNodePowerParameters.return_value = succeed(
1231+ {"nodes": []})
1232
1233- d = service.query_nodes(cluster_uuid)
1234+ d = service.query_nodes(getRegionClient(), cluster_uuid)
1235 io.flush()
1236
1237 self.assertEqual(None, extract_result(d))
1238 self.assertThat(
1239- client.ListNodePowerParameters,
1240+ proto_region.ListNodePowerParameters,
1241 MockCalledOnceWith(ANY, uuid=cluster_uuid))
1242
1243 def test_query_nodes_calls_query_all_nodes(self):
1244 cluster_uuid, service = self.make_monitor_service()
1245+ service.max_nodes_at_once = sentinel.max_nodes_at_once
1246+
1247+ example_power_parameters = {
1248+ "system_id": factory.make_UUID(),
1249+ "hostname": factory.make_hostname(),
1250+ "power_state": factory.make_name("power_state"),
1251+ "power_type": factory.make_name("power_type"),
1252+ "context": {},
1253+ }
1254
1255 rpc_fixture = self.useFixture(MockClusterToRegionRPCFixture())
1256- client, io = rpc_fixture.makeEventLoop(region.ListNodePowerParameters)
1257- client.ListNodePowerParameters.return_value = succeed({"nodes": []})
1258+ proto_region, io = rpc_fixture.makeEventLoop(
1259+ region.ListNodePowerParameters)
1260+ proto_region.ListNodePowerParameters.side_effect = [
1261+ succeed({"nodes": [example_power_parameters]}),
1262+ succeed({"nodes": []}),
1263+ ]
1264
1265 query_all_nodes = self.patch(npms, "query_all_nodes")
1266
1267- d = service.query_nodes(cluster_uuid)
1268+ d = service.query_nodes(getRegionClient(), cluster_uuid)
1269 io.flush()
1270
1271 self.assertEqual(None, extract_result(d))
1272 self.assertThat(
1273 query_all_nodes,
1274 MockCalledOnceWith(
1275- [], max_concurrency=service.max_nodes_at_once,
1276+ [example_power_parameters],
1277+ max_concurrency=sentinel.max_nodes_at_once,
1278 clock=service.clock))
1279
1280 def test_query_nodes_copes_with_NoSuchCluster(self):
1281 cluster_uuid, service = self.make_monitor_service()
1282
1283 rpc_fixture = self.useFixture(MockClusterToRegionRPCFixture())
1284- client, io = rpc_fixture.makeEventLoop(region.ListNodePowerParameters)
1285- client.ListNodePowerParameters.return_value = fail(
1286+ proto_region, io = rpc_fixture.makeEventLoop(
1287+ region.ListNodePowerParameters)
1288+ proto_region.ListNodePowerParameters.return_value = fail(
1289 exceptions.NoSuchCluster.from_uuid(cluster_uuid))
1290
1291- d = service.query_nodes(cluster_uuid)
1292+ d = service.query_nodes(getRegionClient(), cluster_uuid)
1293+ d.addErrback(service.query_nodes_failed, cluster_uuid)
1294 with FakeLogger("maas") as maaslog:
1295 io.flush()
1296
1297 self.assertEqual(None, extract_result(d))
1298 self.assertDocTestMatches(
1299- "This cluster (...) is not recognised by the region.",
1300- maaslog.output)
1301+ "Cluster ... is not recognised.", maaslog.output)
1302
1303 def test_try_query_nodes_logs_other_errors(self):
1304 cluster_uuid, service = self.make_monitor_service()
1305+ self.patch(npms, "getRegionClient").return_value = sentinel.client
1306
1307 query_nodes = self.patch(service, "query_nodes")
1308 query_nodes.return_value = fail(
1309
1310=== modified file 'src/provisioningserver/rpc/power.py'
1311--- src/provisioningserver/rpc/power.py 2015-07-10 09:08:58 +0000
1312+++ src/provisioningserver/rpc/power.py 2015-07-15 12:15:32 +0000
1313@@ -19,6 +19,7 @@
1314
1315 from datetime import timedelta
1316 from functools import partial
1317+import sys
1318
1319 from provisioningserver.events import (
1320 EVENT_TYPES,
1321@@ -331,33 +332,38 @@
1322 # type, however this is left here to prevent PEBKAC.
1323 raise PowerActionFail("Unknown power_type '%s'" % power_type)
1324
1325+ def check_power_state(state):
1326+ if state not in ("on", "off", "unknown"):
1327+ # This is considered an error.
1328+ raise PowerActionFail(state)
1329+
1330+ # Capture errors as we go along.
1331+ exc_info = None, None, None
1332+
1333 # Use increasing waiting times to work around race conditions that could
1334 # arise when power querying the node.
1335 for waiting_time in default_waiting_policy:
1336- error = None
1337 # Perform power query.
1338 try:
1339 power_state = yield deferToThread(
1340- perform_power_query, system_id, hostname, power_type, context)
1341- if power_state not in ("on", "off", "unknown"):
1342- # This is considered an error.
1343- raise PowerActionFail(power_state)
1344- except PowerActionFail as e:
1345- # Hold the error so if failure after retries, we can
1346- # log the reason.
1347- error = e
1348-
1349+ perform_power_query, system_id, hostname,
1350+ power_type, context)
1351+ check_power_state(power_state)
1352+ except:
1353+ # Hold the error; it may be reported later.
1354+ exc_info = sys.exc_info()
1355 # Wait before trying again.
1356 yield pause(waiting_time, clock)
1357- continue
1358- yield power_state_update(system_id, power_state)
1359- returnValue(power_state)
1360+ else:
1361+ yield power_state_update(system_id, power_state)
1362+ returnValue(power_state)
1363
1364- # Send node is broken, since query failed after the multiple retries.
1365- message = "Node could not be queried %s (%s) %s" % (
1366- system_id, hostname, error)
1367+ # Reaching here means that things have gone wrong.
1368+ assert exc_info != (None, None, None)
1369+ exc_type, exc_value, exc_trace = exc_info
1370+ message = "Power state could not be queried: %s" % (exc_value,)
1371 yield power_query_failure(system_id, hostname, message)
1372- raise PowerActionFail(error)
1373+ raise exc_type, exc_value, exc_trace
1374
1375
1376 def maaslog_report_success(node, power_state):
1377
1378=== modified file 'src/provisioningserver/rpc/region.py'
1379--- src/provisioningserver/rpc/region.py 2015-05-07 18:14:38 +0000
1380+++ src/provisioningserver/rpc/region.py 2015-07-15 12:15:32 +0000
1381@@ -246,11 +246,16 @@
1382
1383
1384 class ListNodePowerParameters(amp.Command):
1385- """Return the list of power parameters for nodes
1386- that this cluster controls.
1387-
1388- Used to query all of the nodes that the cluster
1389- composes.
1390+ """Return power parameters for the nodes in the specified cluster.
1391+
1392+ This will only return power parameters for nodes that have power types for
1393+ which MAAS has a query capability.
1394+
1395+ It will return nodes in priority order. Those nodes at the beginning of
1396+ the list should be queried first.
1397+
1398+ It may return an empty list. This means that all nodes have been recently
1399+ queried. Take a break before asking again.
1400
1401 :since: 1.7
1402 """
1403
1404=== modified file 'src/provisioningserver/rpc/tests/test_power.py'
1405--- src/provisioningserver/rpc/tests/test_power.py 2015-05-22 15:52:13 +0000
1406+++ src/provisioningserver/rpc/tests/test_power.py 2015-07-15 12:15:32 +0000
1407@@ -30,6 +30,7 @@
1408 MAASTwistedRunTest,
1409 )
1410 from maastesting.twisted import (
1411+ always_fail_with,
1412 always_succeed_with,
1413 TwistedLoggerFixture,
1414 )
1415@@ -41,6 +42,7 @@
1416 sentinel,
1417 )
1418 import provisioningserver
1419+from provisioningserver.drivers.power import PowerDriverRegistry
1420 from provisioningserver.events import EVENT_TYPES
1421 from provisioningserver.power.poweraction import PowerActionFail
1422 from provisioningserver.rpc import (
1423@@ -643,6 +645,79 @@
1424 self.assertEqual("off", extract_result(d))
1425
1426
1427+class TestPowerQueryExceptions(MAASTestCase):
1428+
1429+ scenarios = tuple(
1430+ (power_type, {
1431+ "power_type": power_type,
1432+ "func": ( # Function to invoke driver.
1433+ "perform_power_driver_query"
1434+ if power_type in PowerDriverRegistry
1435+ else "perform_power_query"),
1436+ "waits": ( # Pauses between retries.
1437+ [] if power_type in PowerDriverRegistry
1438+ else power.default_waiting_policy),
1439+ "calls": ( # No. of calls to the driver.
1440+ 1 if power_type in PowerDriverRegistry
1441+ else len(power.default_waiting_policy)),
1442+ })
1443+ for power_type in power.QUERY_POWER_TYPES
1444+ )
1445+
1446+ def test_get_power_state_captures_all_exceptions(self):
1447+ logger_twisted = self.useFixture(TwistedLoggerFixture())
1448+ logger_maaslog = self.useFixture(FakeLogger("maas"))
1449+
1450+ # Avoid threads here.
1451+ self.patch(power, "deferToThread", maybeDeferred)
1452+
1453+ exception_type = factory.make_exception_type()
1454+ exception_message = factory.make_string()
1455+ exception = exception_type(exception_message)
1456+
1457+ # Pretend the query always fails with `exception`.
1458+ query = self.patch_autospec(power, self.func)
1459+ query.side_effect = always_fail_with(exception)
1460+
1461+ # Intercept calls to power_query_failure().
1462+ self.patch_autospec(power, "power_query_failure")
1463+
1464+ system_id = factory.make_name('system_id')
1465+ hostname = factory.make_name('hostname')
1466+ context = sentinel.context
1467+ clock = Clock()
1468+
1469+ d = power.get_power_state(
1470+ system_id, hostname, self.power_type, context, clock)
1471+
1472+ # Crank through some number of retries.
1473+ for wait in self.waits:
1474+ self.assertFalse(d.called)
1475+ clock.advance(wait)
1476+ self.assertTrue(d.called)
1477+
1478+ # Finally the exception from the query is raised.
1479+ self.assertRaises(exception_type, extract_result, d)
1480+
1481+ # The broken power query function patched earlier was called the same
1482+ # number of times as there are steps in the default waiting policy.
1483+ expected_call = call(system_id, hostname, self.power_type, context)
1484+ expected_calls = [expected_call] * self.calls
1485+ self.assertThat(query, MockCallsMatch(*expected_calls))
1486+
1487+ # power_query_failure() was called once at the end with a message
1488+ # constructed using the error message we fabricated at the beginning.
1489+ expected_message = (
1490+ "Power state could not be queried: %s" % exception_message)
1491+ self.assertThat(power.power_query_failure, MockCalledOnceWith(
1492+ system_id, hostname, expected_message))
1493+
1494+ # Nothing was logged to the Twisted log or to maaslog; that happens
1495+ # elsewhere, in maaslog_query_failure() and maaslog_query().
1496+ self.assertEqual("", logger_twisted.output)
1497+ self.assertEqual("", logger_maaslog.output)
1498+
1499+
1500 class TestPowerQueryAsync(MAASTestCase):
1501
1502 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)

Subscribers

People subscribed via source and target branches

to all changes: