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

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 4076
Proposed branch: lp:~allenap/maas/power-poll-fewer--bug-1389007
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 1586 lines (+998/-184)
16 files modified
HACKING.txt (+9/-3)
src/maasserver/migrations/0150_power_parameters_and_state_updated_field.py (+515/-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 (+45/-32)
src/provisioningserver/rpc/region.py (+10/-5)
src/provisioningserver/rpc/tests/test_power.py (+74/-0)
To merge this branch: bzr merge lp:~allenap/maas/power-poll-fewer--bug-1389007
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+263550@code.launchpad.net

This proposal supersedes a proposal from 2014-11-24.

Commit message

Query node power states in smaller batches.

Description of the change

Big diff because migrations!

This branch was previously reviewed by Graham Binns, and approved. It
has changed only a little since then, but I'd appreciate a sanity check
before I land it.

This branch adds a power_state_updated field to Node, which records when
the power state was last checked, regardless of success or failure. It's
a simple way to rate-limit checks.

The reason it's needed is because ListNodePowerParameters now only
returns a subset of all queryable nodes (at least for a large
installation). Each time it's called it returns a list of those nodes
that need checking *in priority order*. Nodes that have never had their
power state checked are done first, then the ones that have been queried
least recently.

We /could/ later use this new field to trigger an immediate check via
the API or UI; set it to NULL and it'll be in the next set of checks.
That's a bit of an abuse though.

In any case, this branch is meant to be a most-bang-for-the-least-buck
kind of thing. It doesn't back us into a corner, but does solve the
immediate problem in what I hope is an elegant and effective way.

Thanks!

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote : Posted in a previous version of this proposal

LGTM. I have some questions and concerns, but nothing to hold the branch back.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote : Posted in a previous version of this proposal

Ta muchly!

Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal
Download full text (4.6 MiB)

The attempt to merge lp:~allenap/maas/power-poll-fewer--bug-1389007 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://security.ubuntu.com trusty-security Release [62.0 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [62.0 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [51.5 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [17.4 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [162 kB]
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [72.6 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [142 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [91.8 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [367 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [220 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,250 kB in 2s (437 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools gjs ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make pep8 postgresql pyflakes python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi ...

Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal
Download full text (19.5 KiB)

The attempt to merge lp:~allenap/maas/power-poll-fewer--bug-1389007 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://security.ubuntu.com trusty-security Release [62.0 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [62.0 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [51.5 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [17.4 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [162 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [72.5 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [142 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [91.8 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [367 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [220 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,250 kB in 3s (411 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools gjs ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make pep8 postgresql pyflakes python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi ...

Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal
Download full text (24.3 KiB)

The attempt to merge lp:~allenap/maas/power-poll-fewer--bug-1389007 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [62.0 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [62.0 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [51.5 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [17.4 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [162 kB]
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [72.5 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [142 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [91.8 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [368 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [221 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Fetched 1,251 kB in 2s (424 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools gjs ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make pep8 postgresql pyflakes python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mimeparse python-mock python-netaddr python-netifaces python-nose python-oauth python-oops python-oops-amqp python-oops-datedir-repo python-oops-twisted python-oops-wsgi ...

Revision history for this message
Gavin Panella (allenap) wrote : Posted in a previous version of this proposal

This is ready to land, but I want to make sure it's okay to go into 1.7.1 first.

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

So, everyone's good with this, right?

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

The attempt to merge lp:~allenap/maas/power-poll-fewer--bug-1389007 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [63.5 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [63.5 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [87.5 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [28.1 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [309 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [111 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [212 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [124 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [567 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [292 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,860 kB in 2s (629 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mock python-netaddr python-netifaces python-...

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

The attempt to merge lp:~allenap/maas/power-poll-fewer--bug-1389007 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [63.5 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [63.5 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [87.5 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:6 http://security.ubuntu.com trusty-security/universe Sources [28.1 kB]
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [309 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:8 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [212 kB]
Get:9 http://security.ubuntu.com trusty-security/universe amd64 Packages [111 kB]
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [124 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [567 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [292 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,860 kB in 3s (547 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-mock python-netaddr python-netifaces python-...

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