Merge lp:~allenap/maas/shared-to-per-tenant-storage 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: 1449
Proposed branch: lp:~allenap/maas/shared-to-per-tenant-storage
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 632 lines (+570/-6)
9 files modified
src/maasserver/models/user.py (+1/-0)
src/maasserver/support/pertenant/migration.py (+179/-0)
src/maasserver/support/pertenant/tests/test_migration.py (+384/-0)
src/maasserver/tests/data/test_rsa0.pub (+1/-1)
src/maasserver/tests/data/test_rsa1.pub (+1/-1)
src/maasserver/tests/data/test_rsa2.pub (+1/-1)
src/maasserver/tests/data/test_rsa3.pub (+1/-1)
src/maasserver/tests/data/test_rsa4.pub (+1/-1)
src/maasserver/tests/test_sshkey.py (+1/-1)
To merge this branch: bzr merge lp:~allenap/maas/shared-to-per-tenant-storage
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+151858@code.launchpad.net

Commit message

Add the mechanism for migrating shared-namespace file storage usage over to the per-tenant model.

There is a carefully constructed 4-step process that's intended to minimise disruption. Ultimately some eggs do need to be broken, but this branch catches their contents and tries damn hard to bake a tasty cake with it.

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

This is a work in progress for the shared to per-tenant namespace migration. It might actually be complete, but it's 1am here and I can't tell any more.

Revision history for this message
Raphaël Badin (rvb) wrote :

Great work!

[0]

I'd suggest adding a test in TestMigrate to make sure the files change ownership properly when there is a valid 'provider-state' file (and a related node with an owner). I know you're sort of testing this at a lower level in 'TestFunctions' but since we suspect this is going to be one of the most used code paths, I'd add that test anyway.

[1]

Can you please reference the "steps" you're testing in 'TestMigrate' the same way you did it in "def migrate():"?

more to come…

Revision history for this message
Raphaël Badin (rvb) wrote :

[2]

+# -*- coding: utf-8 -*-

I don't see why this is required…?
(fwiw, the only file with that are the auto-generated migration files)

PS: I suspect this is just because of → … s/→/->/ ?

[3]

76 + try:
77 + legacy_user = User.objects.get(username=legacy_user_name)

If a migrations runs and we are in a position to run that code (i.e. more than one user, no 'provider-state' file), if it happens that there is a *real* user with that username, it won't do the right thing. I reckon this is pretty improbable but maybe we should at least raise and exception in that case. What do you think?

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

> [2]
>
> +# -*- coding: utf-8 -*-
>
> I don't see why this is required…?
> (fwiw, the only file with that are the auto-generated migration files)
>
> PS: I suspect this is just because of → … s/→/->/ ?

Yes, that's exactly why. I'll remove it for the benefit of
encoding-challenged text editors :)

>
> [3]
>
> 76 + try:
> 77 + legacy_user = User.objects.get(username=legacy_user_name)
>
> If a migrations runs and we are in a position to run that code
> (i.e. more than one user, no 'provider-state' file), if it happens
> that there is a *real* user with that username, it won't do the
> right thing. I reckon this is pretty improbable but maybe we should
> at least raise and exception in that case. What do you think?

I want the code to be safe to run multiple times, so I think it's
important to be able to pick up an existing legacy user. However, I'll
make the legacy user similar to the other system users, in that it
won't have a UserProfile. Then I can check that here and raise an
exception if it appears to be a regular user with the same name.

Sound okay?

I

Revision history for this message
Raphaël Badin (rvb) wrote :

[4]

149 +def give_credentials_to_user(user_from, user_dest):
150 + """Gives one user's credentials to another.

'credentials' is a bit vague don't you think? How about 'API credentials'?

[5]

57 +from maasserver.models import (
58 + FileStorage,
59 + Node,
60 + SSHKey,
61 + User,
62 + )

There is no 'User' class in maasserver.models, it's simply an alias for django.contrib.auth.models.User. I think it's clearer to import the class from Django.

[6]

106 +def get_owned_nodes_owners():
107 + """Returns a `QuerySet` of the owners of nodes owned by real users."""
108 + owner_ids = get_owned_nodes().values_list("owner", flat=True)
109 + return User.objects.filter(id__in=owner_ids)

Adding '.distinct()' to the query on line 108 might speed things up a bit if there is lots of nodes belonging to just a few users.

Revision history for this message
Raphaël Badin (rvb) wrote :

> > If a migrations runs and we are in a position to run that code
> > (i.e. more than one user, no 'provider-state' file), if it happens
> > that there is a *real* user with that username, it won't do the
> > right thing. I reckon this is pretty improbable but maybe we should
> > at least raise and exception in that case. What do you think?
>
> I want the code to be safe to run multiple times, so I think it's
> important to be able to pick up an existing legacy user. However, I'll
> make the legacy user similar to the other system users, in that it
> won't have a UserProfile. Then I can check that here and raise an
> exception if it appears to be a regular user with the same name.
>
> Sound okay?

Sounds good to me. Thanks!

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

> [0]
>
> I'd suggest adding a test in TestMigrate to make sure the files change
> ownership properly when there is a valid 'provider-state' file (and a related
> node with an owner). I know you're sort of testing this at a lower level in
> 'TestFunctions' but since we suspect this is going to be one of the most used
> code paths, I'd add that test anyway.

Good idea, done.

>
> [1]
>
> Can you please reference the "steps" you're testing in 'TestMigrate' the same
> way you did it in "def migrate():"?

Sure, done.

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

> [4]
>
> 149     +def give_credentials_to_user(user_from, user_dest):
> 150     +    """Gives one user's credentials to another.
>
> 'credentials' is a bit vague don't you think?  How about 'API credentials'?

I've renamed the function and updated the docstring.

> [5]
>
> 57      +from maasserver.models import (
> 58      +    FileStorage,
> 59      +    Node,
> 60      +    SSHKey,
> 61      +    User,
> 62      +    )
>
> There is no 'User' class in maasserver.models, it's simply an alias for
> django.contrib.auth.models.User.  I think it's clearer to import the class
> from Django.

Done.

> [6]
>
> 106     +def get_owned_nodes_owners():
> 107     +    """Returns a `QuerySet` of the owners of nodes owned by real
> users."""
> 108     +    owner_ids = get_owned_nodes().values_list("owner", flat=True)
> 109     +    return User.objects.filter(id__in=owner_ids)
>
> Adding '.distinct()' to the query on line 108 might speed things up a bit if
> there is lots of nodes belonging to just a few users.

Done.

Revision history for this message
Raphaël Badin (rvb) wrote :

[7]

433 + def test_give(self):
434 + user1 = factory.make_user()
435 + user2 = factory.make_user()
436 + profile = user1.get_profile()
437 + consumer, token = profile.create_authorisation_token()
438 + give_credentials_to_user(user1, user2)
439 + self.assertEqual(user2, reload_object(consumer).user)
440 + self.assertEqual(user2, reload_object(token).user)
441 +
442 + def test_consumer_and_token_saved(self):
443 + user1 = factory.make_user()
444 + user2 = factory.make_user()
445 + profile = user1.get_profile()
446 + consumer, token = profile.create_authorisation_token()
447 + self.patch(consumer, "save")
448 + self.patch(token, "save")
449 + give_credentials_to_user(user1, user2)
450 + consumer.save.assert_called_once()
451 + token.save.assert_called_once()

I don't really see what the second test adds… since you're reloading the objects in the first test and you're getting what you expect, you can be sure the objects have been saved… ?

[8]

456 + def test_give(self):
457 + user1 = factory.make_user()
458 + user2 = factory.make_user()
459 + node = factory.make_node(owner=user1)
460 + give_node_to_user(node, user2)
461 + self.assertEqual(user2, node.owner)
462 +
463 + def test_node_saved(self):
464 + user1 = factory.make_user()
465 + user2 = factory.make_user()
466 + node = factory.make_node(owner=user1)
467 + save = self.patch(node, "save")
468 + give_node_to_user(node, user2)
469 + save.assert_called_once()

Similar remark here. Although I'd refactor the first test to reload the object before checking the owner, and I'd get rid of the second test.

Revision history for this message
Raphaël Badin (rvb) wrote :

[9]

580 + self.assertEqual(
581 + (legacy_user, legacy_user, legacy_user, legacy_user),

"(legacy_user, legacy_user, legacy_user, legacy_user)" => "[legacy_user] * 4" ?

I think it's more readable but this is really a detail.

[10]

558 + self.assertEqual(user1, reload_object(node1).owner)
559 + self.assertEqual(user1, reload_object(node2).owner)

Probably better to make that one check.

[11]

472 +class TestMigrateToUser(TestCase):
473 +
474 + def test_migrate(self):

That test is a bit… huge :) . Don't you think it could test for behavior instead of patching methods… and maybe share some code with TestMigrate.test_migrate_ancillary_data_to_legacy_user_when_multiple_users ?

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

> ... I'll make the legacy user similar to the other system users, in
> that it won't have a UserProfile. Then I can check that here and
> raise an exception if it appears to be a regular user with the same
> name.

Actually, I'm not 100% sure this is a good idea. The legacy user needs
to be the same as a regular user. It's not unreasonable for someone to
set its password and log-in to the web UI, at which point the lack of
a UserProfile will cause problems, I think.

I'm inclined to either (a) not check or (b) check only that the user's
full name is "Shared Environment".

Revision history for this message
Raphaël Badin (rvb) wrote :

> > ... I'll make the legacy user similar to the other system users, in
> > that it won't have a UserProfile. Then I can check that here and
> > raise an exception if it appears to be a regular user with the same
> > name.
>
> Actually, I'm not 100% sure this is a good idea. The legacy user needs
> to be the same as a regular user. It's not unreasonable for someone to
> set its password and log-in to the web UI, at which point the lack of
> a UserProfile will cause problems, I think.

Indeed, you're right, I lost track of that. (I think we need a profile to log in the UI so a user probably won't be able to log in, but as you pointed out, this is a problem.)

>
> I'm inclined to either (a) not check or (b) check only that the user's
> full name is "Shared Environment".

b) does not feel very precise :/

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

> [7]
...
> I don't really see what the second test adds… since you're reloading the
> objects in the first test and you're getting what you expect, you can be sure
> the objects have been saved… ?

Good point. I was adding belts and braces where dungarees were being
worn :)

> [8]
...
> Similar remark here. Although I'd refactor the first test to reload the
> object before checking the owner, and I'd get rid of the second test.

Done.

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

> [9]
>
> 580     +        self.assertEqual(
> 581     +            (legacy_user, legacy_user, legacy_user, legacy_user),
>
> "(legacy_user, legacy_user, legacy_user, legacy_user)" => "[legacy_user] * 4"
> ?
>
> I think it's more readable but this is really a detail.

We'll agree to differ on this one :)

> [10]
>
> 558     +        self.assertEqual(user1, reload_object(node1).owner)
> 559     +        self.assertEqual(user1, reload_object(node2).owner)
>
> Probably better to make that one check.

Done.

>
> [11]
>
> 472     +class TestMigrateToUser(TestCase):
> 473     +
> 474     +    def test_migrate(self):
>
> That test is a bit… huge :) .  Don't you think it could test for behavior
> instead of patching methods… and maybe share some code with
> TestMigrate.test_migrate_ancillary_data_to_legacy_user_when_multiple_users ?

The behaviour of the individual components is tested elsewhere, and
the overall behaviour is tested in TestMigrate, so I sought only to
show that things were wired up correctly. In a way it's also a
reminder not to put more logic into migrate_to_user(); it should be
factored into separate functions.

It was a bit of a what-if experiment too. I've added comments to the
function to explain its rationale, but I'm open to more discussion.
It's interesting to consider the value of this kind of test; perhaps
not very much.

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

> > I'm inclined to either (a) not check or (b) check only that the user's
> > full name is "Shared Environment".
>
> b) does not feel very precise :/

I agree, but it's a small problem in the scale of things here. I say
we just stick with what we've got, i.e. no checks. If someone already
has a user called shared-environment then I'm inclined to think that
it's the user they'd want us to choose anyway!

Revision history for this message
Raphaël Badin (rvb) wrote :

> I agree, but it's a small problem in the scale of things here. I say
> we just stick with what we've got, i.e. no checks. If someone already
> has a user called shared-environment then I'm inclined to think that
> it's the user they'd want us to choose anyway!

Okay… the consequences of picking up the wrong user are not devastating anyway, plus it's very unlikely that someone would have created a user precisely with that name.

Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good, thanks for all your work on this branch!

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

A fallout from renaming the ssh keys:

======================================================================
FAIL: maasserver.tests.test_sshkey.SSHKeyTest.test_sshkey_display_with_real_life_key
----------------------------------------------------------------------
_StringException: Traceback (most recent call last):
  File "/home/rvb/canonical/shared-to-per-tenant-storage/src/maasserver/tests/test_sshkey.py", line 229, in test_sshkey_display_with_real_life_key
    'ssh-rsa AAAAB3NzaC1yc2E… ubuntu@server-7476', display)
MismatchError: !=:
reference = u'ssh-rsa AAAAB3NzaC1yc2E… ubuntu@server-7476'
actual = u'ssh-rsa AAAAB3NzaC1yc… ubuntu@test_rsa0.pub'

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

> Looks good, thanks for all your work on this branch!

Thank you for all your help :)

> A fallout from renaming the ssh keys:

Thanks for pointing that out. Fixed.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/models/user.py'
2--- src/maasserver/models/user.py 2012-08-13 04:47:10 +0000
3+++ src/maasserver/models/user.py 2013-03-06 15:28:20 +0000
4@@ -15,6 +15,7 @@
5 'create_user',
6 'get_auth_tokens',
7 'get_creds_tuple',
8+ 'SYSTEM_USERS',
9 ]
10
11 from maasserver import worker_user
12
13=== added file 'src/maasserver/support/pertenant/migration.py'
14--- src/maasserver/support/pertenant/migration.py 1970-01-01 00:00:00 +0000
15+++ src/maasserver/support/pertenant/migration.py 2013-03-06 15:28:20 +0000
16@@ -0,0 +1,179 @@
17+# Copyright 2012 Canonical Ltd. This software is licensed under the
18+# GNU Affero General Public License version 3 (see the file LICENSE).
19+
20+"""Shared namespace --> per-tenant namespace migration.
21+
22+Perform the following steps to migrate:
23+
24+1. When no files exist (i.e. no Juju environments exist): do nothing
25+1a. When no *unowned* files exist: do nothing.
26+
27+2. When there's only one user: assign ownership of all files to user.
28+
29+3. When there are multiple users and a `provider-state` file: parse that file
30+ to extract the instance id of the bootstrap node. From that instance id,
31+ get the identity of the user who deployed this environment (that's the
32+ owner of the bootstrap node). Then proceed as in 4, using that user as the
33+ "legacy" user.
34+
35+4. When there are multiple users: create a new "legacy" user, assign ownership
36+ of all files and allocated/owned nodes to this user, copy all public SSH
37+ keys to this user, and move all API credentials to this user.
38+
39+There's not a lot we can do about SSH keys authorised to connect to the
40+already deployed nodes in #3, but this set will only ever decrease: nodes
41+allocated after this migration will permit access from any of the users with
42+SSH keys prior to the migration.
43+"""
44+
45+from __future__ import (
46+ absolute_import,
47+ print_function,
48+ unicode_literals,
49+ )
50+
51+__metaclass__ = type
52+__all__ = [
53+ "migrate",
54+ ]
55+
56+from django.contrib.auth.models import User
57+from maasserver.models import (
58+ FileStorage,
59+ Node,
60+ SSHKey,
61+ )
62+from maasserver.models.user import (
63+ get_auth_tokens,
64+ SYSTEM_USERS,
65+ )
66+from maasserver.support.pertenant.utils import get_bootstrap_node_owner
67+from maasserver.utils.orm import get_one
68+
69+
70+legacy_user_name = "shared-environment"
71+
72+
73+def get_legacy_user():
74+ """Return the legacy namespace user, creating it if need be."""
75+ try:
76+ legacy_user = User.objects.get(username=legacy_user_name)
77+ except User.DoesNotExist:
78+ # Create the legacy user with a local, probably non-working, email
79+ # address, and an unusable password.
80+ legacy_user = User.objects.create_user(
81+ email="%s@localhost" % legacy_user_name,
82+ username=legacy_user_name)
83+ legacy_user.first_name = "Shared"
84+ legacy_user.last_name = "Environment"
85+ legacy_user.is_active = True
86+ return legacy_user
87+
88+
89+def get_unowned_files():
90+ """Returns a `QuerySet` of unowned files."""
91+ return FileStorage.objects.filter(owner=None)
92+
93+
94+def get_real_users():
95+ """Returns a `QuerySet` of real. not system, users."""
96+ users = User.objects.exclude(username__in=SYSTEM_USERS)
97+ users = users.exclude(username=legacy_user_name)
98+ return users
99+
100+
101+def get_owned_nodes():
102+ """Returns a `QuerySet` of nodes owned by real users."""
103+ return Node.objects.filter(owner__in=get_real_users())
104+
105+
106+def get_owned_nodes_owners():
107+ """Returns a `QuerySet` of the owners of nodes owned by real users."""
108+ owner_ids = get_owned_nodes().values_list("owner", flat=True)
109+ return User.objects.filter(id__in=owner_ids.distinct())
110+
111+
112+def get_destination_user():
113+ """Return the user to which resources should be assigned."""
114+ real_users = get_real_users()
115+ if real_users.count() == 1:
116+ return get_one(real_users)
117+ else:
118+ bootstrap_user = get_bootstrap_node_owner()
119+ if bootstrap_user is None:
120+ return get_legacy_user()
121+ else:
122+ return bootstrap_user
123+
124+
125+def get_ssh_keys(user):
126+ """Return the SSH key strings belonging to the specified user."""
127+ return SSHKey.objects.filter(user=user).values_list("key", flat=True)
128+
129+
130+def copy_ssh_keys(user_from, user_dest):
131+ """Copies SSH keys from one user to another.
132+
133+ This is idempotent, and does not clobber the destination user's existing
134+ keys.
135+ """
136+ user_from_keys = get_ssh_keys(user_from)
137+ user_dest_keys = get_ssh_keys(user_dest)
138+ for key in set(user_from_keys).difference(user_dest_keys):
139+ ssh_key = SSHKey(user=user_dest, key=key)
140+ ssh_key.save()
141+
142+
143+def give_file_to_user(file, user):
144+ """Give a file to a user."""
145+ file.owner = user
146+ file.save()
147+
148+
149+def give_api_credentials_to_user(user_from, user_dest):
150+ """Gives one user's API credentials to another.
151+
152+ This ensures that users of the shared namespace environment continue to
153+ operate within the legacy shared namespace environment by default via the
154+ API (e.g. maas-cli and Juju).
155+ """
156+ for token in get_auth_tokens(user_from):
157+ consumer = token.consumer
158+ consumer.user = user_dest
159+ consumer.save()
160+ token.user = user_dest
161+ token.save()
162+
163+
164+def give_node_to_user(node, user):
165+ """Changes a node's ownership for the legacy shared environment."""
166+ node.owner = user
167+ node.save()
168+
169+
170+def migrate_to_user(user):
171+ """Migrate files and nodes to the specified user.
172+
173+ This also copies, to the destination user, the public SSH keys of any
174+ owned nodes' owners. This is so that those users who had allocated nodes
175+ (i.e. active users of a shared-namespace environment) can access newly
176+ created nodes in the legacy shared-namespace environment.
177+ """
178+ for unowned_file in get_unowned_files():
179+ give_file_to_user(unowned_file, user)
180+ for node_owner in get_owned_nodes_owners():
181+ copy_ssh_keys(node_owner, user)
182+ give_api_credentials_to_user(node_owner, user)
183+ for owned_node in get_owned_nodes():
184+ give_node_to_user(owned_node, user)
185+
186+
187+def migrate():
188+ """Migrate files to a per-tenant namespace."""
189+ if get_unowned_files().exists():
190+ # 2, 3, and 4
191+ user = get_destination_user()
192+ migrate_to_user(user)
193+ else:
194+ # 1 and 1a
195+ pass
196
197=== added file 'src/maasserver/support/pertenant/tests/test_migration.py'
198--- src/maasserver/support/pertenant/tests/test_migration.py 1970-01-01 00:00:00 +0000
199+++ src/maasserver/support/pertenant/tests/test_migration.py 2013-03-06 15:28:20 +0000
200@@ -0,0 +1,384 @@
201+# Copyright 2012 Canonical Ltd. This software is licensed under the
202+# GNU Affero General Public License version 3 (see the file LICENSE).
203+
204+"""Test `maasserver.support.pertenant.migration."""
205+
206+from __future__ import (
207+ absolute_import,
208+ print_function,
209+ unicode_literals,
210+ )
211+
212+__metaclass__ = type
213+__all__ = []
214+
215+from django.contrib.auth.models import User
216+from maasserver.models import (
217+ Node,
218+ SSHKey,
219+ )
220+from maasserver.support.pertenant import migration
221+from maasserver.support.pertenant.migration import (
222+ copy_ssh_keys,
223+ get_destination_user,
224+ get_legacy_user,
225+ get_owned_nodes,
226+ get_owned_nodes_owners,
227+ get_real_users,
228+ get_ssh_keys,
229+ get_unowned_files,
230+ give_api_credentials_to_user,
231+ give_file_to_user,
232+ give_node_to_user,
233+ legacy_user_name,
234+ migrate,
235+ migrate_to_user,
236+ )
237+from maasserver.support.pertenant.tests.test_utils import (
238+ make_provider_state_file,
239+ )
240+from maasserver.testing import (
241+ get_data,
242+ reload_object,
243+ )
244+from maasserver.testing.factory import factory
245+from maasserver.testing.testcase import TestCase
246+from mock import (
247+ call,
248+ sentinel,
249+ )
250+from testtools.matchers import MatchesStructure
251+
252+
253+def get_ssh_key_string(num=0):
254+ return get_data('data/test_rsa%d.pub' % num)
255+
256+
257+class TestFunctions(TestCase):
258+
259+ def find_legacy_user(self):
260+ return User.objects.filter(username=legacy_user_name)
261+
262+ def test_get_legacy_user_creates_user(self):
263+ self.assertEqual([], list(self.find_legacy_user()))
264+ legacy_user = get_legacy_user()
265+ self.assertEqual([legacy_user], list(self.find_legacy_user()))
266+ self.assertThat(
267+ legacy_user, MatchesStructure.byEquality(
268+ first_name="Shared", last_name="Environment",
269+ email=legacy_user_name + "@localhost", is_active=True))
270+
271+ def test_get_legacy_user_creates_user_only_once(self):
272+ legacy_user1 = get_legacy_user()
273+ self.assertEqual([legacy_user1], list(self.find_legacy_user()))
274+ legacy_user2 = get_legacy_user()
275+ self.assertEqual([legacy_user2], list(self.find_legacy_user()))
276+ self.assertEqual(legacy_user1, legacy_user2)
277+
278+ def test_get_unowned_files_no_files(self):
279+ self.assertEqual([], list(get_unowned_files()))
280+
281+ def test_get_unowned_files(self):
282+ user = factory.make_user()
283+ files = [
284+ factory.make_file_storage(owner=None),
285+ factory.make_file_storage(owner=user),
286+ factory.make_file_storage(owner=None),
287+ ]
288+ self.assertSetEqual(
289+ {files[0], files[2]},
290+ set(get_unowned_files()))
291+
292+ def test_get_real_users_no_users(self):
293+ get_legacy_user() # Ensure at least the legacy user exists.
294+ self.assertEqual([], list(get_real_users()))
295+
296+ def test_get_real_users(self):
297+ get_legacy_user() # Ensure at least the legacy user exists.
298+ users = [
299+ factory.make_user(),
300+ factory.make_user(),
301+ ]
302+ self.assertSetEqual(set(users), set(get_real_users()))
303+
304+ def test_get_owned_nodes_no_nodes(self):
305+ self.assertEqual([], list(get_owned_nodes()))
306+
307+ def test_get_owned_nodes_no_owned_nodes(self):
308+ factory.make_node()
309+ self.assertEqual([], list(get_owned_nodes()))
310+
311+ def test_get_owned_nodes_with_owned_nodes(self):
312+ nodes = {
313+ factory.make_node(owner=factory.make_user()),
314+ factory.make_node(owner=factory.make_user()),
315+ }
316+ self.assertSetEqual(nodes, set(get_owned_nodes()))
317+
318+ def test_get_owned_nodes_with_nodes_owned_by_system_users(self):
319+ factory.make_node(owner=get_legacy_user()),
320+ self.assertEqual([], list(get_owned_nodes()))
321+
322+ def test_get_owned_nodes_owners_no_users(self):
323+ self.assertEqual([], list(get_owned_nodes_owners()))
324+
325+ def test_get_owned_nodes_owners_no_nodes(self):
326+ factory.make_user()
327+ self.assertEqual([], list(get_owned_nodes_owners()))
328+
329+ def test_get_owned_nodes_owners_no_owned_nodes(self):
330+ factory.make_user()
331+ factory.make_node(owner=None)
332+ self.assertEqual([], list(get_owned_nodes_owners()))
333+
334+ def test_get_owned_nodes_owners(self):
335+ user1 = factory.make_user()
336+ user2 = factory.make_user()
337+ factory.make_user()
338+ factory.make_node(owner=user1)
339+ factory.make_node(owner=user2)
340+ factory.make_node(owner=None)
341+ self.assertSetEqual({user1, user2}, set(get_owned_nodes_owners()))
342+
343+ def test_get_destination_user_one_real_user(self):
344+ user = factory.make_user()
345+ self.assertEqual(user, get_destination_user())
346+
347+ def test_get_destination_user_two_real_users(self):
348+ factory.make_user()
349+ factory.make_user()
350+ self.assertEqual(get_legacy_user(), get_destination_user())
351+
352+ def test_get_destination_user_no_real_users(self):
353+ self.assertEqual(get_legacy_user(), get_destination_user())
354+
355+ def test_get_destination_user_with_user_from_juju_state(self):
356+ user1, user2 = factory.make_user(), factory.make_user()
357+ node = factory.make_node(owner=user1)
358+ make_provider_state_file(node)
359+ self.assertEqual(user1, get_destination_user())
360+
361+ def test_get_destination_user_with_orphaned_juju_state(self):
362+ user1, user2 = factory.make_user(), factory.make_user()
363+ node = factory.make_node(owner=user1)
364+ make_provider_state_file(node)
365+ node.delete() # Orphan the state.
366+ self.assertEqual(get_legacy_user(), get_destination_user())
367+
368+
369+class TestCopySSHKeys(TestCase):
370+ """Tests for copy_ssh_keys()."""
371+
372+ def test_noop_when_there_are_no_keys(self):
373+ user1 = factory.make_user()
374+ user2 = factory.make_user()
375+ copy_ssh_keys(user1, user2)
376+ ssh_keys = SSHKey.objects.filter(user__in={user1, user2})
377+ self.assertEqual([], list(ssh_keys))
378+
379+ def test_copy(self):
380+ user1 = factory.make_user()
381+ key1 = factory.make_sshkey(user1)
382+ user2 = factory.make_user()
383+ copy_ssh_keys(user1, user2)
384+ user2s_ssh_keys = SSHKey.objects.filter(user=user2)
385+ self.assertSetEqual(
386+ {key1.key}, {ssh_key.key for ssh_key in user2s_ssh_keys})
387+
388+ def test_copy_is_idempotent(self):
389+ # When the destination user already has a key, copy_ssh_keys() is a
390+ # noop for that key.
391+ user1 = factory.make_user()
392+ key1 = factory.make_sshkey(user1)
393+ user2 = factory.make_user()
394+ key2 = factory.make_sshkey(user2, key1.key)
395+ copy_ssh_keys(user1, user2)
396+ user2s_ssh_keys = SSHKey.objects.filter(user=user2)
397+ self.assertSetEqual(
398+ {key2.key}, {ssh_key.key for ssh_key in user2s_ssh_keys})
399+
400+ def test_copy_does_not_clobber(self):
401+ # When the destination user already has some keys, copy_ssh_keys()
402+ # adds to them; it does not remove them.
403+ user1 = factory.make_user()
404+ key1 = factory.make_sshkey(user1, get_ssh_key_string(1))
405+ user2 = factory.make_user()
406+ key2 = factory.make_sshkey(user2, get_ssh_key_string(2))
407+ copy_ssh_keys(user1, user2)
408+ user2s_ssh_keys = SSHKey.objects.filter(user=user2)
409+ self.assertSetEqual(
410+ {key1.key, key2.key},
411+ {ssh_key.key for ssh_key in user2s_ssh_keys})
412+
413+
414+class TestGiveFileToUser(TestCase):
415+
416+ def test_give_unowned_file(self):
417+ user = factory.make_user()
418+ file = factory.make_file_storage(owner=None)
419+ give_file_to_user(file, user)
420+ self.assertEqual(user, file.owner)
421+
422+ def test_give_owned_file(self):
423+ user1 = factory.make_user()
424+ user2 = factory.make_user()
425+ file = factory.make_file_storage(owner=user1)
426+ give_file_to_user(file, user2)
427+ self.assertEqual(user2, file.owner)
428+
429+ def test_file_saved(self):
430+ user = factory.make_user()
431+ file = factory.make_file_storage(owner=None)
432+ save = self.patch(file, "save")
433+ give_file_to_user(file, user)
434+ save.assert_called_once()
435+
436+
437+class TestGiveCredentialsToUser(TestCase):
438+
439+ def test_give(self):
440+ user1 = factory.make_user()
441+ user2 = factory.make_user()
442+ profile = user1.get_profile()
443+ consumer, token = profile.create_authorisation_token()
444+ give_api_credentials_to_user(user1, user2)
445+ self.assertEqual(user2, reload_object(consumer).user)
446+ self.assertEqual(user2, reload_object(token).user)
447+
448+
449+class TestGiveNodeToUser(TestCase):
450+
451+ def test_give(self):
452+ user1 = factory.make_user()
453+ user2 = factory.make_user()
454+ node = factory.make_node(owner=user1)
455+ give_node_to_user(node, user2)
456+ self.assertEqual(user2, reload_object(node).owner)
457+
458+
459+class TestMigrateToUser(TestCase):
460+
461+ def test_migrate(self):
462+ # This is a mechanical test, to demonstrate that migrate_to_user() is
463+ # wired up correctly: it should not really contain much logic because
464+ # it is meant only as a convenient wrapper around other functions.
465+ # Those functions are unit tested individually, and the overall
466+ # behaviour of migrate() is tested too; this is another layer of
467+ # verification. It's also a reminder not to stuff logic into
468+ # migrate_to_user(); extract it into functions instead and unit test
469+ # those.
470+
471+ # migrate_to_user() will give all unowned files to a specified user.
472+ get_unowned_files = self.patch(migration, "get_unowned_files")
473+ get_unowned_files.return_value = [sentinel.file1, sentinel.file2]
474+ give_file_to_user = self.patch(migration, "give_file_to_user")
475+ # migrate_to_user() will copy all SSH keys and give all API
476+ # credentials belonging to node owners over to a specified user.
477+ get_owned_nodes_owners = self.patch(
478+ migration, "get_owned_nodes_owners")
479+ get_owned_nodes_owners.return_value = [
480+ sentinel.node_owner1, sentinel.node_owner2]
481+ copy_ssh_keys = self.patch(migration, "copy_ssh_keys")
482+ give_api_credentials_to_user = self.patch(
483+ migration, "give_api_credentials_to_user")
484+ # migrate_to_user() will give all owned nodes to a specified user.
485+ get_owned_nodes = self.patch(migration, "get_owned_nodes")
486+ get_owned_nodes.return_value = [sentinel.node1, sentinel.node2]
487+ give_node_to_user = self.patch(migration, "give_node_to_user")
488+
489+ migrate_to_user(sentinel.user)
490+
491+ # Each unowned file is given to the destination user one at a time.
492+ get_unowned_files.assert_called_once()
493+ self.assertEqual(
494+ [call(sentinel.file1, sentinel.user),
495+ call(sentinel.file2, sentinel.user)],
496+ give_file_to_user.call_args_list)
497+ # The SSH keys of each node owner are copied to the destination user,
498+ # one at a time, and the credentials of these users are given to the
499+ # destination user.
500+ get_owned_nodes_owners.assert_called_once()
501+ self.assertEqual(
502+ [call(sentinel.node_owner1, sentinel.user),
503+ call(sentinel.node_owner2, sentinel.user)],
504+ copy_ssh_keys.call_args_list)
505+ self.assertEqual(
506+ [call(sentinel.node_owner1, sentinel.user),
507+ call(sentinel.node_owner2, sentinel.user)],
508+ give_api_credentials_to_user.call_args_list)
509+ # Each owned node is given to the destination user one at a time.
510+ get_owned_nodes.assert_called_once()
511+ self.assertEqual(
512+ [call(sentinel.node1, sentinel.user),
513+ call(sentinel.node2, sentinel.user)],
514+ give_node_to_user.call_args_list)
515+
516+
517+class TestMigrate(TestCase):
518+
519+ def test_migrate_runs_when_no_files_exist(self):
520+ migrate()
521+
522+ def test_migrate_runs_when_no_unowned_files_exist(self):
523+ factory.make_file_storage(owner=factory.make_user())
524+ migrate()
525+
526+ def test_migrate_all_files_to_single_user_when_only_one_user(self):
527+ user = factory.make_user()
528+ stored = factory.make_file_storage(owner=None)
529+ migrate()
530+ self.assertEqual(user, reload_object(stored).owner)
531+
532+ def test_migrate_all_files_to_new_legacy_user_when_multiple_users(self):
533+ stored = factory.make_file_storage(owner=None)
534+ user1 = factory.make_user()
535+ user2 = factory.make_user()
536+ migrate()
537+ stored = reload_object(stored)
538+ self.assertNotIn(stored.owner, {user1, user2, None})
539+
540+ def test_migrate_all_nodes_to_new_legacy_user_when_multiple_users(self):
541+ factory.make_file_storage(owner=None)
542+ user1 = factory.make_user()
543+ node1 = factory.make_node(owner=user1)
544+ user2 = factory.make_user()
545+ node2 = factory.make_node(owner=user2)
546+ migrate()
547+ self.assertNotIn(reload_object(node1).owner, {user1, user2, None})
548+ self.assertNotIn(reload_object(node2).owner, {user1, user2, None})
549+
550+ def test_migrate_all_nodes_to_bootstrap_owner_when_multiple_users(self):
551+ user1 = factory.make_user()
552+ node1 = factory.make_node(owner=user1)
553+ user2 = factory.make_user()
554+ node2 = factory.make_node(owner=user2)
555+ make_provider_state_file(node1)
556+ migrate()
557+ self.assertEqual(
558+ (user1, user1),
559+ (reload_object(node1).owner,
560+ reload_object(node2).owner))
561+
562+ def test_migrate_ancillary_data_to_legacy_user_when_multiple_users(self):
563+ factory.make_file_storage(owner=None)
564+ # Create two users, both with API credentials, an SSH key and a node.
565+ user1 = factory.make_user()
566+ consumer1, token1 = user1.get_profile().create_authorisation_token()
567+ key1 = factory.make_sshkey(user1, get_ssh_key_string(1))
568+ node1 = factory.make_node(owner=user1)
569+ user2 = factory.make_user()
570+ consumer2, token2 = user2.get_profile().create_authorisation_token()
571+ key2 = factory.make_sshkey(user2, get_ssh_key_string(2))
572+ node2 = factory.make_node(owner=user2)
573+ migrate()
574+ # The SSH keys have been copied to the legacy user.
575+ legacy_user = get_legacy_user()
576+ legacy_users_ssh_keys = get_ssh_keys(legacy_user)
577+ self.assertSetEqual({key1.key, key2.key}, set(legacy_users_ssh_keys))
578+ # The API credentials have been moved to the legacy user.
579+ legacy_users_nodes = Node.objects.filter(owner=legacy_user)
580+ self.assertSetEqual({node1, node2}, set(legacy_users_nodes))
581+ self.assertEqual(
582+ (legacy_user, legacy_user, legacy_user, legacy_user),
583+ (reload_object(consumer1).user, reload_object(token1).user,
584+ reload_object(consumer2).user, reload_object(token2).user))
585
586=== modified file 'src/maasserver/tests/data/test_rsa0.pub'
587--- src/maasserver/tests/data/test_rsa0.pub 2012-04-06 09:52:45 +0000
588+++ src/maasserver/tests/data/test_rsa0.pub 2013-03-06 15:28:20 +0000
589@@ -1,1 +1,1 @@
590-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDdrzzDZNwyMVBvBTT6kBnrfPZv/AUbkxj7G5CaMTdw6xkKthV22EntD3lxaQxRKzQTfCc2d/CC1K4ushCcRs1S6SQ2zJ2jDq1UmOUkDMgvNh4JVhJYSKc6mu8i3s7oGSmBado5wvtlpSzMrscOpf8Qe/wmT5fH12KB9ipJqoFNQMVbVcVarE/v6wpn3GZC62YRb5iaz9/M+t92Qhu50W2u+KfouqtKB2lwIDDKZMww38ExtdMouh2FZpxaoh4Uey5bRp3tM3JgnWcX6fyUOp2gxJRPIlD9rrZhX5IkEkZM8MQbdPTQLgIf98oFph5RG6w1t02BvI9nJKM7KkKEfBHt ubuntu@server-7476
591+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDdrzzDZNwyMVBvBTT6kBnrfPZv/AUbkxj7G5CaMTdw6xkKthV22EntD3lxaQxRKzQTfCc2d/CC1K4ushCcRs1S6SQ2zJ2jDq1UmOUkDMgvNh4JVhJYSKc6mu8i3s7oGSmBado5wvtlpSzMrscOpf8Qe/wmT5fH12KB9ipJqoFNQMVbVcVarE/v6wpn3GZC62YRb5iaz9/M+t92Qhu50W2u+KfouqtKB2lwIDDKZMww38ExtdMouh2FZpxaoh4Uey5bRp3tM3JgnWcX6fyUOp2gxJRPIlD9rrZhX5IkEkZM8MQbdPTQLgIf98oFph5RG6w1t02BvI9nJKM7KkKEfBHt ubuntu@test_rsa0.pub
592
593=== modified file 'src/maasserver/tests/data/test_rsa1.pub'
594--- src/maasserver/tests/data/test_rsa1.pub 2012-04-06 09:52:45 +0000
595+++ src/maasserver/tests/data/test_rsa1.pub 2013-03-06 15:28:20 +0000
596@@ -1,1 +1,1 @@
597-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC6Gkj1y8/0T7q/FqBSr9xRBO9GzT+JeoWNXaqhUBg179Zd53XM4qblVwz/rsMa70te8CYNIFU+GbcNY1tNCo78NlHjQA8H98COnbVWKxvABECHrJ8nbYB4lWH9wI8/uvR0um6yUb/tZYbiSqnQxhoGAF/uQQfhqzc+tc7uTjnsa6krrNqQCdpFbAVVy+vZzvcJl6CX8nu5uJ8jedWfXOZJFcQPH+VwkUT0oV+1zVeLpE4LFkRO52JrC9Dy1xgrYM0EhcrShBdD1GQx9IXdW4Z9PIaVcq/y4Qv574yHMvi+6hwG6xpCtRXmy0lG0LiG60c1yOredkO6U0MJIVbeZ/+r ubuntu@server-7493
598+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC6Gkj1y8/0T7q/FqBSr9xRBO9GzT+JeoWNXaqhUBg179Zd53XM4qblVwz/rsMa70te8CYNIFU+GbcNY1tNCo78NlHjQA8H98COnbVWKxvABECHrJ8nbYB4lWH9wI8/uvR0um6yUb/tZYbiSqnQxhoGAF/uQQfhqzc+tc7uTjnsa6krrNqQCdpFbAVVy+vZzvcJl6CX8nu5uJ8jedWfXOZJFcQPH+VwkUT0oV+1zVeLpE4LFkRO52JrC9Dy1xgrYM0EhcrShBdD1GQx9IXdW4Z9PIaVcq/y4Qv574yHMvi+6hwG6xpCtRXmy0lG0LiG60c1yOredkO6U0MJIVbeZ/+r ubuntu@test_rsa1.pub
599
600=== modified file 'src/maasserver/tests/data/test_rsa2.pub'
601--- src/maasserver/tests/data/test_rsa2.pub 2012-04-06 09:52:45 +0000
602+++ src/maasserver/tests/data/test_rsa2.pub 2013-03-06 15:28:20 +0000
603@@ -1,1 +1,1 @@
604-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKVdMk4Q+13uUvXjb6iU+oB2Auk0HpaILZ8Pw/V63PTJ+QXtEp0vTe6DEvr9uF2vl6tF+AosiG4krEwqBNGx/h8MmFO7BgNTxn9eU2VwfHzmQ2nqkXHsXgp66cNT0Yd0nfvVV/fsMpKN9fUaYrXjAlFxvC9iQ33Rp6vj/X+oqDvYf3xZjbuZy+BxdJnmiTAJcFouTyrdy1Em1EZITq5M4EXw93/O2vAPYSFPAeELBE+mIMJxOCY1Fm101oAqO0qof3Rb2hZxc2WINjmqZIxoi+sviU0ny/dIFknhYEg1Xh2hObPn0nN5+4VHjBTdRmpRXqggotc53sYC5udVmFsW8B ubuntu@server-7493
605+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKVdMk4Q+13uUvXjb6iU+oB2Auk0HpaILZ8Pw/V63PTJ+QXtEp0vTe6DEvr9uF2vl6tF+AosiG4krEwqBNGx/h8MmFO7BgNTxn9eU2VwfHzmQ2nqkXHsXgp66cNT0Yd0nfvVV/fsMpKN9fUaYrXjAlFxvC9iQ33Rp6vj/X+oqDvYf3xZjbuZy+BxdJnmiTAJcFouTyrdy1Em1EZITq5M4EXw93/O2vAPYSFPAeELBE+mIMJxOCY1Fm101oAqO0qof3Rb2hZxc2WINjmqZIxoi+sviU0ny/dIFknhYEg1Xh2hObPn0nN5+4VHjBTdRmpRXqggotc53sYC5udVmFsW8B ubuntu@test_rsa2.pub
606
607=== modified file 'src/maasserver/tests/data/test_rsa3.pub'
608--- src/maasserver/tests/data/test_rsa3.pub 2012-04-06 09:52:45 +0000
609+++ src/maasserver/tests/data/test_rsa3.pub 2013-03-06 15:28:20 +0000
610@@ -1,1 +1,1 @@
611-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDai2ir5yxckoYTHUbFL6pe01Kx+Dy6nw9p7LhFaBixUOh8G7eIgFBguYcir2ZKBfM/lbTnW+MSiGF2VMlXX0+X9Ux2iwPSJa2wIA7Cc5prCz/RnMRKQ+2S1JJuORoi8tDI0p1R0sGWMXCwaj30oRN0THWz884+d3YlDD/O39h74gnLNEx/TQig/r/Aev3VfeKO6dlbbX81vSad2JVncislyMq1TgJdhn2/JI8t+LW0xVc6ZgQr94YB2M2DNjFSisP2vDrV5LWM+IqiF8T/YHkcSsANr8WWvZWa79uHyRBU3xr2qZZqMjMVL0B/NOJYXyGBIJ7HQnlVLmqFenKl8ZtL ubuntu@server-7493
612+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDai2ir5yxckoYTHUbFL6pe01Kx+Dy6nw9p7LhFaBixUOh8G7eIgFBguYcir2ZKBfM/lbTnW+MSiGF2VMlXX0+X9Ux2iwPSJa2wIA7Cc5prCz/RnMRKQ+2S1JJuORoi8tDI0p1R0sGWMXCwaj30oRN0THWz884+d3YlDD/O39h74gnLNEx/TQig/r/Aev3VfeKO6dlbbX81vSad2JVncislyMq1TgJdhn2/JI8t+LW0xVc6ZgQr94YB2M2DNjFSisP2vDrV5LWM+IqiF8T/YHkcSsANr8WWvZWa79uHyRBU3xr2qZZqMjMVL0B/NOJYXyGBIJ7HQnlVLmqFenKl8ZtL ubuntu@test_rsa3.pub
613
614=== modified file 'src/maasserver/tests/data/test_rsa4.pub'
615--- src/maasserver/tests/data/test_rsa4.pub 2012-04-06 09:52:45 +0000
616+++ src/maasserver/tests/data/test_rsa4.pub 2013-03-06 15:28:20 +0000
617@@ -1,1 +1,1 @@
618-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNDA4vXVTxHuKikIXeA6/K/X7hKpJcOJV0HcXUHlSNa9phNW0f8vbci+BxcLAqIz/U+BPiQ9lCxz7so+qCTFrM4poOdkTyup8VUxUqntiaxgiCJZ1of+eMe39+S9XQk6RogiCpExanhD9xPLkK/mLr5phnQwDjEDJwD4OOF0rYsbYoqje/0Pd+Tm0PIepq/qwsu5PAKPJU8dfnp8BWLCuIJ+DA2lfRUjmxWwLczfM/4hu1bZlYp1mzJJgMIOY92/pUToYxvBiIiKs3qWh6HC5Vxo5Vz4w5WLnTnIPDvpYBvWj8LGXJwHuhqlzed2icwPk8krip2BzwsHotru3UXtKf ubuntu@server-7493
619+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNDA4vXVTxHuKikIXeA6/K/X7hKpJcOJV0HcXUHlSNa9phNW0f8vbci+BxcLAqIz/U+BPiQ9lCxz7so+qCTFrM4poOdkTyup8VUxUqntiaxgiCJZ1of+eMe39+S9XQk6RogiCpExanhD9xPLkK/mLr5phnQwDjEDJwD4OOF0rYsbYoqje/0Pd+Tm0PIepq/qwsu5PAKPJU8dfnp8BWLCuIJ+DA2lfRUjmxWwLczfM/4hu1bZlYp1mzJJgMIOY92/pUToYxvBiIiKs3qWh6HC5Vxo5Vz4w5WLnTnIPDvpYBvWj8LGXJwHuhqlzed2icwPk8krip2BzwsHotru3UXtKf ubuntu@test_rsa4.pub
620
621=== modified file 'src/maasserver/tests/test_sshkey.py'
622--- src/maasserver/tests/test_sshkey.py 2012-05-29 10:24:47 +0000
623+++ src/maasserver/tests/test_sshkey.py 2013-03-06 15:28:20 +0000
624@@ -226,7 +226,7 @@
625 key = SSHKey(key=key_string, user=user)
626 display = key.display_html()
627 self.assertEqual(
628- 'ssh-rsa AAAAB3NzaC1yc2E… ubuntu@server-7476', display)
629+ 'ssh-rsa AAAAB3NzaC1yc… ubuntu@test_rsa0.pub', display)
630
631 def test_sshkey_display_is_marked_as_HTML_safe(self):
632 key_string = get_data('data/test_rsa0.pub')