Merge lp:~flacoste/maas/meta-publickeys into lp:~maas-committers/maas/trunk

Proposed by Francis J. Lacoste
Status: Merged
Approved by: Francis J. Lacoste
Approved revision: no longer in the source branch.
Merged at revision: 355
Proposed branch: lp:~flacoste/maas/meta-publickeys
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 520 lines (+272/-49)
10 files modified
src/maasserver/middleware.py (+0/-1)
src/maasserver/migrations/0003_rename_sshkeys.py (+149/-0)
src/maasserver/models.py (+22/-6)
src/maasserver/testing/factory.py (+16/-0)
src/maasserver/tests/test_models.py (+21/-0)
src/maasserver/tests/test_views.py (+0/-24)
src/maasserver/urls.py (+0/-2)
src/maasserver/views.py (+0/-8)
src/metadataserver/api.py (+29/-7)
src/metadataserver/tests/test_api.py (+35/-1)
To merge this branch: bzr merge lp:~flacoste/maas/meta-publickeys
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+99147@code.launchpad.net

This proposal supersedes a proposal from 2012-03-19.

Commit message

Export public-keys attribute over the meta-data service.

Description of the change

This branch exports the user's registered SSH keys over the metadata service. cloud-init already supports getting the keys at that location in that format.

It also rename SSHKeys to SSHKey to be more in line with our naming conventions and remove the obsolete KeystoreView.

We are now only missing the UI to upload and delete keys on the user preferences page.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (3.2 KiB)

The practice deleting and re-creating a table as a means of altering it scared me a little, but it appears to be safe: someone from the future migrating back to our current schema (and therefore, our current codebase as well) would not expect any ssh keys they still had to work. I do wonder if renaming the table & adding some columns wouldn't have been easier though!

On another sidenote, it's not just our naming convention to keep class name in the singular; it's long-standing practice in database design. It does occasionally lead to awkwardness, although it works pretty well overall.

258 === modified file 'src/maasserver/tests/test_models.py'
259 --- src/maasserver/tests/test_models.py 2012-03-22 15:10:57 +0000
260 +++ src/maasserver/tests/test_models.py 2012-03-24 01:25:23 +0000

269 @@ -490,6 +491,26 @@
270 self.assertTrue(set(SYSTEM_USERS).isdisjoint(usernames))
271
272
273 +class SSHKeyManager(TestCase):
274 + """Testing for the :class `SSHKeyManager` model manager."""
275 +
276 + def test_get_keys_for_user_no_keys(self):
277 + user = factory.make_user()
278 + keys = SSHKey.objects.get_keys_for_user(user)
279 + self.assertEquals([], list(keys))

There's a specialized assertion method that obviates the listification:

    self.assertItemsEqual([], keys)

This says that [] and keys contain the same numbers of the same items, regardless of order.

281 + def test_get_keys_for_user_with_keys(self):
282 + user1 = factory.make_user_with_keys(n_keys=3, username='user1')
283 + # user2
284 + factory.make_user_with_keys(n_keys=2)
285 + keys = SSHKey.objects.get_keys_for_user(user1)
286 + self.assertEquals([
287 + 'ssh-rsa KEY user1-key-0',
288 + 'ssh-rsa KEY user1-key-1',
289 + 'ssh-rsa KEY user1-key-2',
290 + ], list(keys))

There's another specialized assertion method that obviates this listification:

    self.assertSequenceEqual([…], keys)

This asserts that […] and keys contain the same items, in the same order.

I assume you asked yourself the two standard question when asserting sequence equality:

1. Is this ordering really guaranteed?

2. Is checking the ordering part of the purpose of this test?

382 === modified file 'src/metadataserver/api.py'
383 --- src/metadataserver/api.py 2012-03-21 05:56:36 +0000
384 +++ src/metadataserver/api.py 2012-03-24 01:25:23 +0000

