Merge lp:~ev/charms/xenial/cassandra/snap into lp:~cassandra-charmers/charms/xenial/cassandra/trunk

Proposed by Evan
Status: Merged
Merge reported by: Stuart Bishop
Merged at revision: not available
Proposed branch: lp:~ev/charms/xenial/cassandra/snap
Merge into: lp:~cassandra-charmers/charms/xenial/cassandra/trunk
Diff against target: 498 lines (+224/-31)
4 files modified
config.yaml (+4/-4)
hooks/actions.py (+10/-2)
hooks/helpers.py (+167/-24)
tests/test_helpers.py (+43/-1)
To merge this branch: bzr merge lp:~ev/charms/xenial/cassandra/snap
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Review via email: mp+297723@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

Comments inline.

Revision history for this message
Evan (ev) wrote :

Thanks for the thorough review! I've fixed the raised issues and mentioned the individual revnos inline.

Revision history for this message
Stuart Bishop (stub) wrote :

This is all looking good. A few comments inline, but nothing series except for the encrypt() method failing under trusty.

We will want integration tests, which starts with creating TestSnapDeployment(Test3UnitDeployment) in tests/test_integration.py. The tests and harness on this branch are Juju1 specific (I can sort Juju 2.0 mostly by syncing testing/amuletfixture.py to the PostgreSQL charm's version).

The integration tests at the moment will almost certainly fail for snap, as they 'juju run --unit ... nodetool ...' so we either need the charm to create symlinks in /usr/local/bin so the standard tool names work, or update the callsites in the test suite (just 3 or 4 I see). Ideally the snap would use the standard tool names like 'nodetool' rather than 'cassandra.nodetool', but from what I can tell that would mean maintaining a separate snap for each cli tool?

Revision history for this message
Evan (ev) :
lp:~ev/charms/xenial/cassandra/snap updated
390. By Evan

Add bug reference.

391. By Evan

Correct default for edition.

392. By Evan

Prefer platform.dist to host.lsb_release

393. By Evan

Do not try to decode from a string

394. By Evan

Fix tests; reset log mocks between calls.

395. By Evan

Revert move from lsb_release to platform: https://bugs.python.org/issue1322#msg263313

Revision history for this message
Evan (ev) :
Revision history for this message
Stuart Bishop (stub) wrote :

I think this is past the point where it can land. I'm running tests.

review: Approve
Revision history for this message
Stuart Bishop (stub) wrote :

I needed to make some more changes, which at at lp:~stub/charms/trusty/cassandra/xenial. The charm is now multi-series, and I'm pushing it to a new home at https://launchpad.net/cassandra-charm ( git+ssh://git.launchpad.net/cassandra-charm ). Further work should land on the git branch (let me know if you have much outstanding work that can't be easily moved in).

