Merge lp:~ahasenack/landscape-charm/create-account-first-admin into lp:~landscape/landscape-charm/trunk

Proposed by Andreas Hasenack
Status: Merged
Approved by: Andreas Hasenack
Approved revision: 181
Merged at revision: 207
Proposed branch: lp:~ahasenack/landscape-charm/create-account-first-admin
Merge into: lp:~landscape/landscape-charm/trunk
Diff against target: 542 lines (+406/-24)
5 files modified
config.yaml (+21/-4)
config/landscape-deployments.yaml (+4/-0)
hooks/hooks.py (+54/-18)
hooks/lib/util.py (+61/-0)
hooks/test_hooks.py (+266/-2)
To merge this branch: bzr merge lp:~ahasenack/landscape-charm/create-account-first-admin
Reviewer Review Type Date Requested Status
Björn Tillenius (community) Approve
Adam Collard (community) Abstain
Chad Smith Approve
Review via email: mp+232601@code.launchpad.net

Commit message

New charm config options for the creation of the first landscape administrator.

Description of the change

Three new config options that allow for the creation of the first landscape administrator. They can also be set after a deployment is done, but unless the database is empty, it's a noop.

I used ./schema instead of the BootstrapLDS API call because this call is not available in LDS 13.09, which is our currently released version.

I kept with the tradition of the existing code and used mocker a lot in the tests. I didn't like to have to use the juju log messages to determine why a method exited. I was tempted to set specific return values, or raise exceptions, but they would only be used by the tests, so I didn't do it.

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) wrote :

Needs Fixing because of the horrible bit twiddling. Would otherwise be Needs Information as to why we use ./schema and not the API.

review: Needs Fixing
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Bits are so nice! Machine language! :)

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

I used ./schema because 13.09, the currently released LDS, does not have that new api call.

Revision history for this message
Adam Collard (adam-collard) wrote :

> I used ./schema because 13.09, the currently released LDS, does not have that
> new api call.

Ah ok. Fair enough

Revision history for this message
Chad Smith (chad.smith) wrote :

Initial set of comments. Looks good so far. I want to try a deploy first and then I'll approve. I'm going to look over those unit tests again, it seems like it's sort of painful how we set it up. Not sure if we care about tackling that in this branch but it's something we need to refine at some point.

Revision history for this message
Chad Smith (chad.smith) wrote :

Looks good. works fine.

One thing we might want to consider at some point, since you are using schema directly. We aren't doing any validation on email address format specified in sample/sample.py. So it might be good to safeguard that configuration value with an email format validation so we can throw a config hook error exit(1) if that value is not correct so we don't configure the DB with an invalid email. Just a thought

review: Approve
Revision history for this message
Adam Collard (adam-collard) wrote :

Sorry for blocking this for so long, I don't have time to review it properly, so Abstaining.

review: Abstain
Revision history for this message
Björn Tillenius (bjornt) wrote :

I think this can be merged. I have some minor comments, but nothing blocking, some are just suggestions, and I'm not expecting them to be fixed here.

I also don't like the mocking in the tests, but I think it's quite a lot of work getting rid of it.

review: Approve
179. By Andreas Hasenack

Encode admin details at the last possible moment.

180. By Andreas Hasenack

Simplify test_first_admin_not_created_without_name_email_password() a bit

181. By Andreas Hasenack

create_landscape_admin() and _create_first_admin() now return True or
False.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'config.yaml'
2--- config.yaml 2014-04-24 20:25:07 +0000
3+++ config.yaml 2014-10-01 20:17:48 +0000
4@@ -50,7 +50,24 @@
5 schema (instead of just validating that it is correct). This
6 should only typically be enabled as part of a landscape maintenance
7 cycle.
8-
9-
10-
11-
12+ admin-name:
13+ description: |
14+ Name of the account administrator. If this, admin-email and
15+ admin-password are specified, then the standalone account will be
16+ created as part of the deployment, and this person will be its
17+ administrator. If not specified, Landscape will prompt for these
18+ details when it is first accessed with a web browser. Has no
19+ effect if changed after a deployment.
20+ type: string
21+ admin-email:
22+ description: |
23+ Email of the account administrator. To have any effect, needs to
24+ specified in conjunction with admin-name and admin-password. Has
25+ no effect if changed after a deployment.
26+ type: string
27+ admin-password:
28+ description: |
29+ Password of the account administrator. To have any effect, needs
30+ to be specified in conjunction with admin-name and admin-email.
31+ Has no effect if changed after a deployment.
32+ type: string
33
34=== modified file 'config/landscape-deployments.yaml'
35--- config/landscape-deployments.yaml 2014-09-15 20:26:59 +0000
36+++ config/landscape-deployments.yaml 2014-10-01 20:17:48 +0000
37@@ -39,6 +39,10 @@
38 services: static appserver pingserver combo-loader async-frontend apiserver package-upload jobhandler package-search cron juju-sync
39 repository: include-file://repo-file
40 license-file: include-file://license-file
41+ # Optionally create the first admin:
42+ #admin-name: Stan Peters
43+ #admin-email: stan@example.com
44+ #admin-password: pwd
45 landscape-msg:
46 branch: lp:~landscape/landscape-charm/stable
47 charm: landscape
48
49=== modified file 'hooks/hooks.py'
50--- hooks/hooks.py 2014-07-09 21:11:25 +0000
51+++ hooks/hooks.py 2014-10-01 20:17:48 +0000
52@@ -193,6 +193,52 @@
53 juju.juju_log(str(e), level="DEBUG")
54
55
56+def _get_db_access_details():
57+ """
58+ Returns the main database access details as they are set in the landscape
59+ service configuration file.
60+ """
61+ config_obj = _get_config_obj(LANDSCAPE_SERVICE_CONF)
62+ try:
63+ section = config_obj["stores"]
64+ database = section["main"]
65+ db_host = section["host"]
66+ db_user = section["user"]
67+ db_password = section["password"]
68+ except KeyError:
69+ return None
70+ return (database, db_host, db_user, db_password)
71+
72+
73+def _create_first_admin():
74+ """
75+ If so requested by the presence of the right configuration keys,
76+ tries to create the landscape first administrator and, as a consequence,
77+ the standalone account too.
78+ """
79+ first_admin_email = juju.config_get("admin-email")
80+ first_admin_name = juju.config_get("admin-name")
81+ first_admin_password = juju.config_get("admin-password")
82+ if not all((first_admin_email, first_admin_name, first_admin_password)):
83+ juju.juju_log("Not creating a Landscape administrator: need "
84+ "admin-email, admin-name and admin-password.")
85+ return False
86+ juju.juju_log("First admin creation requested")
87+ access_details = _get_db_access_details()
88+ if not access_details:
89+ juju.juju_log("No DB configuration yet, bailing.")
90+ return False
91+ database, db_host, db_user, db_password = access_details
92+ if util.is_db_up(database, db_host, db_user, db_password):
93+ with closing(util.connect_exclusive(db_host, db_user, db_password)):
94+ return util.create_landscape_admin(
95+ db_user, db_password, db_host, first_admin_name,
96+ first_admin_email, first_admin_password)
97+ else:
98+ juju.juju_log("Can't talk to the DB yet, bailing.")
99+ return False
100+
101+
102 def amqp_relation_joined():
103 juju.relation_set("username=landscape")
104 juju.relation_set("vhost=landscape")
105@@ -350,16 +396,11 @@
106
107 notify_vhost_config_relation(os.environ.get("JUJU_RELATION_ID", None))
108
109- config_obj = _get_config_obj(LANDSCAPE_SERVICE_CONF)
110- try:
111- section = config_obj["stores"]
112- database = section["main"]
113- host = section["host"]
114- user = section["user"]
115- password = section["password"]
116- except KeyError:
117+ access_details = _get_db_access_details()
118+ if not access_details:
119 juju.juju_log("Database not ready yet, deferring call")
120 sys.exit(0)
121+ database, host, user, password = access_details
122
123 relids = juju.relation_ids("vhost-config")
124 if relids:
125@@ -413,6 +454,7 @@
126 _set_maintenance()
127 _enable_services()
128 _set_upgrade_schema()
129+ _create_first_admin()
130
131 if _is_db_up() and _is_amqp_up():
132 _lsctl("start")
133@@ -726,17 +768,11 @@
134 """
135 Return True if the database is accessible and read/write, False otherwise.
136 """
137- config_obj = _get_config_obj(LANDSCAPE_SERVICE_CONF)
138- try:
139- section = config_obj["stores"]
140- database = section["main"]
141- host = section["host"]
142- user = section["user"]
143- password = section["password"]
144- except KeyError:
145+ access_details = _get_db_access_details()
146+ if not access_details:
147 return False
148- else:
149- return util.is_db_up(database, host, user, password)
150+ database, host, user, password = access_details
151+ return util.is_db_up(database, host, user, password)
152
153
154 ERROR_PATH = "/opt/canonical/landscape/canonical/landscape/static/offline/"
155
156=== modified file 'hooks/lib/util.py'
157--- hooks/lib/util.py 2014-06-14 03:49:47 +0000
158+++ hooks/lib/util.py 2014-10-01 20:17:48 +0000
159@@ -5,10 +5,26 @@
160 from psycopg2 import connect, Error as psycopg2Error
161 from juju import Juju
162 from contextlib import closing
163+from subprocess import check_output
164+
165+import re
166+import os
167
168 juju = Juju()
169
170
171+def is_email_valid(email):
172+ """
173+ Returns true if the given email is safe to use and has no "funny"
174+ characters. We don't go overboard and look for an RFC compliant email
175+ here.
176+
177+ @param email: string containing the email to be validated
178+ """
179+ valid_email_re = r"^[\w.+-]+@[\w-]+\.[\w.]+$"
180+ return re.search(valid_email_re, email) is not None
181+
182+
183 def connect_exclusive(host, admin_user, admin_password):
184 """
185 database user creation and setup-landscape-server need to be done in a
186@@ -49,6 +65,51 @@
187 conn.close()
188
189
190+def account_is_empty(db_user, db_password, db_host):
191+ """
192+ Returns true if the person and account tables from the
193+ landscape-standalone-main database are empty.
194+ """
195+ with closing(connect(database="landscape-standalone-main", host=db_host,
196+ user=db_user, password=db_password)) as conn:
197+ cur = conn.cursor()
198+ cur.execute("SELECT COUNT(person.id),COUNT(account.id) FROM "
199+ "person,account")
200+ result = cur.fetchall()[0]
201+ return int(result[0]) == 0 and int(result[1]) == 0
202+
203+
204+def create_landscape_admin(db_user, db_password, db_host, admin_name,
205+ admin_email, admin_password):
206+ """
207+ Create the first Landscape administrator with the given credentials.
208+ Returns True if the administrator was created, False otherwise.
209+ """
210+ if account_is_empty(db_user, db_password, db_host):
211+ if not is_email_valid(admin_email):
212+ raise ValueError("Invalid administrator email %s" % admin_email)
213+ juju.juju_log("Creating first administrator")
214+ env = os.environ.copy()
215+ env["LANDSCAPE_CONFIG"] = "standalone"
216+ admin_name = admin_name.encode("utf-8")
217+ admin_email = admin_email.encode("utf-8")
218+ admin_password = admin_password.encode("utf-8")
219+ cmd = ["./schema", "--create-lds-account-only", "--admin-name",
220+ admin_name, "--admin-email", admin_email,
221+ "--admin-password", admin_password]
222+ # Throw stdout away, bceause when the call works, stdout will have API
223+ # credentials, which we don't want in the juju logs. When the call
224+ # fails, however, we want stderr because it will say why it failed, so
225+ # let the exception be raised and stderr go through as usual.
226+ check_output(cmd, cwd="/opt/canonical/landscape", env=env)
227+ juju.juju_log("Administrator called %s with email %s created" %
228+ (admin_name, admin_email))
229+ return True
230+ else:
231+ juju.juju_log("DB not empty, skipping first admin creation")
232+ return False
233+
234+
235 def change_root_url(database, user, password, host, url):
236 """Change the root url in the database."""
237 url = "u%s:%s" % (len(url), url)
238
239=== modified file 'hooks/test_hooks.py'
240--- hooks/test_hooks.py 2014-07-08 19:21:33 +0000
241+++ hooks/test_hooks.py 2014-10-01 20:17:48 +0000
242@@ -1,5 +1,6 @@
243+from configobj import ConfigObj
244+from itertools import product
245 import base64
246-from configobj import ConfigObj
247 import hooks
248 import mocker
249 import os
250@@ -8,6 +9,7 @@
251 import stat
252 import subprocess
253 import tempfile
254+import unittest
255 import yaml
256
257
258@@ -28,7 +30,10 @@
259 "license-file": "LICENSE_FILE_TEXT",
260 "service-count": "msgserver:2 pingserver:1",
261 "upgrade-schema": False,
262- "maintenance": False}
263+ "maintenance": False,
264+ "admin-name": None,
265+ "admin-email": None,
266+ "admin-password": None}
267
268 def relation_set(self, *args, **kwargs):
269 """
270@@ -92,6 +97,7 @@
271 def setUp(self):
272 hooks._lsctl = lambda x: True
273 hooks.juju = TestJuju()
274+ hooks.util.juju = hooks.juju
275 hooks.LANDSCAPE_LICENSE_DEST = self.makeFile()
276 hooks.LANDSCAPE_DEFAULT_FILE = self.makeFile()
277 self._default_file = open(hooks.LANDSCAPE_DEFAULT_FILE, "w")
278@@ -137,6 +143,264 @@
279
280 class TestHooksService(TestHooks):
281
282+ def test_first_admin_not_created_without_name_email_password(self):
283+ """
284+ The first admin is not created when only one or two of name, email and
285+ password are given.
286+ """
287+ admins = [("Foo Bar", "foo@example.com", None),
288+ ("Foo Bar", None, "secret"),
289+ (None, "foo@example.com", "secret")]
290+ for name, email, password in admins:
291+ hooks.juju.config["admin-name"] = name
292+ hooks.juju.config["admin-email"] = email
293+ hooks.juju.config["admin-password"] = password
294+ self.assertFalse(hooks._create_first_admin())
295+ hooks.juju._logs = ()
296+
297+ def test_first_admin_not_created_if_no_db_config(self):
298+ """
299+ The first administrator is not created if there is no database
300+ configuration in the service.conf file.
301+ """
302+ messages = ("First admin creation requested",
303+ "No DB configuration yet, bailing.")
304+ hooks.juju.config["admin-name"] = "Foo Bar"
305+ hooks.juju.config["admin-email"] = "foo@example.com"
306+ hooks.juju.config["admin-password"] = "secret"
307+ config_obj = ConfigObj(hooks.LANDSCAPE_SERVICE_CONF)
308+ config_obj["stores"] = {}
309+ config_obj.write()
310+ self.assertFalse(hooks._create_first_admin())
311+ self.assertEqual(messages, hooks.juju._logs)
312+
313+ def test_email_syntax_check(self):
314+ """
315+ Invalid email addresses are flagged as such.
316+ """
317+ # some invalid choices
318+ emails = ["invalidemail", "invalid@", "a@b", "a@b.",
319+ "`cat /etc/password`@example.com", "#foobar@example.com",
320+ "(foo)@example.com", "foo@(example).com",
321+ "'foo@example.com", "\"foo@example.com"]
322+ for email in emails:
323+ self.assertIs(False, hooks.util.is_email_valid(email))
324+
325+ def test_create_landscape_admin_checks_email_syntax(self):
326+ """
327+ The util.create_landscape_admin() method verifies if the email is
328+ valid before attempting to create the admin.
329+ """
330+ db_user = "user"
331+ db_password = "password"
332+ db_host = "example.com"
333+ admin_name = "Foo Bar"
334+ admin_email = "foo'@bar"
335+ admin_password = "secret"
336+
337+ account_is_empty = self.mocker.replace(hooks.util.account_is_empty)
338+ account_is_empty(db_user, db_password, db_host)
339+ self.mocker.result(True)
340+ self.mocker.replay()
341+ with unittest.TestCase.assertRaises(self, ValueError) as invalid_email:
342+ hooks.util.create_landscape_admin(db_user, db_password, db_host,
343+ admin_name, admin_email, admin_password)
344+ self.assertEqual("Invalid administrator email %s" % admin_email,
345+ invalid_email.exception.message)
346+
347+ def test_first_admin_not_created_if_account_not_empty(self):
348+ """
349+ The first administrator is not created if the account is not
350+ empty.
351+ """
352+ db_user = "user"
353+ db_password = "password"
354+ db_host = "example.com"
355+ admin_name = "Foo Bar"
356+ admin_email = "foo@example.com"
357+ admin_password = "secret"
358+ message = "DB not empty, skipping first admin creation"
359+
360+ account_is_empty = self.mocker.replace(hooks.util.account_is_empty)
361+ account_is_empty(db_user, db_password, db_host)
362+ self.mocker.result(False)
363+ self.mocker.replay()
364+ admin_created = hooks.util.create_landscape_admin(
365+ db_user, db_password, db_host, admin_name, admin_email,
366+ admin_password)
367+ self.assertFalse(admin_created)
368+ self.assertEqual((message,), hooks.juju._logs)
369+
370+ def test__create_first_admin_calls_create_landscape_admin(self):
371+ """
372+ When all conditions are met, _create_first_admin() calls
373+ create_landscape_admin.
374+ """
375+ # juju log message we expect
376+ message = "First admin creation requested"
377+
378+ # we have an admin user defined
379+ admin_name = "Foo Bar"
380+ admin_email = "foo@example.com"
381+ admin_password = "secret"
382+ hooks.juju.config["admin-name"] = admin_name
383+ hooks.juju.config["admin-email"] = admin_email
384+ hooks.juju.config["admin-password"] = admin_password
385+
386+ # we have the database access details
387+ config_obj = ConfigObj(hooks.LANDSCAPE_SERVICE_CONF)
388+ database = "mydb"
389+ db_host = "myhost"
390+ db_user = "myuser"
391+ db_password = "mypassword"
392+ stores = {"main": database, "host": db_host, "user": db_user,
393+ "password": db_password}
394+ config_obj["stores"] = stores
395+ config_obj.write()
396+
397+ # the db is up
398+ is_db_up = self.mocker.replace(hooks.util.is_db_up)
399+ is_db_up(database, db_host, db_user, db_password)
400+ self.mocker.result(True)
401+
402+ # we can connect
403+ connect_exclusive = self.mocker.replace(hooks.util.connect_exclusive)
404+ connect_exclusive(db_host, db_user, db_password)
405+ connection = self.mocker.mock()
406+ self.mocker.result(connection)
407+
408+ # util.create_landscape_admin is called
409+ create_landscape_admin = self.mocker.replace(
410+ hooks.util.create_landscape_admin)
411+ create_landscape_admin(
412+ db_user, db_password, db_host, admin_name, admin_email,
413+ admin_password)
414+ connection.close()
415+ self.mocker.replay()
416+
417+ hooks._create_first_admin()
418+ self.assertEqual((message,), hooks.juju._logs)
419+
420+ def test__create_first_admin_bails_if_db_is_not_up(self):
421+ """
422+ The _create_first_admin() method gives up if the DB is not
423+ accessible.
424+ """
425+ # juju log messages we expect
426+ messages = ("First admin creation requested",
427+ "Can't talk to the DB yet, bailing.")
428+ # we have an admin user defined
429+ admin_name = "Foo Bar"
430+ admin_email = "foo@example.com"
431+ admin_password = "secret"
432+ hooks.juju.config["admin-name"] = admin_name
433+ hooks.juju.config["admin-email"] = admin_email
434+ hooks.juju.config["admin-password"] = admin_password
435+
436+ # we have the database access details
437+ config_obj = ConfigObj(hooks.LANDSCAPE_SERVICE_CONF)
438+ database = "mydb"
439+ db_host = "myhost"
440+ db_user = "myuser"
441+ db_password = "mypassword"
442+ stores = {"main": database, "host": db_host, "user": db_user,
443+ "password": db_password}
444+ config_obj["stores"] = stores
445+ config_obj.write()
446+
447+ # the db is down, though
448+ is_db_up = self.mocker.replace(hooks.util.is_db_up)
449+ is_db_up(database, db_host, db_user, db_password)
450+ self.mocker.result(False)
451+
452+ self.mocker.replay()
453+ admin_created = hooks._create_first_admin()
454+ self.assertFalse(admin_created)
455+ self.assertEqual(messages, hooks.juju._logs)
456+
457+ def test__get_db_access_details(self):
458+ """
459+ The _get_db_access_details() function returns the database name and
460+ access details when all these keys exist in the landscape service
461+ configuration file.
462+ """
463+ config_obj = ConfigObj(hooks.LANDSCAPE_SERVICE_CONF)
464+ stores = {"main": "mydb", "host": "myhost", "user": "myuser",
465+ "password": "mypassword"}
466+ config_obj["stores"] = stores
467+ config_obj.write()
468+
469+ result = hooks._get_db_access_details()
470+ database, db_host, db_user, db_password = result
471+ self.assertEqual(database, stores["main"])
472+ self.assertEqual(db_host, stores["host"])
473+ self.assertEqual(db_user, stores["user"])
474+ self.assertEqual(db_password, stores["password"])
475+
476+ def test_no_db_access_details_if_missing_config_key(self):
477+ """
478+ The _get_db_access_details() function returns None if any of
479+ the needed configuration keys is missing.
480+ """
481+ full_config = {"main": "mydb", "host": "myhost", "user": "myuser",
482+ "password": "mypassword"}
483+ config_obj = ConfigObj(hooks.LANDSCAPE_SERVICE_CONF)
484+ # remove one key each time
485+ for key in full_config:
486+ data = full_config.copy()
487+ data.pop(key)
488+ config_obj["stores"] = data
489+ config_obj.write()
490+ result = hooks._get_db_access_details()
491+ self.assertIsNone(result)
492+ # last try, with no data at all
493+ config_obj.clear()
494+ config_obj.write()
495+ self.assertIsNone(hooks._get_db_access_details())
496+
497+ def test_create_landscape_admin_calls_schema_script(self):
498+ """
499+ The create_landscape_admin() method calls the landscape schema
500+ script with the right parameters.
501+ """
502+ # we have an admin user defined
503+ admin_name = "Foo Bar"
504+ admin_email = "foo@example.com"
505+ admin_password = "secret"
506+
507+ # juju log messages we expect
508+ messages = ("Creating first administrator",
509+ "Administrator called %s with email %s created" %
510+ (admin_name, admin_email))
511+
512+ # we have the database access details
513+ db_host = "myhost"
514+ db_user = "myuser"
515+ db_password = "mypassword"
516+
517+ # account is empty
518+ account_is_empty = self.mocker.replace(hooks.util.account_is_empty)
519+ account_is_empty(db_user, db_password, db_host)
520+ self.mocker.result(True)
521+
522+ # schema script is called with the right parameters
523+ self.addCleanup(setattr, hooks.util.os, "environ",
524+ hooks.util.os.environ)
525+ hooks.util.os.environ = {}
526+ env = {"LANDSCAPE_CONFIG": "standalone"}
527+ schema_call = self.mocker.replace(hooks.util.check_output)
528+ cmd = ["./schema", "--create-lds-account-only", "--admin-name",
529+ admin_name, "--admin-email", admin_email, "--admin-password",
530+ admin_password]
531+ schema_call(cmd, cwd="/opt/canonical/landscape", env=env)
532+
533+ self.mocker.replay()
534+
535+ hooks.util.create_landscape_admin(
536+ db_user, db_password, db_host, admin_name, admin_email,
537+ admin_password)
538+ self.assertEqual(messages, hooks.juju._logs)
539+
540 def test_get_services_non_proxied(self):
541 """
542 helper method should not break if non-proxied services are called for

Subscribers

People subscribed via source and target branches