425 @@ -142,6 +150,13 @@
426 """Produce instance-id attribute."""
427 return make_text_response(node.system_id)
428
429 + def public_keys(self, node, version, item):
430 + """ Produce public-keys attribute."""
431 + keys = SSHKey.objects.get_keys_for_user(user=node.owner)
432 + if not keys:
433 + raise Http404
434 + return make_list_response(keys)

Is 404 really the right response here? Or is it acceptable for a user to have zero public keys?

If you do want to stick with 404, then I think that:

1. You should probably raise MAASAPINotFound rather than Http404.

2. Unless it's practically unthinkable for this to happen, if the user has no keys registered, the “parent” page /metadata/<ve...

Read more...

review: Approve
Revision history for this message
Francis J. Lacoste (flacoste) wrote :

Thanks for the review Jeroen.

I've used the more appropriate asserts (thanks for pointing these out) and I've added code to filter out public-keys/ when it's going to raise a 404. It turns out that cloud-init relies on that behaviour.

Cheers

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/middleware.py'
2--- src/maasserver/middleware.py 2012-03-23 11:09:54 +0000
3+++ src/maasserver/middleware.py 2012-03-26 21:06:20 +0000
4@@ -74,7 +74,6 @@
5 reverse('metadata'),
6 # API calls are protected by piston.
7 settings.API_URL_REGEXP,
8- r'^/accounts/[\w]+/sshkeys/$',
9 ]
10 self.public_urls = re.compile("|".join(public_url_roots))
11 self.login_url = reverse('login')
12
13=== added file 'src/maasserver/migrations/0003_rename_sshkeys.py'
14--- src/maasserver/migrations/0003_rename_sshkeys.py 1970-01-01 00:00:00 +0000
15+++ src/maasserver/migrations/0003_rename_sshkeys.py 2012-03-26 21:06:20 +0000
16@@ -0,0 +1,149 @@
17+# encoding: utf-8
18+import datetime
19+from south.db import db
20+from south.v2 import SchemaMigration
21+from django.db import models
22+
23+class Migration(SchemaMigration):
24+
25+ def forwards(self, orm):
26+
27+ # Deleting model 'SSHKeys'
28+ db.delete_table('maasserver_sshkeys')
29+
30+ # Adding model 'SSHKey'
31+ db.create_table('maasserver_sshkey', (
32+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
33+ ('created', self.gf('django.db.models.fields.DateField')()),
34+ ('updated', self.gf('django.db.models.fields.DateTimeField')()),
35+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
36+ ('key', self.gf('django.db.models.fields.TextField')()),
37+ ))
38+ db.send_create_signal('maasserver', ['SSHKey'])
39+
40+
41+ def backwards(self, orm):
42+
43+ # Adding model 'SSHKeys'
44+ db.create_table('maasserver_sshkeys', (
45+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
46+ ('key', self.gf('django.db.models.fields.TextField')()),
47+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['maasserver.UserProfile'])),
48+ ))
49+ db.send_create_signal('maasserver', ['SSHKeys'])
50+
51+ # Deleting model 'SSHKey'
52+ db.delete_table('maasserver_sshkey')
53+
54+
55+ models = {
56+ 'auth.group': {
57+ 'Meta': {'object_name': 'Group'},
58+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
59+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
60+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
61+ },
62+ 'auth.permission': {
63+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
64+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
65+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
66+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
67+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
68+ },
69+ 'auth.user': {
70+ 'Meta': {'object_name': 'User'},
71+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
72+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
73+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
74+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
75+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
76+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
77+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
78+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
79+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
80+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
81+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
82+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
83+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
84+ },
85+ 'contenttypes.contenttype': {
86+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
87+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
88+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
89+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
90+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
91+ },
92+ 'maasserver.config': {
93+ 'Meta': {'object_name': 'Config'},
94+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
95+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
96+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
97+ },
98+ 'maasserver.filestorage': {
99+ 'Meta': {'object_name': 'FileStorage'},
100+ 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
101+ 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
102+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
103+ },
104+ 'maasserver.macaddress': {
105+ 'Meta': {'object_name': 'MACAddress'},
106+ 'created': ('django.db.models.fields.DateField', [], {}),
107+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
108+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
109+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['maasserver.Node']"}),
110+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
111+ },
112+ 'maasserver.node': {
113+ 'Meta': {'object_name': 'Node'},
114+ 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
115+ 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}),
116+ 'created': ('django.db.models.fields.DateField', [], {}),
117+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
118+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
119+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
120+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
121+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '4', 'max_length': '10'}),
122+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-0af2184e-7549-11e1-b99b-00242bbb6876'", 'unique': 'True', 'max_length': '41'}),
123+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
124+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
125+ },
126+ 'maasserver.sshkey': {
127+ 'Meta': {'object_name': 'SSHKey'},
128+ 'created': ('django.db.models.fields.DateField', [], {}),
129+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
130+ 'key': ('django.db.models.fields.TextField', [], {}),
131+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
132+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
133+ },
134+ 'maasserver.userprofile': {
135+ 'Meta': {'object_name': 'UserProfile'},
136+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
137+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
138+ },
139+ 'piston.consumer': {
140+ 'Meta': {'object_name': 'Consumer'},
141+ 'description': ('django.db.models.fields.TextField', [], {}),
142+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
143+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
144+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
145+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
146+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
147+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
148+ },
149+ 'piston.token': {
150+ 'Meta': {'object_name': 'Token'},
151+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
152+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
153+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
154+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
155+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
156+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
157+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
158+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1332549237L'}),
159+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
160+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
161+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
162+ }
163+ }
164+
165+ complete_apps = ['maasserver']
166
167=== modified file 'src/maasserver/models.py'
168--- src/maasserver/models.py 2012-03-26 05:07:12 +0000
169+++ src/maasserver/models.py 2012-03-26 21:06:20 +0000
170@@ -18,6 +18,7 @@
171 "NODE_STATUS",
172 "Node",
173 "MACAddress",
174+ "SSHKey",
175 "UserProfile",
176 ]
177
178@@ -645,15 +646,30 @@
179 User._meta.get_field('email')._unique = True
180
181
182-class SSHKeys(models.Model):
183- """A simple SSH public keystore that can be retrieved, a user
184- can have multiple keys.
185+class SSHKeyManager(models.Manager):
186+ """A utility to manage the colletion of `SSHKey`s."""
187+
188+ def get_keys_for_user(self, user):
189+ """Return the text of the ssh keys associated with a user."""
190+ return SSHKey.objects.filter(user=user).values_list('key', flat=True)
191+
192+
193+class SSHKey(CommonInfo):
194+ """A `SSHKey` represents a user public SSH key.
195+
196+ Users will be able to access `Node`s using any of their registered keys.
197
198 :ivar user: The user which owns the key.
199 :ivar key: The ssh public key.
200 """
201- user = models.ForeignKey(UserProfile)
202- key = models.TextField()
203+ class Meta:
204+ verbose_name_plural = "SSH keys"
205+
206+ objects = SSHKeyManager()
207+
208+ user = models.ForeignKey(User, null=False, editable=False)
209+
210+ key = models.TextField(null=False, editable=True)
211
212 def __unicode__(self):
213 return self.key
214@@ -919,7 +935,7 @@
215 admin.site.register(FileStorage)
216 admin.site.register(MACAddress)
217 admin.site.register(Node)
218-admin.site.register(SSHKeys)
219+admin.site.register(SSHKey)
220
221
222 class MAASAuthorizationBackend(ModelBackend):
223
224=== modified file 'src/maasserver/testing/factory.py'
225--- src/maasserver/testing/factory.py 2012-03-22 11:56:17 +0000
226+++ src/maasserver/testing/factory.py 2012-03-26 21:06:20 +0000
227@@ -24,6 +24,7 @@
228 MACAddress,
229 Node,
230 NODE_STATUS,
231+ SSHKey,
232 )
233 from maasserver.testing.enum import map_enum
234 import maastesting.factory
235@@ -82,6 +83,21 @@
236 return User.objects.create_user(
237 username=username, password=password, email=email)
238
239+ def make_user_with_keys(self, n_keys=2, **kwargs):
240+ """Create a user with n `SSHKey`.
241+
242+ Additional keyword arguments are passed to `make_user()`.
243+
244+ Keys will have a comment of the form: <username>-key-<i> where i
245+ is the key index.
246+ """
247+ user = self.make_user(**kwargs)
248+ for i in range(n_keys):
249+ SSHKey(
250+ user=user,
251+ key='ssh-rsa KEY %s-key-%d' % (user.username, i)).save()
252+ return user
253+
254 def make_admin(self, username=None, password=None, email=None):
255 admin = self.make_user(
256 username=username, password=password, email=email)
257
258=== modified file 'src/maasserver/tests/test_models.py'
259--- src/maasserver/tests/test_models.py 2012-03-26 05:07:12 +0000
260+++ src/maasserver/tests/test_models.py 2012-03-26 21:06:20 +0000
261@@ -37,6 +37,7 @@
262 Node,
263 NODE_STATUS,
264 NODE_STATUS_CHOICES_DICT,
265+ SSHKey,
266 SYSTEM_USERS,
267 UserProfile,
268 )
269@@ -489,6 +490,26 @@
270 self.assertTrue(set(SYSTEM_USERS).isdisjoint(usernames))
271
272
273+class SSHKeyManagerTest(TestCase):
274+ """Testing for the :class `SSHKeyManager` model manager."""
275+
276+ def test_get_keys_for_user_no_keys(self):
277+ user = factory.make_user()
278+ keys = SSHKey.objects.get_keys_for_user(user)
279+ self.assertItemsEqual([], keys)
280+
281+ def test_get_keys_for_user_with_keys(self):
282+ user1 = factory.make_user_with_keys(n_keys=3, username='user1')
283+ # user2
284+ factory.make_user_with_keys(n_keys=2)
285+ keys = SSHKey.objects.get_keys_for_user(user1)
286+ self.assertItemsEqual([
287+ 'ssh-rsa KEY user1-key-0',
288+ 'ssh-rsa KEY user1-key-1',
289+ 'ssh-rsa KEY user1-key-2',
290+ ], keys)
291+
292+
293 class FileStorageTest(TestCase):
294 """Testing of the :class:`FileStorage` model."""
295
296
297=== modified file 'src/maasserver/tests/test_views.py'
298--- src/maasserver/tests/test_views.py 2012-03-23 05:02:31 +0000
299+++ src/maasserver/tests/test_views.py 2012-03-26 21:06:20 +0000
300@@ -31,7 +31,6 @@
301 Config,
302 NODE_AFTER_COMMISSIONING_ACTION,
303 POWER_TYPE_CHOICES,
304- SSHKeys,
305 UserProfile,
306 )
307 from maasserver.testing import reload_object
308@@ -681,26 +680,3 @@
309 content_text = doc.cssselect('#content')[0].text_content()
310 self.assertIn(user.username, content_text)
311 self.assertIn(user.email, content_text)
312-
313-
314-class SSHKeyServerTest(TestCase):
315-
316- def setUp(self):
317- super(SSHKeyServerTest, self).setUp()
318- self.user = factory.make_user()
319- self.sshkey = SSHKeys.objects.create(
320- user=self.user.get_profile(),
321- key=("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQDmQLTto0BUB2+Ayj9rwuE",
322- "iwd/IyY9YU7qUzqgJBqRp+3FDhZYQqI6aG9sLmPccP+gka1Ia5wlJODpXeu",
323- "cQVqPsKW9Moj/XP1spIuYh6ZrhHElyPB7aPjqoTtpX1+lx6mJU=",
324- "maas@example")
325- )
326-
327- def test_get_user_sshkey(self):
328- response = self.client.get('/accounts/%s/sshkeys/' % self.user)
329- self.assertIn(str(self.sshkey.key), response.content)
330-
331- def test_get_null_sshkey(self):
332- response = self.client.get('/accounts/nulluser/sshkeys/')
333- self.assertEqual(response.status_code, 200)
334- self.assertEqual('\n'.encode('utf-8'), response.content)
335
336=== modified file 'src/maasserver/urls.py'
337--- src/maasserver/urls.py 2012-03-23 11:18:18 +0000
338+++ src/maasserver/urls.py 2012-03-26 21:06:20 +0000
339@@ -32,7 +32,6 @@
340 AccountsEdit,
341 AccountsView,
342 combo_view,
343- KeystoreView,
344 login,
345 logout,
346 NodeEdit,
347@@ -64,7 +63,6 @@
348 url(
349 r'^favicon\.ico$', redirect_to, {'url': '/static/img/favicon.ico'},
350 name='favicon'),
351- url(r'^accounts/(?P<userid>\w+)/sshkeys/$', KeystoreView),
352 )
353
354 # URLs for logged-in users.
355
356=== modified file 'src/maasserver/views.py'
357--- src/maasserver/views.py 2012-03-23 11:18:18 +0000
358+++ src/maasserver/views.py 2012-03-26 21:06:20 +0000
359@@ -71,7 +71,6 @@
360 from maasserver.messages import messaging
361 from maasserver.models import (
362 Node,
363- SSHKeys,
364 UserProfile,
365 )
366
367@@ -163,13 +162,6 @@
368 return reverse('index')
369
370
371-def KeystoreView(request, userid):
372- keys = SSHKeys.objects.filter(user__user__username=userid)
373- return render_to_response(
374- 'maasserver/sshkeys.txt', {'keys': keys}, mimetype="text/plain",
375- context_instance=RequestContext(request))
376-
377-
378 def process_form(request, form_class, redirect_url, prefix,
379 success_message=None, form_kwargs=None):
380 """Utility method to process subforms (i.e. forms with a prefix).
381
382=== modified file 'src/metadataserver/api.py'
383--- src/metadataserver/api.py 2012-03-21 05:56:36 +0000
384+++ src/metadataserver/api.py 2012-03-26 21:06:20 +0000
385@@ -16,7 +16,10 @@
386 'VersionIndexHandler',
387 ]
388
389-from django.http import HttpResponse
390+from django.http import (
391+ Http404,
392+ HttpResponse,
393+ )
394 from maasserver.api import extract_oauth_key
395 from maasserver.exceptions import (
396 MAASAPINotFound,
397@@ -27,6 +30,10 @@
398 NodeKey,
399 NodeUserData,
400 )
401+from maasserver.models import (
402+ SSHKey,
403+ )
404+
405 from piston.handler import BaseHandler
406
407
408@@ -99,7 +106,7 @@
409 class MetaDataHandler(VersionIndexHandler):
410 """Meta-data listing for a given version."""
411
412- fields = ('instance-id', 'local-hostname',)
413+ fields = ('instance-id', 'local-hostname', 'public-keys')
414
415 def get_attribute_producer(self, item):
416 """Return a callable to deliver a given metadata item.
417@@ -119,18 +126,26 @@
418 producers = {
419 'local-hostname': self.local_hostname,
420 'instance-id': self.instance_id,
421+ 'public-keys': self.public_keys,
422 }
423
424 return producers[field]
425
426 def read(self, request, version, item=None):
427+ check_version(version)
428+ node = get_node_for_request(request)
429+
430+ # Requesting the list of attributes, not any particular
431+ # attribute.
432 if item is None or len(item) == 0:
433- # Requesting the list of attributes, not any particular
434- # attribute.
435- return make_list_response(sorted(self.fields))
436+ fields = list(self.fields)
437+ # Add public-keys to the list of attributes, if the
438+ # node has registered SSH keys.
439+ keys = SSHKey.objects.get_keys_for_user(user=node.owner)
440+ if not keys:
441+ fields.remove('public-keys')
442+ return make_list_response(sorted(fields))
443
444- check_version(version)
445- node = get_node_for_request(request)
446 producer = self.get_attribute_producer(item)
447 return producer(node, version, item)
448
449@@ -142,6 +157,13 @@
450 """Produce instance-id attribute."""
451 return make_text_response(node.system_id)
452
453+ def public_keys(self, node, version, item):
454+ """ Produce public-keys attribute."""
455+ keys = SSHKey.objects.get_keys_for_user(user=node.owner)
456+ if not keys:
457+ raise MAASAPINotFound("No registered public keys")
458+ return make_list_response(keys)
459+
460
461 class UserDataHandler(MetadataViewHandler):
462 """User-data blob for a given version."""
463
464=== modified file 'src/metadataserver/tests/test_api.py'
465--- src/metadataserver/tests/test_api.py 2012-03-21 21:40:28 +0000
466+++ src/metadataserver/tests/test_api.py 2012-03-26 21:06:20 +0000
467@@ -13,6 +13,7 @@
468
469 from collections import namedtuple
470 import httplib
471+from textwrap import dedent
472
473 from maasserver.exceptions import Unauthorized
474 from maasserver.testing.factory import factory
475@@ -135,7 +136,10 @@
476 self.assertIn('user-data', items)
477
478 def test_meta_data_view_lists_fields(self):
479- client = self.make_node_client()
480+ # Some fields only are returned if there is data related to them.
481+ user = factory.make_user_with_keys(n_keys=2, username='my-user')
482+ node = factory.make_node(owner=user)
483+ client = self.make_node_client(node=node)
484 response = self.get('/latest/meta-data/', client)
485 self.assertIn('text/plain', response['Content-Type'])
486 self.assertItemsEqual(
487@@ -189,3 +193,33 @@
488 def test_user_data_for_node_without_user_data_returns_not_found(self):
489 response = self.get('/latest/user-data', self.make_node_client())
490 self.assertEqual(httplib.NOT_FOUND, response.status_code)
491+
492+ def test_public_keys_not_listed_for_node_without_public_keys(self):
493+ response = self.get('/latest/meta-data/', self.make_node_client())
494+ self.assertNotIn(
495+ 'public-keys', response.content.decode('ascii').split('\n'))
496+
497+ def test_public_keys_listed_for_node_with_public_keys(self):
498+ user = factory.make_user_with_keys(n_keys=2, username='my-user')
499+ node = factory.make_node(owner=user)
500+ response = self.get(
501+ '/latest/meta-data/', self.make_node_client(node=node))
502+ self.assertIn(
503+ 'public-keys', response.content.decode('ascii').split('\n'))
504+
505+ def test_public_keys_for_node_without_public_keys_returns_not_found(self):
506+ response = self.get(
507+ '/latest/meta-data/public-keys', self.make_node_client())
508+ self.assertEqual(httplib.NOT_FOUND, response.status_code)
509+
510+ def test_public_keys_for_node_returns_list_of_keys(self):
511+ user = factory.make_user_with_keys(n_keys=2, username='my-user')
512+ node = factory.make_node(owner=user)
513+ response = self.get(
514+ '/latest/meta-data/public-keys', self.make_node_client(node=node))
515+ self.assertEqual(httplib.OK, response.status_code)
516+ self.assertEquals(dedent("""\
517+ ssh-rsa KEY my-user-key-0
518+ ssh-rsa KEY my-user-key-1"""),
519+ response.content.decode('ascii'))
520+ self.assertIn('text/plain', response['Content-Type'])