There are no integration tests of the snap install yet. Which is fine, as there is no documentation for the new feature either :-) We should fix both of these issues on the git branch.

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 2016-02-26 16:47:03 +0000
3+++ config.yaml 2016-06-29 15:55:10 +0000
4@@ -74,16 +74,16 @@
5 type: string
6 default: community
7 description: >
8- One of 'community' or 'dse'. 'community' uses the
9- Apache Cassandra packages. 'dse' is for DataStax
10- Enterprise. Selecting 'dse' overrides the jvm setting.
11+ One of 'community', 'dse', or 'apache-snap'. 'community' uses the
12+ Apache Cassandra packages. 'dse' is for DataStax Enterprise.
13+ Selecting 'dse' overrides the jvm setting. 'apache-snap' uses a
14+ snap package of Apache Cassandra.
15 jre:
16 type: string
17 default: openjdk
18 description: >
19 Which Java runtime environment to use. May be 'openjdk' or
20 'oracle'.
21-
22 # Storage configuration
23 wait_for_storage_broker:
24 type: boolean
25
26=== modified file 'hooks/actions.py'
27--- hooks/actions.py 2016-06-09 10:59:24 +0000
28+++ hooks/actions.py 2016-06-29 15:55:10 +0000
29@@ -277,7 +277,9 @@
30 @action
31 def install_cassandra_packages():
32 helpers.install_packages(helpers.get_cassandra_packages())
33- if helpers.get_jre() != 'oracle':
34+ if helpers.get_cassandra_edition() == 'apache-snap':
35+ helpers.install_cassandra_snap()
36+ elif helpers.get_jre() != 'oracle':
37 subprocess.check_call(['update-java-alternatives',
38 '--jre-headless',
39 '--set', 'java-1.8.0-openjdk-amd64'])
40@@ -286,6 +288,7 @@
41 @action
42 def ensure_cassandra_package_status():
43 helpers.ensure_package_status(helpers.get_cassandra_packages())
44+ helpers.ensure_cassandra_snap_installed()
45
46
47 def _fetch_oracle_jre():
48@@ -361,6 +364,8 @@
49 def install_oracle_jre():
50 if helpers.get_jre() != 'oracle':
51 return
52+ if helpers.get_cassandra_edition() == 'apache-snap':
53+ return
54
55 tarball = _fetch_oracle_jre()
56 _install_oracle_jre_tarball(tarball)
57@@ -368,6 +373,9 @@
58
59 @action
60 def emit_java_version():
61+ if helpers.get_cassandra_edition() == 'apache-snap':
62+ return
63+
64 # Log the version for posterity. Could be useful since Oracle JRE
65 # security updates are not automated.
66 version = subprocess.check_output(['java', '-version'],
67@@ -426,7 +434,7 @@
68 rack={}
69 ''').format(datacenter, rack)
70 rackdc_path = helpers.get_cassandra_rackdc_file()
71- host.write_file(rackdc_path, rackdc_properties.encode('UTF-8'))
72+ helpers.write_config(rackdc_path, rackdc_properties)
73
74
75 def needs_reset_auth_keyspace_replication():
76
77=== modified file 'hooks/helpers.py'
78--- hooks/helpers.py 2016-06-09 10:59:24 +0000
79+++ hooks/helpers.py 2016-06-29 15:55:10 +0000
80@@ -125,6 +125,46 @@
81 dpkg.communicate(input=''.join(selections).encode('US-ASCII'))
82
83
84+# FOR CHARMHELPERS
85+@logged
86+def install_cassandra_snap():
87+ '''Ensure that the Cassandra snap is installed at the current revision.
88+ '''
89+ cmd = ['snap', 'list', 'cassandra']
90+ output = subprocess.check_output(cmd, universal_newlines=True)
91+ if 'cassandra' not in output:
92+ with autostart_disabled(['snap.cassandra.cassandra']):
93+ subprocess.check_call(['snap', 'install', 'cassandra'])
94+ else:
95+ # snap will exit 1 if the snap is already the newest version.
96+ # Filed as http://pad.lv/1595064
97+ subprocess.call(['snap', 'refresh', 'cassandra'])
98+
99+ plug = 'cassandra:mount-observe'
100+ slot = 'ubuntu-core:mount-observe'
101+ subprocess.check_call(['snap', 'connect', plug, slot])
102+
103+
104+# FOR CHARMHELPERS
105+@logged
106+def ensure_cassandra_snap_installed():
107+ '''Check if the cassandra snap is installed and raise RuntimeError if it is
108+ not.
109+ '''
110+ cmd = ['snap', 'list', 'cassandra']
111+ output = subprocess.check_output(cmd, universal_newlines=True)
112+ if 'cassandra' not in output:
113+ raise RuntimeError('Cassandra snap not installed.')
114+
115+
116+def get_snap_env(envar):
117+ '''Return the value of an environment variable present in the snap wrapper
118+ scripts.
119+ '''
120+ cmd = ['/snap/bin/cassandra.env-get', envar]
121+ return subprocess.check_output(cmd, universal_newlines=True).strip('\n')
122+
123+
124 def get_seed_ips():
125 '''Return the set of seed ip addresses.
126
127@@ -149,7 +189,9 @@
128 '''
129 import relations
130 storage = relations.StorageRelation()
131- if storage.mountpoint:
132+ if get_cassandra_edition() == 'apache-snap':
133+ root = get_snap_env('SNAP_DATA')
134+ elif storage.mountpoint:
135 root = os.path.join(storage.mountpoint, 'cassandra')
136 else:
137 root = '/var/lib/cassandra'
138@@ -169,10 +211,13 @@
139 component = os.sep
140 for p in absdir.split(os.sep)[1:-1]:
141 component = os.path.join(component, p)
142- if not os.path.exists(p):
143+ if not os.path.exists(component):
144 host.mkdir(component)
145 assert component == os.path.split(absdir)[0]
146- host.mkdir(absdir, owner='cassandra', group='cassandra', perms=0o750)
147+ if get_cassandra_edition() == 'apache-snap':
148+ host.mkdir(absdir, owner='root', group='root', perms=0o750)
149+ else:
150+ host.mkdir(absdir, owner='cassandra', group='cassandra', perms=0o750)
151 return absdir
152
153
154@@ -277,6 +322,15 @@
155 host.write_file(backup_path, f.read(), perms=0o600)
156
157
158+def get_snap_version(snap):
159+ '''Get the version string for an installed snap.'''
160+ cmd = ['snap', 'list', snap]
161+ out = subprocess.check_output(cmd, universal_newlines=True)
162+ match = re.search('\n{}\s*(\S*)'.format(snap), out)
163+ if match:
164+ return match.groups(0)[0]
165+
166+
167 # FOR CHARMHELPERS
168 def get_package_version(package):
169 cache = fetch.apt_cache()
170@@ -305,22 +359,34 @@
171 def get_cassandra_edition():
172 config = hookenv.config()
173 edition = config['edition'].lower()
174- if edition not in ('community', 'dse'):
175+ if edition not in ('community', 'dse', 'apache-snap'):
176 hookenv.log('Unknown edition {!r}. Using community.'.format(edition),
177 ERROR)
178 edition = 'community'
179+ release = host.lsb_release()['DISTRIB_CODENAME']
180+ if edition == 'apache-snap' and release in ['precise', 'trusty']:
181+ msg = '{!r} cannot be used with {!r}. Using community.'
182+ msg = msg.format(release, edition)
183+ hookenv.log(msg, ERROR)
184+ edition = 'community'
185+
186 return edition
187
188
189 def get_cassandra_service():
190 '''Cassandra upstart service'''
191- if get_cassandra_edition() == 'dse':
192+ if get_cassandra_edition() == 'apache-snap':
193+ return 'snap.cassandra.cassandra'
194+ elif get_cassandra_edition() == 'dse':
195 return 'dse'
196 return 'cassandra'
197
198
199 def get_cassandra_version():
200- if get_cassandra_edition() == 'dse':
201+ edition = get_cassandra_edition()
202+ if edition == 'apache-snap':
203+ return get_snap_version('cassandra')
204+ elif edition == 'dse':
205 dse_ver = get_package_version('dse-full')
206 if not dse_ver:
207 return None
208@@ -338,12 +404,37 @@
209
210
211 def get_cassandra_config_dir():
212- if get_cassandra_edition() == 'dse':
213+ edition = get_cassandra_edition()
214+ if edition == 'apache-snap':
215+ return get_snap_env('CASSANDRA_CONF')
216+ elif edition == 'dse':
217 return '/etc/dse/cassandra'
218 else:
219 return '/etc/cassandra'
220
221
222+def get_snap_config_file(filename):
223+ '''Get the contents of the named configuration file from the current snap
224+ data directory.
225+ '''
226+ cmd = ['/snap/bin/cassandra.config-get', filename]
227+ return subprocess.check_output(cmd, universal_newlines=True)
228+
229+
230+def set_snap_config_file(filename, contents):
231+ '''Install a new copy of the configuration file with the provided contents
232+ in the current snap data directory.
233+ '''
234+ cmd = ['/snap/bin/cassandra.config-set', filename]
235+ cs = subprocess.Popen(cmd, stdin=subprocess.PIPE, universal_newlines=True)
236+ _, err = cs.communicate(input=contents)
237+ if err:
238+ hookenv.log('Error calling {}:\n{}'.format(' '.join(cmd), err))
239+ if cs.returncode != 0:
240+ msg = '{} exited with code {}'.format(' '.join(cmd), cs.returncode)
241+ raise RuntimeError(msg)
242+
243+
244 def get_cassandra_yaml_file():
245 return os.path.join(get_cassandra_config_dir(), "cassandra.yaml")
246
247@@ -359,7 +450,10 @@
248
249 def get_cassandra_pid_file():
250 edition = get_cassandra_edition()
251- if edition == 'dse':
252+ if edition == 'apache-snap':
253+ home = get_snap_env('CASSANDRA_HOME')
254+ pid_file = os.path.join(home, 'cassandra.pid')
255+ elif edition == 'dse':
256 pid_file = "/var/run/dse/dse.pid"
257 else:
258 pid_file = "/var/run/cassandra/cassandra.pid"
259@@ -367,16 +461,22 @@
260
261
262 def get_cassandra_packages():
263- edition = get_cassandra_edition()
264- if edition == 'dse':
265- packages = set(['dse-full'])
266- else:
267- packages = set(['cassandra']) # 'cassandra-tools'
268+ packages = set()
269
270 packages.add('ntp')
271 packages.add('run-one')
272 packages.add('netcat')
273
274+ edition = get_cassandra_edition()
275+ if edition == 'apache-snap':
276+ packages.add('snapd')
277+ return packages
278+
279+ if edition == 'dse':
280+ packages.add('dse-full')
281+ else:
282+ packages.add('cassandra') # 'cassandra-tools'
283+
284 jre = get_jre()
285 if jre == 'oracle':
286 # We can't use a packaged version of the Oracle JRE, as we
287@@ -464,6 +564,7 @@
288 # Guard against changing perms on a running db. Although probably
289 # harmless, it causes shutil.chown() to fail.
290 assert not is_cassandra_running()
291+
292 db_dirs = get_all_database_directories()
293 ensure_database_directory(db_dirs['commitlog_directory'])
294 ensure_database_directory(db_dirs['saved_caches_directory'])
295@@ -549,7 +650,19 @@
296
297
298 def encrypt_password(password):
299- return bcrypt.hashpw(password, bcrypt.gensalt())
300+ password = password.encode('ascii')
301+ # Java doesn't understand bcrypt 2b yet:
302+ # cassandra.AuthenticationFailed: Failed to authenticate to localhost:
303+ # code=0000 [Server error] message="java.lang.IllegalArgumentException:
304+ # Invalid salt revision"
305+ try:
306+ salt = bcrypt.gensalt(prefix=b'2a')
307+ # Newer versions of bcrypt return a bytestring.
308+ return bcrypt.hashpw(password, salt).decode('ascii')
309+ except TypeError:
310+ # Trusty bcrypt doesn't support prefix=
311+ salt = bcrypt.gensalt()
312+ return bcrypt.hashpw(password, salt)
313
314
315 @logged
316@@ -563,6 +676,7 @@
317 hookenv.log('Creating SUPERUSER {}'.format(username))
318 else:
319 hookenv.log('Creating user {}'.format(username))
320+
321 if has_cassandra_version('2.2'):
322 query(session,
323 'INSERT INTO system_auth.roles '
324@@ -609,7 +723,11 @@
325
326
327 def get_cqlshrc_path():
328- return os.path.expanduser('~root/.cassandra/cqlshrc')
329+ if get_cassandra_edition() == 'apache-snap':
330+ base = get_cassandra_config_dir()
331+ return os.path.join(base, 'cql/cqlshrc')
332+ else:
333+ return os.path.expanduser('~root/.cassandra/cqlshrc')
334
335
336 def superuser_username():
337@@ -672,7 +790,11 @@
338
339
340 def nodetool(*cmd, timeout=120):
341- cmd = ['nodetool'] + [str(i) for i in cmd]
342+ if get_cassandra_edition() == 'apache-snap':
343+ nodetool_cmd = '/snap/bin/cassandra.nodetool'
344+ else:
345+ nodetool_cmd = 'nodetool'
346+ cmd = [nodetool_cmd] + [str(i) for i in cmd]
347 i = 0
348 until = time.time() + timeout
349 for _ in backoff('nodetool to work'):
350@@ -710,24 +832,39 @@
351 return len(get_bootstrapped_ips())
352
353
354+def write_config(path, contents):
355+ '''Write out the config file at path with the provided contents, encoding
356+ in UTF-8 first. If using a snap edition, write to the snap config
357+ directory.
358+ '''
359+ if get_cassandra_edition() == 'apache-snap':
360+ set_snap_config_file(os.path.basename(path), contents)
361+ else:
362+ contents = contents.encode('UTF-8')
363+ host.write_file(path, contents)
364+
365+
366 def read_cassandra_yaml():
367- cassandra_yaml_path = get_cassandra_yaml_file()
368- with open(cassandra_yaml_path, 'rb') as f:
369+ if get_cassandra_edition() == 'apache-snap':
370+ f = get_snap_config_file('cassandra.yaml')
371 return yaml.safe_load(f)
372+ else:
373+ cassandra_yaml_path = get_cassandra_yaml_file()
374+ with open(cassandra_yaml_path, 'rb') as f:
375+ return yaml.safe_load(f)
376
377
378 @logged
379 def write_cassandra_yaml(cassandra_yaml):
380- cassandra_yaml_path = get_cassandra_yaml_file()
381- host.write_file(cassandra_yaml_path,
382- yaml.safe_dump(cassandra_yaml).encode('UTF-8'))
383+ write_config(get_cassandra_yaml_file(), yaml.safe_dump(cassandra_yaml))
384
385
386 def configure_cassandra_yaml(overrides={}, seeds=None):
387- cassandra_yaml_path = get_cassandra_yaml_file()
388 config = hookenv.config()
389
390- maybe_backup(cassandra_yaml_path) # Its comments may be useful.
391+ if get_cassandra_edition() != 'apache-snap':
392+ cassandra_yaml_path = get_cassandra_yaml_file()
393+ maybe_backup(cassandra_yaml_path) # Its comments may be useful.
394
395 cassandra_yaml = read_cassandra_yaml()
396
397@@ -784,6 +921,7 @@
398
399
400 def is_cassandra_running():
401+ edition = get_cassandra_edition()
402 pid_file = get_cassandra_pid_file()
403
404 try:
405@@ -797,7 +935,12 @@
406 # is not running.
407 os.kill(pid, 0)
408
409- if subprocess.call(["nodetool", "status"],
410+ if edition == 'apache-snap':
411+ # /snap/bin is not on PATH for the root user.
412+ nodetool = '/snap/bin/cassandra.nodetool'
413+ else:
414+ nodetool = 'nodetool'
415+ if subprocess.call([nodetool, "status"],
416 stdout=subprocess.DEVNULL,
417 stderr=subprocess.DEVNULL) == 0:
418 hookenv.log(
419
420=== modified file 'tests/test_helpers.py'
421--- tests/test_helpers.py 2016-03-17 10:54:10 +0000
422+++ tests/test_helpers.py 2016-06-29 15:55:10 +0000
423@@ -163,6 +163,10 @@
424 # Autostart wasn't messed with.
425 self.assertFalse(autostart_disabled.called)
426
427+ def test_encrypt_password(self):
428+ self.assertEqual(type(helpers.encrypt_password('')), str)
429+
430+
431 @patch('subprocess.Popen')
432 def test_ensure_package_status(self, popen):
433 for status in ['install', 'hold']:
434@@ -380,7 +384,10 @@
435 hookenv.config()['edition'] = 'dse'
436 self.assertEqual(helpers.get_jre(), 'oracle')
437
438- def test_get_cassandra_edition(self):
439+ @patch('charmhelpers.core.host.lsb_release')
440+ def test_get_cassandra_edition(self, lsb_release):
441+ lsb_release.return_value = {'DISTRIB_CODENAME': 'trusty'}
442+
443 hookenv.config()['edition'] = 'community'
444 self.assertEqual(helpers.get_cassandra_edition(), 'community')
445
446@@ -389,10 +396,21 @@
447
448 self.assertFalse(hookenv.log.called)
449
450+ hookenv.log.reset_mock()
451 hookenv.config()['edition'] = 'typo' # Default to community
452 self.assertEqual(helpers.get_cassandra_edition(), 'community')
453 hookenv.log.assert_any_call(ANY, hookenv.ERROR) # Logs an error.
454
455+ hookenv.log.reset_mock()
456+ hookenv.config()['edition'] = 'apache-snap' # Default to community
457+ self.assertEqual(helpers.get_cassandra_edition(), 'community')
458+ hookenv.log.assert_any_call(ANY, hookenv.ERROR) # Logs an error.
459+
460+ lsb_release.reset_mock()
461+ lsb_release.return_value = {'DISTRIB_CODENAME': 'xenial'}
462+ hookenv.config()['edition'] = 'apache-snap'
463+ self.assertEqual(helpers.get_cassandra_edition(), 'apache-snap')
464+
465 @patch('helpers.get_cassandra_edition')
466 def test_get_cassandra_service(self, get_edition):
467 get_edition.return_value = 'whatever'
468@@ -460,6 +478,30 @@
469 '/foo/cassandra-rackdc.properties')
470
471 @patch('helpers.get_cassandra_edition')
472+ def test_write_config(self, get_edition):
473+ get_edition.return_value = 'whatever'
474+ expected = 'some config'
475+ with tempfile.TemporaryDirectory() as tmpdir:
476+ path = os.path.join(tmpdir, 'foo')
477+ helpers.write_config(path, expected)
478+ with open(path, 'r') as fp:
479+ self.assertEqual(fp.read(), expected)
480+
481+ @patch('subprocess.Popen')
482+ @patch('helpers.get_cassandra_edition')
483+ def test_write_config_snap(self, get_edition, popen):
484+ get_edition.return_value = 'apache-snap'
485+ popen.return_value.returncode = 0
486+ popen.return_value.communicate.return_value = ('', '')
487+ helpers.write_config('/some/path/to/config.yaml', 'some config')
488+ expected = 'some config'
489+ self.assertEqual(
490+ [call(['/snap/bin/cassandra.config-set', 'config.yaml'],
491+ stdin=subprocess.PIPE, universal_newlines=True),
492+ call().communicate(input=expected)], popen.mock_calls)
493+
494+
495+ @patch('helpers.get_cassandra_edition')
496 def test_get_cassandra_pid_file(self, get_edition):
497 get_edition.return_value = 'whatever'
498 self.assertEqual(helpers.get_cassandra_pid_file(),

Subscribers

People subscribed via source and target branches

to all changes: