Merge lp:~flacoste/maas/meta-publickeys into lp:~maas-committers/maas/trunk
- meta-publickeys
- Merge into 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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jeroen T. Vermeulen (community) | Approve | ||
Review via email:
|
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
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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']) |
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' tests/test_ models. py 2012-03-22 15:10:57 +0000 tests/test_ models. py 2012-03-24 01:25:23 +0000
259 --- src/maasserver/
260 +++ src/maasserver/
269 @@ -490,6 +491,26 @@ (set(SYSTEM_ USERS). isdisjoint( usernames) ) TestCase) : keys_for_ user_no_ keys(self) : objects. get_keys_ for_user( user) ls([], list(keys))
270 self.assertTrue
271
272
273 +class SSHKeyManager(
274 + """Testing for the :class `SSHKeyManager` model manager."""
275 +
276 + def test_get_
277 + user = factory.make_user()
278 + keys = SSHKey.
279 + self.assertEqua
There's a specialized assertion method that obviates the listification:
self. assertItemsEqua l([], 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) : make_user_ with_keys( n_keys= 3, username='user1') make_user_ with_keys( n_keys= 2) objects. get_keys_ for_user( user1)
282 + user1 = factory.
283 + # user2
284 + factory.
285 + keys = SSHKey.
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. assertSequenceE qual([… ], 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/metadatase rver/api. py' ver/api. py 2012-03-21 05:56:36 +0000 ver/api. py 2012-03-24 01:25:23 +0000
383 --- src/metadataser
384 +++ src/metadataser
425 @@ -142,6 +150,13 @@ response( node.system_ id) objects. get_keys_ for_user( user=node. owner) response( keys)
426 """Produce instance-id attribute."""
427 return make_text_
428
429 + def public_keys(self, node, version, item):
430 + """ Produce public-keys attribute."""
431 + keys = SSHKey.
432 + if not keys:
433 + raise Http404
434 + return make_list_
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...