Merge lp:~1chb1n/charms/trusty/rabbitmq-server/amulet-refactor-1508 into lp:~openstack-charmers-archive/charms/trusty/rabbitmq-server/next

Proposed by Ryan Beisner
Status: Superseded
Proposed branch: lp:~1chb1n/charms/trusty/rabbitmq-server/amulet-refactor-1508
Merge into: lp:~openstack-charmers-archive/charms/trusty/rabbitmq-server/next
Diff against target: 6948 lines (+2700/-3906) (has conflicts)
45 files modified
Makefile (+5/-4)
charm-helpers-tests.yaml (+2/-3)
hooks/rabbitmq_server_relations.py (+18/-0)
metadata.yaml (+4/-1)
tests/00-setup (+16/-0)
tests/014-basic-precise-icehouse (+11/-0)
tests/015-basic-trusty-icehouse (+9/-0)
tests/016-basic-trusty-juno (+11/-0)
tests/017-basic-trusty-kilo (+11/-0)
tests/019-basic-vivid-kilo (+9/-0)
tests/020-basic-trusty-liberty (+11/-0)
tests/021-basic-wily-liberty (+9/-0)
tests/basic_deployment.py (+492/-0)
tests/charmhelpers/__init__.py (+0/-38)
tests/charmhelpers/cli/__init__.py (+0/-191)
tests/charmhelpers/cli/benchmark.py (+0/-36)
tests/charmhelpers/cli/commands.py (+0/-32)
tests/charmhelpers/cli/hookenv.py (+0/-23)
tests/charmhelpers/cli/host.py (+0/-31)
tests/charmhelpers/cli/unitdata.py (+0/-39)
tests/charmhelpers/contrib/__init__.py (+0/-15)
tests/charmhelpers/contrib/amulet/__init__.py (+15/-0)
tests/charmhelpers/contrib/amulet/deployment.py (+93/-0)
tests/charmhelpers/contrib/amulet/utils.py (+771/-0)
tests/charmhelpers/contrib/openstack/__init__.py (+15/-0)
tests/charmhelpers/contrib/openstack/amulet/__init__.py (+15/-0)
tests/charmhelpers/contrib/openstack/amulet/deployment.py (+198/-0)
tests/charmhelpers/contrib/openstack/amulet/utils.py (+965/-0)
tests/charmhelpers/contrib/ssl/__init__.py (+0/-94)
tests/charmhelpers/contrib/ssl/service.py (+0/-279)
tests/charmhelpers/core/__init__.py (+0/-15)
tests/charmhelpers/core/decorators.py (+0/-57)
tests/charmhelpers/core/files.py (+0/-45)
tests/charmhelpers/core/fstab.py (+0/-134)
tests/charmhelpers/core/hookenv.py (+0/-896)
tests/charmhelpers/core/host.py (+0/-570)
tests/charmhelpers/core/hugepage.py (+0/-62)
tests/charmhelpers/core/services/__init__.py (+0/-18)
tests/charmhelpers/core/services/base.py (+0/-353)
tests/charmhelpers/core/services/helpers.py (+0/-283)
tests/charmhelpers/core/strutils.py (+0/-42)
tests/charmhelpers/core/sysctl.py (+0/-56)
tests/charmhelpers/core/templating.py (+0/-68)
tests/charmhelpers/core/unitdata.py (+0/-521)
tests/tests.yaml (+20/-0)
Text conflict in hooks/rabbitmq_server_relations.py
To merge this branch: bzr merge lp:~1chb1n/charms/trusty/rabbitmq-server/amulet-refactor-1508
Reviewer Review Type Date Requested Status
OpenStack Charmers Pending
Review via email: mp+268811@code.launchpad.net

This proposal has been superseded by a proposal from 2015-09-01.

Description of the change

Refactor amulet tests, deprecate old tests, pull in cluster race fix and collect_rabbitmq_stats script fix, sync charm-helpers.

Cluster race bug:
https://bugs.launchpad.net/charms/+source/rabbitmq-server/+bug/1486177

PID file bug for Vivid and later:
https://bugs.launchpad.net/charms/+source/rabbitmq-server/+bug/1485722

This charm merge proposal is dependent on charm-helpers mp @:
https://code.launchpad.net/~1chb1n/charm-helpers/amulet-rmq-helpers

To post a comment you must log in.
Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8491 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8491/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #7882 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/7882/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #5952 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
make: *** [functional_test] Error 1
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12146373/
Build: http://10.245.162.77:8080/job/charm_amulet_test/5952/

Revision history for this message
Ryan Beisner (1chb1n) wrote :

In amulet test 5952, all combos except Vivid-Kilo pass all tests. All combos, including V-K cluster ok. The V-K fail is a packaging issue (bug 1485722).

110. By Ryan Beisner

update makefile

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8541 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8541/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #5953 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
ERROR subprocess encountered error code 1
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12148380/
Build: http://10.245.162.77:8080/job/charm_amulet_test/5953/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #7930 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/7930/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #5958 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
2015-08-22 06:21:18,640 run_cmd_unit DEBUG: rabbitmq-server/2 `bash -c "$(egrep -oh /usr/local.* /etc/nagios/nrpe.d/check_rabbitmq.cfg)"` command returned 0 (OK)
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12148852/
Build: http://10.245.162.77:8080/job/charm_amulet_test/5958/

111. By Ryan Beisner

update amulet nrpe check

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8039 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8039/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8706 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8706/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6029 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
2015-08-25 20:07:58,933 publish_amqp_message_by_unit DEBUG: Publishing message to test queue:
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12194886/
Build: http://10.245.162.77:8080/job/charm_amulet_test/6029/

112. By Ryan Beisner

revert prev rev change to nrpe test; cron job is expected to have already run check_rabbitmq

113. By Ryan Beisner

fix collect script for vivid (cron job)

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8089 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8089/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8758 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8758/

114. By Ryan Beisner

add nrpe dat file existence check

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8763 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8763/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8094 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8094/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6041 rabbitmq-server-next for 1chb1n mp268811
    AMULET OK: passed

Build: http://10.245.162.77:8080/job/charm_amulet_test/6041/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8814 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8814/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8142 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8142/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6043 rabbitmq-server-next for 1chb1n mp268811
    AMULET OK: passed

Build: http://10.245.162.77:8080/job/charm_amulet_test/6043/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6044 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
make: *** [functional_test] Error 1
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12203258/
Build: http://10.245.162.77:8080/job/charm_amulet_test/6044/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6047 rabbitmq-server-next for 1chb1n mp268811
    AMULET OK: passed

Build: http://10.245.162.77:8080/job/charm_amulet_test/6047/

115. By Ryan Beisner

rebase, fix landed for bug 1485722 (vivid pid file)

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8817 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8817/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8145 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8145/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6049 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
2015-08-27 04:27:53,775 _test_rmq_amqp_messages_all_units DEBUG: Publish message to: 172.17.107.154 (rabbitmq-server/1 juju-osci-sv07-machine-3)
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12203625/
Build: http://10.245.162.77:8080/job/charm_amulet_test/6049/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6052 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
MESSAGE 7@172.17.117.126 [62A5E9AD-3A96-4865-B303-DC431ECF9D43-1440653092.55]
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12203737/
Build: http://10.245.162.77:8080/job/charm_amulet_test/6052/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8828 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8828/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8156 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8156/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6062 rabbitmq-server-next for 1chb1n mp268811
    AMULET FAIL: amulet-test failed

AMULET Results (max last 2 lines):
2015-08-27 16:27:34,911 connect_amqp_by_unit DEBUG: Connecting to amqp on 172.17.105.60:5999 (rabbitmq-server/0) as testuser1...
ERROR:root:Make target returned non-zero.

Full amulet test output: http://paste.ubuntu.com/12207067/
Build: http://10.245.162.77:8080/job/charm_amulet_test/6062/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8251 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8251/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #8932 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/8932/

117. By Ryan Beisner

 - close connections and channels when done;
 - update debug feedback;
 - fix math on mgmt plugin check.

118. By Ryan Beisner

Re-sync tests/charmhelpers re: updated fix for service_restarted_since bug 1474030

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8409 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8409/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #9100 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/9100/

119. By Ryan Beisner

re-merge lp:~thedac/charms/trusty/rabbitmq-server/native-cluster-race-fixes

120. By Ryan Beisner

update comments

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_lint_check #9152 rabbitmq-server-next for 1chb1n mp268811
    LINT OK: passed

Build: http://10.245.162.77:8080/job/charm_lint_check/9152/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_unit_test #8458 rabbitmq-server-next for 1chb1n mp268811
    UNIT OK: passed

Build: http://10.245.162.77:8080/job/charm_unit_test/8458/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6163 rabbitmq-server-next for 1chb1n mp268811
    AMULET OK: passed

Build: http://10.245.162.77:8080/job/charm_amulet_test/6163/

Revision history for this message
uosci-testing-bot (uosci-testing-bot) wrote :

charm_amulet_test #6164 rabbitmq-server-next for 1chb1n mp268811
    AMULET OK: passed

Build: http://10.245.162.77:8080/job/charm_amulet_test/6164/

121. By Ryan Beisner

re-merge lp:~thedac/charms/trusty/rabbitmq-server/native-cluster-race-fixes

122. By Ryan Beisner

resync tests/charmhelpers for lint fixes

123. By Ryan Beisner

resync tests/charmhelpers for review changes

124. By Ryan Beisner

resync tests/charmhelpers

125. By Ryan Beisner

revert hooks, pull from rmq/next

Unmerged revisions

125. By Ryan Beisner

revert hooks, pull from rmq/next

124. By Ryan Beisner

resync tests/charmhelpers

123. By Ryan Beisner

resync tests/charmhelpers for review changes

122. By Ryan Beisner

resync tests/charmhelpers for lint fixes

121. By Ryan Beisner

re-merge lp:~thedac/charms/trusty/rabbitmq-server/native-cluster-race-fixes

120. By Ryan Beisner

update comments

119. By Ryan Beisner

re-merge lp:~thedac/charms/trusty/rabbitmq-server/native-cluster-race-fixes

118. By Ryan Beisner

Re-sync tests/charmhelpers re: updated fix for service_restarted_since bug 1474030

117. By Ryan Beisner

 - close connections and channels when done;
 - update debug feedback;
 - fix math on mgmt plugin check.

116. By Ryan Beisner

re-merge lp:~thedac/charms/trusty/rabbitmq-server/native-cluster-race-fixes

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'Makefile'
--- Makefile 2015-04-20 11:13:39 +0000
+++ Makefile 2015-09-01 02:46:36 +0000
@@ -29,14 +29,15 @@
29 @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers.yaml29 @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers.yaml
30 @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-tests.yaml30 @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-tests.yaml
3131
32publish: lint32publish: lint test
33 bzr push lp:charms/rabbitmq-server33 bzr push lp:charms/rabbitmq-server
34 bzr push lp:charms/trusty/rabbitmq-server34 bzr push lp:charms/trusty/rabbitmq-server
3535
36unit_test: .venv36test: .venv
37 @echo Starting tests...37 @echo Starting tests...
38 env CHARM_DIR=$(CHARM_DIR) $(TEST_PREFIX) .venv/bin/nosetests unit_tests/38 env CHARM_DIR=$(CHARM_DIR) $(TEST_PREFIX) .venv/bin/nosetests \
39 --nologcapture --with-coverage unit_tests/
3940
40functional_test:41functional_test:
41 @echo Starting amulet tests...42 @echo Starting amulet tests...
42 @juju test -v -p AMULET_HTTP_PROXY,OS_USERNAME,OS_TENANT_NAME,OS_REGION_NAME,OS_PASSWORD,OS_AUTH_URL --timeout 90043 @juju test -v -p AMULET_HTTP_PROXY,AMULET_OS_VIP --timeout 2700
4344
=== modified file 'charm-helpers-tests.yaml'
--- charm-helpers-tests.yaml 2015-07-31 22:00:49 +0000
+++ charm-helpers-tests.yaml 2015-09-01 02:46:36 +0000
@@ -1,6 +1,5 @@
1destination: tests/charmhelpers1destination: tests/charmhelpers
2branch: lp:charm-helpers2branch: lp:charm-helpers
3include:3include:
4 - core4 - contrib.amulet
5 - cli5 - contrib.openstack.amulet
6 - contrib.ssl
76
=== modified file 'hooks/rabbitmq_server_relations.py'
--- hooks/rabbitmq_server_relations.py 2015-09-01 00:54:12 +0000
+++ hooks/rabbitmq_server_relations.py 2015-09-01 02:46:36 +0000
@@ -326,6 +326,7 @@
326 log('cluster_joined: cookie not yet set.', level=INFO)326 log('cluster_joined: cookie not yet set.', level=INFO)
327 return327 return
328328
329<<<<<<< TREE
329 rdata = relation_get()330 rdata = relation_get()
330 hostname = rdata.get('hostname', None)331 hostname = rdata.get('hostname', None)
331 private_address = rdata.get('private-address', None)332 private_address = rdata.get('private-address', None)
@@ -343,6 +344,23 @@
343344
344 if hostname and private_address:345 if hostname and private_address:
345 rabbit.update_hosts_file({private_address: hostname})346 rabbit.update_hosts_file({private_address: hostname})
347=======
348 # If called from leader-settings-changed we are not in a hook env
349 if not in_relation_hook():
350 rid = relation_ids('cluster')[0]
351 for unit in related_units(rid):
352 rdata = relation_get(rid=rid, unit=unit)
353 if rdata:
354 break;
355 else:
356 rdata = relation_get()
357
358 if config('prefer-ipv6') and rdata.get('hostname'):
359 private_address = rdata['private-address']
360 hostname = rdata['hostname']
361 if hostname:
362 rabbit.update_hosts_file({private_address: hostname})
363>>>>>>> MERGE-SOURCE
346364
347 # sync passwords365 # sync passwords
348 blacklist = ['hostname', 'private-address', 'public-address']366 blacklist = ['hostname', 'private-address', 'public-address']
349367
=== modified file 'metadata.yaml'
--- metadata.yaml 2013-11-15 19:15:16 +0000
+++ metadata.yaml 2015-09-01 02:46:36 +0000
@@ -5,7 +5,10 @@
5 RabbitMQ is an implementation of AMQP, the emerging standard for high5 RabbitMQ is an implementation of AMQP, the emerging standard for high
6 performance enterprise messaging. The RabbitMQ server is a robust and6 performance enterprise messaging. The RabbitMQ server is a robust and
7 scalable implementation of an AMQP broker.7 scalable implementation of an AMQP broker.
8categories: ["misc"]8tags:
9 - openstack
10 - amqp
11 - misc
9provides:12provides:
10 amqp:13 amqp:
11 interface: rabbitmq14 interface: rabbitmq
1215
=== added file 'tests/00-setup'
--- tests/00-setup 1970-01-01 00:00:00 +0000
+++ tests/00-setup 2015-09-01 02:46:36 +0000
@@ -0,0 +1,16 @@
1#!/bin/bash
2
3set -ex
4
5sudo add-apt-repository --yes ppa:juju/stable
6sudo apt-get update --yes
7sudo apt-get install --yes python-amulet \
8 python-cinderclient \
9 python-distro-info \
10 python-glanceclient \
11 python-heatclient \
12 python-keystoneclient \
13 python-neutronclient \
14 python-novaclient \
15 python-pika \
16 python-swiftclient
017
=== added file 'tests/014-basic-precise-icehouse'
--- tests/014-basic-precise-icehouse 1970-01-01 00:00:00 +0000
+++ tests/014-basic-precise-icehouse 2015-09-01 02:46:36 +0000
@@ -0,0 +1,11 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on precise-icehouse."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='precise',
9 openstack='cloud:precise-icehouse',
10 source='cloud:precise-updates/icehouse')
11 deployment.run_tests()
012
=== added file 'tests/015-basic-trusty-icehouse'
--- tests/015-basic-trusty-icehouse 1970-01-01 00:00:00 +0000
+++ tests/015-basic-trusty-icehouse 2015-09-01 02:46:36 +0000
@@ -0,0 +1,9 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on trusty-icehouse."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='trusty')
9 deployment.run_tests()
010
=== added file 'tests/016-basic-trusty-juno'
--- tests/016-basic-trusty-juno 1970-01-01 00:00:00 +0000
+++ tests/016-basic-trusty-juno 2015-09-01 02:46:36 +0000
@@ -0,0 +1,11 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on trusty-juno."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='trusty',
9 openstack='cloud:trusty-juno',
10 source='cloud:trusty-updates/juno')
11 deployment.run_tests()
012
=== added file 'tests/017-basic-trusty-kilo'
--- tests/017-basic-trusty-kilo 1970-01-01 00:00:00 +0000
+++ tests/017-basic-trusty-kilo 2015-09-01 02:46:36 +0000
@@ -0,0 +1,11 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on trusty-kilo."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='trusty',
9 openstack='cloud:trusty-kilo',
10 source='cloud:trusty-updates/kilo')
11 deployment.run_tests()
012
=== added file 'tests/019-basic-vivid-kilo'
--- tests/019-basic-vivid-kilo 1970-01-01 00:00:00 +0000
+++ tests/019-basic-vivid-kilo 2015-09-01 02:46:36 +0000
@@ -0,0 +1,9 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on vivid-kilo."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='vivid')
9 deployment.run_tests()
010
=== added file 'tests/020-basic-trusty-liberty'
--- tests/020-basic-trusty-liberty 1970-01-01 00:00:00 +0000
+++ tests/020-basic-trusty-liberty 2015-09-01 02:46:36 +0000
@@ -0,0 +1,11 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on trusty-liberty."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='trusty',
9 openstack='cloud:trusty-liberty',
10 source='cloud:trusty-updates/liberty')
11 deployment.run_tests()
012
=== added file 'tests/021-basic-wily-liberty'
--- tests/021-basic-wily-liberty 1970-01-01 00:00:00 +0000
+++ tests/021-basic-wily-liberty 2015-09-01 02:46:36 +0000
@@ -0,0 +1,9 @@
1#!/usr/bin/python
2
3"""Amulet tests on a basic rabbitmq-server deployment on wily-liberty."""
4
5from basic_deployment import RmqBasicDeployment
6
7if __name__ == '__main__':
8 deployment = RmqBasicDeployment(series='wily')
9 deployment.run_tests()
010
=== added file 'tests/basic_deployment.py'
--- tests/basic_deployment.py 1970-01-01 00:00:00 +0000
+++ tests/basic_deployment.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,492 @@
1#!/usr/bin/python
2"""
3Basic 3-node rabbitmq-server native cluster + nrpe functional tests
4
5Cinder is present to exercise and inspect amqp relation functionality.
6
7Each individual test is idempotent, in that it creates/deletes
8a rmq test user, enables or disables ssl as needed.
9
10Test order is not required, however tests are numbered to keep
11relevant tests grouped together in run order.
12"""
13
14import amulet
15import time
16
17from charmhelpers.contrib.openstack.amulet.deployment import (
18 OpenStackAmuletDeployment
19)
20
21from charmhelpers.contrib.openstack.amulet.utils import (
22 OpenStackAmuletUtils,
23 DEBUG,
24 # ERROR
25)
26
27# Use DEBUG to turn on debug logging
28u = OpenStackAmuletUtils(DEBUG)
29
30
31class RmqBasicDeployment(OpenStackAmuletDeployment):
32 """Amulet tests on a basic rabbitmq cluster deployment. Verify
33 relations, service status, users and endpoint service catalog."""
34
35 def __init__(self, series=None, openstack=None, source=None, stable=False):
36 """Deploy the entire test environment."""
37 super(RmqBasicDeployment, self).__init__(series, openstack, source,
38 stable)
39 self._add_services()
40 self._add_relations()
41 self._configure_services()
42 self._deploy()
43 self._initialize_tests()
44
45 def _add_services(self):
46 """Add services
47
48 Add the services that we're testing, where rmq is local,
49 and the rest of the service are from lp branches that are
50 compatible with the local charm (e.g. stable or next).
51 """
52 this_service = {
53 'name': 'rabbitmq-server',
54 'units': 3
55 }
56 other_services = [{'name': 'cinder'},
57 {'name': 'nrpe'}]
58
59 super(RmqBasicDeployment, self)._add_services(this_service,
60 other_services)
61
62 def _add_relations(self):
63 """Add relations for the services."""
64 relations = {'cinder:amqp': 'rabbitmq-server:amqp',
65 'nrpe:nrpe-external-master':
66 'rabbitmq-server:nrpe-external-master'}
67
68 super(RmqBasicDeployment, self)._add_relations(relations)
69
70 def _configure_services(self):
71 """Configure all of the services."""
72 rmq_config = {
73 'min-cluster-size': '3',
74 'max-cluster-tries': '6',
75 'ssl': 'off',
76 'management_plugin': 'False',
77 'stats_cron_schedule': '*/1 * * * *'
78 }
79 cinder_config = {}
80 configs = {'rabbitmq-server': rmq_config,
81 'cinder': cinder_config}
82 super(RmqBasicDeployment, self)._configure_services(configs)
83
84 def _initialize_tests(self):
85 """Perform final initialization before tests get run."""
86 # Access the sentries for inspecting service units
87 self.rmq0_sentry = self.d.sentry.unit['rabbitmq-server/0']
88 self.rmq1_sentry = self.d.sentry.unit['rabbitmq-server/1']
89 self.rmq2_sentry = self.d.sentry.unit['rabbitmq-server/2']
90 self.cinder_sentry = self.d.sentry.unit['cinder/0']
91 self.nrpe_sentry = self.d.sentry.unit['nrpe/0']
92 u.log.debug('openstack release val: {}'.format(
93 self._get_openstack_release()))
94 u.log.debug('openstack release str: {}'.format(
95 self._get_openstack_release_string()))
96
97 # Let things settle a bit before moving forward
98 time.sleep(30)
99
100 def _get_rmq_sentry_units(self):
101 """Local helper specific to this 3-node rmq series of tests."""
102 return [self.rmq0_sentry,
103 self.rmq1_sentry,
104 self.rmq2_sentry]
105
106 def _test_rmq_amqp_messages_all_units(self, sentry_units,
107 ssl=False, port=None):
108 """Reusable test to send amqp messages to every listed rmq unit
109 and check every listed rmq unit for messages.
110
111 :param sentry_units: list of sentry units
112 :returns: None if successful. Raise on error.
113 """
114
115 # Add test user if it does not already exist
116 u.add_rmq_test_user(sentry_units)
117
118 # Handle ssl
119 if ssl:
120 u.configure_rmq_ssl_on(sentry_units, self.d, port=port)
121 else:
122 u.configure_rmq_ssl_off(sentry_units, self.d)
123
124 # Publish and get amqp messages in all possible unit combinations.
125 # Qty of checks == (qty of units) ^ 2
126 amqp_msg_counter = 1
127 host_names = u.get_unit_hostnames(sentry_units)
128
129 for dest_unit in sentry_units:
130 dest_unit_name = dest_unit.info['unit_name']
131 dest_unit_host = dest_unit.info['public-address']
132 dest_unit_host_name = host_names[dest_unit_name]
133
134 for check_unit in sentry_units:
135 check_unit_name = check_unit.info['unit_name']
136 check_unit_host = check_unit.info['public-address']
137 check_unit_host_name = host_names[check_unit_name]
138
139 amqp_msg_stamp = u.get_uuid_epoch_stamp()
140 amqp_msg = ('Message {}@{} {}'.format(amqp_msg_counter,
141 dest_unit_host,
142 amqp_msg_stamp)).upper()
143 # Publish amqp message
144 u.log.debug('Publish message to: {} '
145 '({} {})'.format(dest_unit_host,
146 dest_unit_name,
147 dest_unit_host_name))
148
149 u.publish_amqp_message_by_unit(dest_unit,
150 amqp_msg, ssl=ssl,
151 port=port)
152
153 # Wait a bit before checking for message
154 time.sleep(2)
155
156 # Get amqp message
157 u.log.debug('Get message from: {} '
158 '({} {})'.format(check_unit_host,
159 check_unit_name,
160 check_unit_host_name))
161
162 amqp_msg_rcvd = u.get_amqp_message_by_unit(check_unit,
163 ssl=ssl,
164 port=port)
165
166 # Validate amqp message content
167 if amqp_msg == amqp_msg_rcvd:
168 u.log.debug('Message {} received '
169 'OK.'.format(amqp_msg_counter))
170 else:
171 u.log.error('Expected: {}'.format(amqp_msg))
172 u.log.error('Actual: {}'.format(amqp_msg_rcvd))
173 msg = 'Message {} mismatch.'.format(amqp_msg_counter)
174 amulet.raise_status(amulet.FAIL, msg)
175
176 amqp_msg_counter += 1
177
178 # Delete the test user
179 u.delete_rmq_test_user(sentry_units)
180
181 def test_100_rmq_processes(self):
182 """Verify that the expected service processes are running
183 on each rabbitmq-server unit."""
184
185 # Beam and epmd sometimes briefly have more than one PID,
186 # True checks for at least 1.
187 rmq_processes = {
188 'beam': True,
189 'epmd': True,
190 }
191
192 # Units with process names and PID quantities expected
193 expected_processes = {
194 self.rmq0_sentry: rmq_processes,
195 self.rmq1_sentry: rmq_processes,
196 self.rmq2_sentry: rmq_processes
197 }
198
199 actual_pids = u.get_unit_process_ids(expected_processes)
200 ret = u.validate_unit_process_ids(expected_processes, actual_pids)
201 if ret:
202 amulet.raise_status(amulet.FAIL, msg=ret)
203
204 u.log.info('OK\n')
205
206 def test_102_services(self):
207 """Verify that the expected services are running on the
208 corresponding service units."""
209 services = {
210 self.rmq0_sentry: ['rabbitmq-server'],
211 self.rmq1_sentry: ['rabbitmq-server'],
212 self.rmq2_sentry: ['rabbitmq-server'],
213 self.cinder_sentry: ['cinder-api',
214 'cinder-scheduler',
215 'cinder-volume'],
216 }
217 ret = u.validate_services_by_name(services)
218 if ret:
219 amulet.raise_status(amulet.FAIL, msg=ret)
220
221 u.log.info('OK\n')
222
223 def test_200_rmq_cinder_amqp_relation(self):
224 """Verify the rabbitmq-server:cinder amqp relation data"""
225 u.log.debug('Checking rmq:cinder amqp relation data...')
226 unit = self.rmq0_sentry
227 relation = ['amqp', 'cinder:amqp']
228 expected = {
229 'private-address': u.valid_ip,
230 'password': u.not_null,
231 'hostname': u.valid_ip
232 }
233 ret = u.validate_relation_data(unit, relation, expected)
234 if ret:
235 msg = u.relation_error('amqp cinder', ret)
236 amulet.raise_status(amulet.FAIL, msg=msg)
237
238 u.log.info('OK\n')
239
240 def test_201_cinder_rmq_amqp_relation(self):
241 """Verify the cinder:rabbitmq-server amqp relation data"""
242 u.log.debug('Checking cinder:rmq amqp relation data...')
243 unit = self.cinder_sentry
244 relation = ['amqp', 'rabbitmq-server:amqp']
245 expected = {
246 'private-address': u.valid_ip,
247 'vhost': 'openstack',
248 'username': u.not_null
249 }
250 ret = u.validate_relation_data(unit, relation, expected)
251 if ret:
252 msg = u.relation_error('cinder amqp', ret)
253 amulet.raise_status(amulet.FAIL, msg=msg)
254
255 u.log.info('OK\n')
256
257 def test_202_rmq_nrpe_ext_master_relation(self):
258 """Verify rabbitmq-server:nrpe nrpe-external-master relation data"""
259 u.log.debug('Checking rmq:nrpe external master relation data...')
260 unit = self.rmq0_sentry
261 relation = ['nrpe-external-master',
262 'nrpe:nrpe-external-master']
263
264 mon_sub = ('monitors:\n remote:\n nrpe:\n rabbitmq: '
265 '{command: check_rabbitmq}\n rabbitmq_queue: '
266 '{command: check_rabbitmq_queue}\n')
267
268 expected = {
269 'private-address': u.valid_ip,
270 'monitors': mon_sub
271 }
272
273 ret = u.validate_relation_data(unit, relation, expected)
274 if ret:
275 msg = u.relation_error('amqp nrpe', ret)
276 amulet.raise_status(amulet.FAIL, msg=msg)
277
278 u.log.info('OK\n')
279
280 def test_203_nrpe_rmq_ext_master_relation(self):
281 """Verify nrpe:rabbitmq-server nrpe-external-master relation data"""
282 u.log.debug('Checking nrpe:rmq external master relation data...')
283 unit = self.nrpe_sentry
284 relation = ['nrpe-external-master',
285 'rabbitmq-server:nrpe-external-master']
286
287 expected = {
288 'private-address': u.valid_ip
289 }
290
291 ret = u.validate_relation_data(unit, relation, expected)
292 if ret:
293 msg = u.relation_error('nrpe amqp', ret)
294 amulet.raise_status(amulet.FAIL, msg=msg)
295
296 u.log.info('OK\n')
297
298 def test_300_rmq_config(self):
299 """Verify the data in the rabbitmq conf file."""
300 conf = '/etc/rabbitmq/rabbitmq-env.conf'
301 sentry_units = self._get_rmq_sentry_units()
302 for unit in sentry_units:
303 host_name = unit.file_contents('/etc/hostname').strip()
304 u.log.debug('Checking rabbitmq config file data on '
305 '{} ({})...'.format(unit.info['unit_name'],
306 host_name))
307 expected = {
308 'RABBITMQ_NODENAME': 'rabbit@{}'.format(host_name)
309 }
310
311 file_contents = unit.file_contents(conf)
312 u.validate_sectionless_conf(file_contents, expected)
313
314 u.log.info('OK\n')
315
316 def test_400_rmq_cluster_running_nodes(self):
317 """Verify that cluster status from each rmq juju unit shows
318 every cluster node as a running member in that cluster."""
319 u.log.debug('Checking that all units are in cluster_status '
320 'running nodes...')
321
322 sentry_units = self._get_rmq_sentry_units()
323
324 ret = u.validate_rmq_cluster_running_nodes(sentry_units)
325 if ret:
326 amulet.raise_status(amulet.FAIL, msg=ret)
327
328 u.log.info('OK\n')
329
330 def test_402_rmq_connect_with_ssl_off(self):
331 """Verify successful non-ssl amqp connection to all units when
332 charm config option for ssl is set False."""
333 u.log.debug('Confirming that non-ssl connection succeeds when '
334 'ssl config is off...')
335 sentry_units = self._get_rmq_sentry_units()
336 u.add_rmq_test_user(sentry_units)
337 u.configure_rmq_ssl_off(sentry_units, self.d)
338
339 # Check amqp connection for all units, expect connections to succeed
340 for unit in sentry_units:
341 connection = u.connect_amqp_by_unit(unit, ssl=False, fatal=False)
342 connection.close()
343
344 u.delete_rmq_test_user(sentry_units)
345 u.log.info('OK\n')
346
347 def test_404_rmq_ssl_connect_with_ssl_off(self):
348 """Verify unsuccessful ssl amqp connection to all units when
349 charm config option for ssl is set False."""
350 u.log.debug('Confirming that ssl connection fails when ssl '
351 'config is off...')
352 sentry_units = self._get_rmq_sentry_units()
353 u.add_rmq_test_user(sentry_units)
354 u.configure_rmq_ssl_off(sentry_units, self.d)
355
356 # Check ssl amqp connection for all units, expect connections to fail
357 for unit in sentry_units:
358 connection = u.connect_amqp_by_unit(unit, ssl=True,
359 port=5971, fatal=False)
360 if connection:
361 connection.close()
362 msg = 'SSL connection unexpectedly succeeded with ssl=off'
363 amulet.raise_status(amulet.FAIL, msg)
364
365 u.delete_rmq_test_user(sentry_units)
366 u.log.info('OK - Confirmed that ssl connection attempt fails '
367 'when ssl config is off.')
368
369 def test_406_rmq_amqp_messages_all_units_ssl_off(self):
370 """Send amqp messages to every rmq unit and check every rmq unit
371 for messages. Standard amqp tcp port, no ssl."""
372 u.log.debug('Checking amqp message publish/get on all units '
373 '(ssl off)...')
374
375 sentry_units = self._get_rmq_sentry_units()
376 self._test_rmq_amqp_messages_all_units(sentry_units, ssl=False)
377 u.log.info('OK\n')
378
379 def test_408_rmq_amqp_messages_all_units_ssl_on(self):
380 """Send amqp messages with ssl enabled, to every rmq unit and
381 check every rmq unit for messages. Standard ssl tcp port."""
382 u.log.debug('Checking amqp message publish/get on all units '
383 '(ssl on)...')
384
385 sentry_units = self._get_rmq_sentry_units()
386 self._test_rmq_amqp_messages_all_units(sentry_units,
387 ssl=True, port=5671)
388 u.log.info('OK\n')
389
390 def test_410_rmq_amqp_messages_all_units_ssl_alt_port(self):
391 """Send amqp messages with ssl on, to every rmq unit and check
392 every rmq unit for messages. Custom ssl tcp port."""
393 u.log.debug('Checking amqp message publish/get on all units '
394 '(ssl on)...')
395
396 sentry_units = self._get_rmq_sentry_units()
397 self._test_rmq_amqp_messages_all_units(sentry_units,
398 ssl=True, port=5999)
399 u.log.info('OK\n')
400
401 def test_412_rmq_management_plugin(self):
402 """Enable and check management plugin."""
403 u.log.debug('Checking tcp socket connect to management plugin '
404 'port on all rmq units...')
405
406 sentry_units = self._get_rmq_sentry_units()
407 mgmt_port = 15672
408
409 # Enable management plugin
410 u.log.debug('Enabling management_plugin charm config option...')
411 config = {'management_plugin': 'True'}
412 self.d.configure('rabbitmq-server', config)
413
414 # Check tcp connect to management plugin port
415 max_wait = 120
416 tries = 0
417 ret = u.port_knock_units(sentry_units, mgmt_port)
418 while ret and tries < (max_wait / 12):
419 time.sleep(12)
420 u.log.debug('Attempt {}: {}'.format(tries, ret))
421 ret = u.port_knock_units(sentry_units, mgmt_port)
422 tries += 1
423
424 if ret:
425 amulet.raise_status(amulet.FAIL, ret)
426 else:
427 u.log.debug('Connect to all units (OK)\n')
428
429 # Disable management plugin
430 u.log.debug('Disabling management_plugin charm config option...')
431 config = {'management_plugin': 'False'}
432 self.d.configure('rabbitmq-server', config)
433
434 # Negative check - tcp connect to management plugin port
435 u.log.info('Expect tcp connect fail since charm config '
436 'option is disabled.')
437 tries = 0
438 ret = u.port_knock_units(sentry_units, mgmt_port, expect_success=False)
439 while ret and tries < (max_wait / 12):
440 time.sleep(12)
441 u.log.debug('Attempt {}: {}'.format(tries, ret))
442 ret = u.port_knock_units(sentry_units, mgmt_port,
443 expect_success=False)
444 tries += 1
445
446 if ret:
447 amulet.raise_status(amulet.FAIL, ret)
448 else:
449 u.log.info('Confirm mgmt port closed on all units (OK)\n')
450
451 def test_414_rmq_nrpe_monitors(self):
452 """Check rabbimq-server nrpe monitor basic functionality."""
453 sentry_units = self._get_rmq_sentry_units()
454 host_names = u.get_unit_hostnames(sentry_units)
455
456 # check_rabbitmq monitor
457 u.log.debug('Checking nrpe check_rabbitmq on units...')
458 cmds = ['egrep -oh /usr/local.* /etc/nagios/nrpe.d/'
459 'check_rabbitmq.cfg']
460 ret = u.check_commands_on_units(cmds, sentry_units)
461 if ret:
462 amulet.raise_status(amulet.FAIL, msg=ret)
463
464 u.log.debug('Sleeping 70s for 1m cron job to run...')
465 time.sleep(70)
466
467 # check_rabbitmq_queue monitor
468 u.log.debug('Checking nrpe check_rabbitmq_queue on units...')
469 cmds = ['egrep -oh /usr/local.* /etc/nagios/nrpe.d/'
470 'check_rabbitmq_queue.cfg']
471 ret = u.check_commands_on_units(cmds, sentry_units)
472 if ret:
473 amulet.raise_status(amulet.FAIL, msg=ret)
474
475 # check dat file existence
476 u.log.debug('Checking nrpe dat file existence on units...')
477 for sentry_unit in sentry_units:
478 unit_name = sentry_unit.info['unit_name']
479 unit_host_name = host_names[unit_name]
480
481 cmds = [
482 'stat /var/lib/rabbitmq/data/{}_general_stats.dat'.format(
483 unit_host_name),
484 'stat /var/lib/rabbitmq/data/{}_queue_stats.dat'.format(
485 unit_host_name)
486 ]
487
488 ret = u.check_commands_on_units(cmds, [sentry_unit])
489 if ret:
490 amulet.raise_status(amulet.FAIL, msg=ret)
491
492 u.log.info('OK\n')
0493
=== added directory 'tests/charmhelpers'
=== removed directory 'tests/charmhelpers'
=== added file 'tests/charmhelpers/__init__.py'
--- tests/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/__init__.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,38 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17# Bootstrap charm-helpers, installing its dependencies if necessary using
18# only standard libraries.
19import subprocess
20import sys
21
22try:
23 import six # flake8: noqa
24except ImportError:
25 if sys.version_info.major == 2:
26 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
27 else:
28 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
29 import six # flake8: noqa
30
31try:
32 import yaml # flake8: noqa
33except ImportError:
34 if sys.version_info.major == 2:
35 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
36 else:
37 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
38 import yaml # flake8: noqa
039
=== removed file 'tests/charmhelpers/__init__.py'
--- tests/charmhelpers/__init__.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,38 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17# Bootstrap charm-helpers, installing its dependencies if necessary using
18# only standard libraries.
19import subprocess
20import sys
21
22try:
23 import six # flake8: noqa
24except ImportError:
25 if sys.version_info.major == 2:
26 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
27 else:
28 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
29 import six # flake8: noqa
30
31try:
32 import yaml # flake8: noqa
33except ImportError:
34 if sys.version_info.major == 2:
35 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
36 else:
37 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
38 import yaml # flake8: noqa
390
=== removed directory 'tests/charmhelpers/cli'
=== removed file 'tests/charmhelpers/cli/__init__.py'
--- tests/charmhelpers/cli/__init__.py 2015-08-19 13:49:53 +0000
+++ tests/charmhelpers/cli/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,191 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import inspect
18import argparse
19import sys
20
21from six.moves import zip
22
23from charmhelpers.core import unitdata
24
25
26class OutputFormatter(object):
27 def __init__(self, outfile=sys.stdout):
28 self.formats = (
29 "raw",
30 "json",
31 "py",
32 "yaml",
33 "csv",
34 "tab",
35 )
36 self.outfile = outfile
37
38 def add_arguments(self, argument_parser):
39 formatgroup = argument_parser.add_mutually_exclusive_group()
40 choices = self.supported_formats
41 formatgroup.add_argument("--format", metavar='FMT',
42 help="Select output format for returned data, "
43 "where FMT is one of: {}".format(choices),
44 choices=choices, default='raw')
45 for fmt in self.formats:
46 fmtfunc = getattr(self, fmt)
47 formatgroup.add_argument("-{}".format(fmt[0]),
48 "--{}".format(fmt), action='store_const',
49 const=fmt, dest='format',
50 help=fmtfunc.__doc__)
51
52 @property
53 def supported_formats(self):
54 return self.formats
55
56 def raw(self, output):
57 """Output data as raw string (default)"""
58 if isinstance(output, (list, tuple)):
59 output = '\n'.join(map(str, output))
60 self.outfile.write(str(output))
61
62 def py(self, output):
63 """Output data as a nicely-formatted python data structure"""
64 import pprint
65 pprint.pprint(output, stream=self.outfile)
66
67 def json(self, output):
68 """Output data in JSON format"""
69 import json
70 json.dump(output, self.outfile)
71
72 def yaml(self, output):
73 """Output data in YAML format"""
74 import yaml
75 yaml.safe_dump(output, self.outfile)
76
77 def csv(self, output):
78 """Output data as excel-compatible CSV"""
79 import csv
80 csvwriter = csv.writer(self.outfile)
81 csvwriter.writerows(output)
82
83 def tab(self, output):
84 """Output data in excel-compatible tab-delimited format"""
85 import csv
86 csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
87 csvwriter.writerows(output)
88
89 def format_output(self, output, fmt='raw'):
90 fmtfunc = getattr(self, fmt)
91 fmtfunc(output)
92
93
94class CommandLine(object):
95 argument_parser = None
96 subparsers = None
97 formatter = None
98 exit_code = 0
99
100 def __init__(self):
101 if not self.argument_parser:
102 self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
103 if not self.formatter:
104 self.formatter = OutputFormatter()
105 self.formatter.add_arguments(self.argument_parser)
106 if not self.subparsers:
107 self.subparsers = self.argument_parser.add_subparsers(help='Commands')
108
109 def subcommand(self, command_name=None):
110 """
111 Decorate a function as a subcommand. Use its arguments as the
112 command-line arguments"""
113 def wrapper(decorated):
114 cmd_name = command_name or decorated.__name__
115 subparser = self.subparsers.add_parser(cmd_name,
116 description=decorated.__doc__)
117 for args, kwargs in describe_arguments(decorated):
118 subparser.add_argument(*args, **kwargs)
119 subparser.set_defaults(func=decorated)
120 return decorated
121 return wrapper
122
123 def test_command(self, decorated):
124 """
125 Subcommand is a boolean test function, so bool return values should be
126 converted to a 0/1 exit code.
127 """
128 decorated._cli_test_command = True
129 return decorated
130
131 def no_output(self, decorated):
132 """
133 Subcommand is not expected to return a value, so don't print a spurious None.
134 """
135 decorated._cli_no_output = True
136 return decorated
137
138 def subcommand_builder(self, command_name, description=None):
139 """
140 Decorate a function that builds a subcommand. Builders should accept a
141 single argument (the subparser instance) and return the function to be
142 run as the command."""
143 def wrapper(decorated):
144 subparser = self.subparsers.add_parser(command_name)
145 func = decorated(subparser)
146 subparser.set_defaults(func=func)
147 subparser.description = description or func.__doc__
148 return wrapper
149
150 def run(self):
151 "Run cli, processing arguments and executing subcommands."
152 arguments = self.argument_parser.parse_args()
153 argspec = inspect.getargspec(arguments.func)
154 vargs = []
155 for arg in argspec.args:
156 vargs.append(getattr(arguments, arg))
157 if argspec.varargs:
158 vargs.extend(getattr(arguments, argspec.varargs))
159 output = arguments.func(*vargs)
160 if getattr(arguments.func, '_cli_test_command', False):
161 self.exit_code = 0 if output else 1
162 output = ''
163 if getattr(arguments.func, '_cli_no_output', False):
164 output = ''
165 self.formatter.format_output(output, arguments.format)
166 if unitdata._KV:
167 unitdata._KV.flush()
168
169
170cmdline = CommandLine()
171
172
173def describe_arguments(func):
174 """
175 Analyze a function's signature and return a data structure suitable for
176 passing in as arguments to an argparse parser's add_argument() method."""
177
178 argspec = inspect.getargspec(func)
179 # we should probably raise an exception somewhere if func includes **kwargs
180 if argspec.defaults:
181 positional_args = argspec.args[:-len(argspec.defaults)]
182 keyword_names = argspec.args[-len(argspec.defaults):]
183 for arg, default in zip(keyword_names, argspec.defaults):
184 yield ('--{}'.format(arg),), {'default': default}
185 else:
186 positional_args = argspec.args
187
188 for arg in positional_args:
189 yield (arg,), {}
190 if argspec.varargs:
191 yield (argspec.varargs,), {'nargs': '*'}
1920
=== removed file 'tests/charmhelpers/cli/benchmark.py'
--- tests/charmhelpers/cli/benchmark.py 2015-07-31 22:00:49 +0000
+++ tests/charmhelpers/cli/benchmark.py 1970-01-01 00:00:00 +0000
@@ -1,36 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17from . import cmdline
18from charmhelpers.contrib.benchmark import Benchmark
19
20
21@cmdline.subcommand(command_name='benchmark-start')
22def start():
23 Benchmark.start()
24
25
26@cmdline.subcommand(command_name='benchmark-finish')
27def finish():
28 Benchmark.finish()
29
30
31@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
32def service(subparser):
33 subparser.add_argument("value", help="The composite score.")
34 subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
35 subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
36 return Benchmark.set_composite_score
370
=== removed file 'tests/charmhelpers/cli/commands.py'
--- tests/charmhelpers/cli/commands.py 2015-08-19 13:49:53 +0000
+++ tests/charmhelpers/cli/commands.py 1970-01-01 00:00:00 +0000
@@ -1,32 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17"""
18This module loads sub-modules into the python runtime so they can be
19discovered via the inspect module. In order to prevent flake8 from (rightfully)
20telling us these are unused modules, throw a ' # noqa' at the end of each import
21so that the warning is suppressed.
22"""
23
24from . import CommandLine # noqa
25
26"""
27Import the sub-modules which have decorated subcommands to register with chlp.
28"""
29from . import host # noqa
30from . import benchmark # noqa
31from . import unitdata # noqa
32from . import hookenv # noqa
330
=== removed file 'tests/charmhelpers/cli/hookenv.py'
--- tests/charmhelpers/cli/hookenv.py 2015-08-19 13:49:53 +0000
+++ tests/charmhelpers/cli/hookenv.py 1970-01-01 00:00:00 +0000
@@ -1,23 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17from . import cmdline
18from charmhelpers.core import hookenv
19
20
21cmdline.subcommand('relation-id')(hookenv.relation_id._wrapped)
22cmdline.subcommand('service-name')(hookenv.service_name)
23cmdline.subcommand('remote-service-name')(hookenv.remote_service_name._wrapped)
240
=== removed file 'tests/charmhelpers/cli/host.py'
--- tests/charmhelpers/cli/host.py 2015-07-31 22:00:49 +0000
+++ tests/charmhelpers/cli/host.py 1970-01-01 00:00:00 +0000
@@ -1,31 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17from . import cmdline
18from charmhelpers.core import host
19
20
21@cmdline.subcommand()
22def mounts():
23 "List mounts"
24 return host.mounts()
25
26
27@cmdline.subcommand_builder('service', description="Control system services")
28def service(subparser):
29 subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
30 subparser.add_argument("service_name", help="Name of the service to control")
31 return host.service
320
=== removed file 'tests/charmhelpers/cli/unitdata.py'
--- tests/charmhelpers/cli/unitdata.py 2015-07-31 22:00:49 +0000
+++ tests/charmhelpers/cli/unitdata.py 1970-01-01 00:00:00 +0000
@@ -1,39 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17from . import cmdline
18from charmhelpers.core import unitdata
19
20
21@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
22def unitdata_cmd(subparser):
23 nested = subparser.add_subparsers()
24 get_cmd = nested.add_parser('get', help='Retrieve data')
25 get_cmd.add_argument('key', help='Key to retrieve the value of')
26 get_cmd.set_defaults(action='get', value=None)
27 set_cmd = nested.add_parser('set', help='Store data')
28 set_cmd.add_argument('key', help='Key to set')
29 set_cmd.add_argument('value', help='Value to store')
30 set_cmd.set_defaults(action='set')
31
32 def _unitdata_cmd(action, key, value):
33 if action == 'get':
34 return unitdata.kv().get(key)
35 elif action == 'set':
36 unitdata.kv().set(key, value)
37 unitdata.kv().flush()
38 return ''
39 return _unitdata_cmd
400
=== added directory 'tests/charmhelpers/contrib'
=== removed directory 'tests/charmhelpers/contrib'
=== added file 'tests/charmhelpers/contrib/__init__.py'
--- tests/charmhelpers/contrib/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/__init__.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== removed file 'tests/charmhelpers/contrib/__init__.py'
--- tests/charmhelpers/contrib/__init__.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/contrib/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,15 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
160
=== added directory 'tests/charmhelpers/contrib/amulet'
=== added file 'tests/charmhelpers/contrib/amulet/__init__.py'
--- tests/charmhelpers/contrib/amulet/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/__init__.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== added file 'tests/charmhelpers/contrib/amulet/deployment.py'
--- tests/charmhelpers/contrib/amulet/deployment.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/deployment.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,93 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import amulet
18import os
19import six
20
21
22class AmuletDeployment(object):
23 """Amulet deployment.
24
25 This class provides generic Amulet deployment and test runner
26 methods.
27 """
28
29 def __init__(self, series=None):
30 """Initialize the deployment environment."""
31 self.series = None
32
33 if series:
34 self.series = series
35 self.d = amulet.Deployment(series=self.series)
36 else:
37 self.d = amulet.Deployment()
38
39 def _add_services(self, this_service, other_services):
40 """Add services.
41
42 Add services to the deployment where this_service is the local charm
43 that we're testing and other_services are the other services that
44 are being used in the local amulet tests.
45 """
46 if this_service['name'] != os.path.basename(os.getcwd()):
47 s = this_service['name']
48 msg = "The charm's root directory name needs to be {}".format(s)
49 amulet.raise_status(amulet.FAIL, msg=msg)
50
51 if 'units' not in this_service:
52 this_service['units'] = 1
53
54 self.d.add(this_service['name'], units=this_service['units'])
55
56 for svc in other_services:
57 if 'location' in svc:
58 branch_location = svc['location']
59 elif self.series:
60 branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
61 else:
62 branch_location = None
63
64 if 'units' not in svc:
65 svc['units'] = 1
66
67 self.d.add(svc['name'], charm=branch_location, units=svc['units'])
68
69 def _add_relations(self, relations):
70 """Add all of the relations for the services."""
71 for k, v in six.iteritems(relations):
72 self.d.relate(k, v)
73
74 def _configure_services(self, configs):
75 """Configure all of the services."""
76 for service, config in six.iteritems(configs):
77 self.d.configure(service, config)
78
79 def _deploy(self):
80 """Deploy environment and wait for all hooks to finish executing."""
81 try:
82 self.d.setup(timeout=900)
83 self.d.sentry.wait(timeout=900)
84 except amulet.helpers.TimeoutError:
85 amulet.raise_status(amulet.FAIL, msg="Deployment timed out")
86 except Exception:
87 raise
88
89 def run_tests(self):
90 """Run all of the methods that are prefixed with 'test_'."""
91 for test in dir(self):
92 if test.startswith('test_'):
93 getattr(self, test)()
094
=== added file 'tests/charmhelpers/contrib/amulet/utils.py'
--- tests/charmhelpers/contrib/amulet/utils.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/amulet/utils.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,771 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import io
18import json
19import logging
20import os
21import re
22import socket
23import subprocess
24import sys
25import time
26import uuid
27
28import amulet
29import distro_info
30import six
31from six.moves import configparser
32if six.PY3:
33 from urllib import parse as urlparse
34else:
35 import urlparse
36
37
38class AmuletUtils(object):
39 """Amulet utilities.
40
41 This class provides common utility functions that are used by Amulet
42 tests.
43 """
44
45 def __init__(self, log_level=logging.ERROR):
46 self.log = self.get_logger(level=log_level)
47 self.ubuntu_releases = self.get_ubuntu_releases()
48
49 def get_logger(self, name="amulet-logger", level=logging.DEBUG):
50 """Get a logger object that will log to stdout."""
51 log = logging
52 logger = log.getLogger(name)
53 fmt = log.Formatter("%(asctime)s %(funcName)s "
54 "%(levelname)s: %(message)s")
55
56 handler = log.StreamHandler(stream=sys.stdout)
57 handler.setLevel(level)
58 handler.setFormatter(fmt)
59
60 logger.addHandler(handler)
61 logger.setLevel(level)
62
63 return logger
64
65 def valid_ip(self, ip):
66 if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
67 return True
68 else:
69 return False
70
71 def valid_url(self, url):
72 p = re.compile(
73 r'^(?:http|ftp)s?://'
74 r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
75 r'localhost|'
76 r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
77 r'(?::\d+)?'
78 r'(?:/?|[/?]\S+)$',
79 re.IGNORECASE)
80 if p.match(url):
81 return True
82 else:
83 return False
84
85 def get_ubuntu_release_from_sentry(self, sentry_unit):
86 """Get Ubuntu release codename from sentry unit.
87
88 :param sentry_unit: amulet sentry/service unit pointer
89 :returns: list of strings - release codename, failure message
90 """
91 msg = None
92 cmd = 'lsb_release -cs'
93 release, code = sentry_unit.run(cmd)
94 if code == 0:
95 self.log.debug('{} lsb_release: {}'.format(
96 sentry_unit.info['unit_name'], release))
97 else:
98 msg = ('{} `{}` returned {} '
99 '{}'.format(sentry_unit.info['unit_name'],
100 cmd, release, code))
101 if release not in self.ubuntu_releases:
102 msg = ("Release ({}) not found in Ubuntu releases "
103 "({})".format(release, self.ubuntu_releases))
104 return release, msg
105
106 def validate_services(self, commands):
107 """Validate that lists of commands succeed on service units. Can be
108 used to verify system services are running on the corresponding
109 service units.
110
111 :param commands: dict with sentry keys and arbitrary command list vals
112 :returns: None if successful, Failure string message otherwise
113 """
114 self.log.debug('Checking status of system services...')
115
116 # /!\ DEPRECATION WARNING (beisner):
117 # New and existing tests should be rewritten to use
118 # validate_services_by_name() as it is aware of init systems.
119 self.log.warn('/!\\ DEPRECATION WARNING: use '
120 'validate_services_by_name instead of validate_services '
121 'due to init system differences.')
122
123 for k, v in six.iteritems(commands):
124 for cmd in v:
125 output, code = k.run(cmd)
126 self.log.debug('{} `{}` returned '
127 '{}'.format(k.info['unit_name'],
128 cmd, code))
129 if code != 0:
130 return "command `{}` returned {}".format(cmd, str(code))
131 return None
132
133 def validate_services_by_name(self, sentry_services):
134 """Validate system service status by service name, automatically
135 detecting init system based on Ubuntu release codename.
136
137 :param sentry_services: dict with sentry keys and svc list values
138 :returns: None if successful, Failure string message otherwise
139 """
140 self.log.debug('Checking status of system services...')
141
142 # Point at which systemd became a thing
143 systemd_switch = self.ubuntu_releases.index('vivid')
144
145 for sentry_unit, services_list in six.iteritems(sentry_services):
146 # Get lsb_release codename from unit
147 release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
148 if ret:
149 return ret
150
151 for service_name in services_list:
152 if (self.ubuntu_releases.index(release) >= systemd_switch or
153 service_name in ['rabbitmq-server', 'apache2']):
154 # init is systemd (or regular sysv)
155 cmd = 'sudo service {} status'.format(service_name)
156 output, code = sentry_unit.run(cmd)
157 service_running = code == 0
158 elif self.ubuntu_releases.index(release) < systemd_switch:
159 # init is upstart
160 cmd = 'sudo status {}'.format(service_name)
161 output, code = sentry_unit.run(cmd)
162 service_running = code == 0 and "start/running" in output
163
164 self.log.debug('{} `{}` returned '
165 '{}'.format(sentry_unit.info['unit_name'],
166 cmd, code))
167 if not service_running:
168 return u"command `{}` returned {} {}".format(
169 cmd, output, str(code))
170 return None
171
172 def _get_config(self, unit, filename):
173 """Get a ConfigParser object for parsing a unit's config file."""
174 file_contents = unit.file_contents(filename)
175
176 # NOTE(beisner): by default, ConfigParser does not handle options
177 # with no value, such as the flags used in the mysql my.cnf file.
178 # https://bugs.python.org/issue7005
179 config = configparser.ConfigParser(allow_no_value=True)
180 config.readfp(io.StringIO(file_contents))
181 return config
182
183 def validate_config_data(self, sentry_unit, config_file, section,
184 expected):
185 """Validate config file data.
186
187 Verify that the specified section of the config file contains
188 the expected option key:value pairs.
189
190 Compare expected dictionary data vs actual dictionary data.
191 The values in the 'expected' dictionary can be strings, bools, ints,
192 longs, or can be a function that evaluates a variable and returns a
193 bool.
194 """
195 self.log.debug('Validating config file data ({} in {} on {})'
196 '...'.format(section, config_file,
197 sentry_unit.info['unit_name']))
198 config = self._get_config(sentry_unit, config_file)
199
200 if section != 'DEFAULT' and not config.has_section(section):
201 return "section [{}] does not exist".format(section)
202
203 for k in expected.keys():
204 if not config.has_option(section, k):
205 return "section [{}] is missing option {}".format(section, k)
206
207 actual = config.get(section, k)
208 v = expected[k]
209 if (isinstance(v, six.string_types) or
210 isinstance(v, bool) or
211 isinstance(v, six.integer_types)):
212 # handle explicit values
213 if actual != v:
214 return "section [{}] {}:{} != expected {}:{}".format(
215 section, k, actual, k, expected[k])
216 # handle function pointers, such as not_null or valid_ip
217 elif not v(actual):
218 return "section [{}] {}:{} != expected {}:{}".format(
219 section, k, actual, k, expected[k])
220 return None
221
222 def _validate_dict_data(self, expected, actual):
223 """Validate dictionary data.
224
225 Compare expected dictionary data vs actual dictionary data.
226 The values in the 'expected' dictionary can be strings, bools, ints,
227 longs, or can be a function that evaluates a variable and returns a
228 bool.
229 """
230 self.log.debug('actual: {}'.format(repr(actual)))
231 self.log.debug('expected: {}'.format(repr(expected)))
232
233 for k, v in six.iteritems(expected):
234 if k in actual:
235 if (isinstance(v, six.string_types) or
236 isinstance(v, bool) or
237 isinstance(v, six.integer_types)):
238 # handle explicit values
239 if v != actual[k]:
240 return "{}:{}".format(k, actual[k])
241 # handle function pointers, such as not_null or valid_ip
242 elif not v(actual[k]):
243 return "{}:{}".format(k, actual[k])
244 else:
245 return "key '{}' does not exist".format(k)
246 return None
247
248 def validate_relation_data(self, sentry_unit, relation, expected):
249 """Validate actual relation data based on expected relation data."""
250 actual = sentry_unit.relation(relation[0], relation[1])
251 return self._validate_dict_data(expected, actual)
252
253 def _validate_list_data(self, expected, actual):
254 """Compare expected list vs actual list data."""
255 for e in expected:
256 if e not in actual:
257 return "expected item {} not found in actual list".format(e)
258 return None
259
260 def not_null(self, string):
261 if string is not None:
262 return True
263 else:
264 return False
265
266 def _get_file_mtime(self, sentry_unit, filename):
267 """Get last modification time of file."""
268 return sentry_unit.file_stat(filename)['mtime']
269
270 def _get_dir_mtime(self, sentry_unit, directory):
271 """Get last modification time of directory."""
272 return sentry_unit.directory_stat(directory)['mtime']
273
274 def _get_proc_start_time(self, sentry_unit, service, pgrep_full=False):
275 """Get process' start time.
276
277 Determine start time of the process based on the last modification
278 time of the /proc/pid directory. If pgrep_full is True, the process
279 name is matched against the full command line.
280 """
281 if pgrep_full:
282 cmd = 'pgrep -o -f {}'.format(service)
283 else:
284 cmd = 'pgrep -o {}'.format(service)
285 cmd = cmd + ' | grep -v pgrep || exit 0'
286 cmd_out = sentry_unit.run(cmd)
287 self.log.debug('CMDout: ' + str(cmd_out))
288 if cmd_out[0]:
289 self.log.debug('Pid for %s %s' % (service, str(cmd_out[0])))
290 proc_dir = '/proc/{}'.format(cmd_out[0].strip())
291 return self._get_dir_mtime(sentry_unit, proc_dir)
292
293 def service_restarted(self, sentry_unit, service, filename,
294 pgrep_full=None, sleep_time=20):
295 """Check if service was restarted.
296
297 Compare a service's start time vs a file's last modification time
298 (such as a config file for that service) to determine if the service
299 has been restarted.
300 """
301 # /!\ DEPRECATION WARNING (beisner):
302 # This is prone to races in that no before-time is known.
303 # Use validate_service_config_changed instead.
304
305 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
306 # used instead of pgrep. pgrep_full is still passed through to ensure
307 # deprecation WARNS. lp1474030
308
309 self.log.warn('/!\\ DEPRECATION WARNING: use '
310 'validate_service_config_changed instead of '
311 'service_restarted due to known races.')
312
313 time.sleep(sleep_time)
314 if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
315 self._get_file_mtime(sentry_unit, filename)):
316 return True
317 else:
318 return False
319
320 def service_restarted_since(self, sentry_unit, mtime, service,
321 pgrep_full=None, sleep_time=20,
322 retry_count=2, retry_sleep_time=30):
323 """Check if service was been started after a given time.
324
325 Args:
326 sentry_unit (sentry): The sentry unit to check for the service on
327 mtime (float): The epoch time to check against
328 service (string): service name to look for in process table
329 pgrep_full: No longer implemented, passed for WARNs
330 sleep_time (int): Seconds to sleep before looking for process
331 retry_count (int): If service is not found, how many times to retry
332
333 Returns:
334 bool: True if service found and its start time it newer than mtime,
335 False if service is older than mtime or if service was
336 not found.
337 """
338 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
339 # used instead of pgrep. pgrep_full is still passed through to ensure
340 # deprecation WARNS. lp1474030
341
342 unit_name = sentry_unit.info['unit_name']
343 self.log.debug('Checking that %s service restarted since %s on '
344 '%s' % (service, mtime, unit_name))
345 time.sleep(sleep_time)
346 proc_start_time = None
347 tries = 0
348 while tries <= retry_count and not proc_start_time:
349 try:
350 proc_start_time = self._get_proc_start_time(sentry_unit,
351 service,
352 pgrep_full)
353 self.log.debug('Attempt {} to get {} proc start time on {} '
354 'OK'.format(tries, service, unit_name))
355 except IOError:
356 # NOTE(beisner) - race avoidance, proc may not exist yet.
357 # https://bugs.launchpad.net/charm-helpers/+bug/1474030
358 self.log.debug('Attempt {} to get {} proc start time on {} '
359 'failed'.format(tries, service, unit_name))
360 time.sleep(retry_sleep_time)
361 tries += 1
362
363 if not proc_start_time:
364 self.log.warn('No proc start time found, assuming service did '
365 'not start')
366 return False
367 if proc_start_time >= mtime:
368 self.log.debug('Proc start time is newer than provided mtime'
369 '(%s >= %s) on %s (OK)' % (proc_start_time,
370 mtime, unit_name))
371 return True
372 else:
373 self.log.warn('Proc start time (%s) is older than provided mtime '
374 '(%s) on %s, service did not '
375 'restart' % (proc_start_time, mtime, unit_name))
376 return False
377
378 def config_updated_since(self, sentry_unit, filename, mtime,
379 sleep_time=20):
380 """Check if file was modified after a given time.
381
382 Args:
383 sentry_unit (sentry): The sentry unit to check the file mtime on
384 filename (string): The file to check mtime of
385 mtime (float): The epoch time to check against
386 sleep_time (int): Seconds to sleep before looking for process
387
388 Returns:
389 bool: True if file was modified more recently than mtime, False if
390 file was modified before mtime,
391 """
392 self.log.debug('Checking %s updated since %s' % (filename, mtime))
393 time.sleep(sleep_time)
394 file_mtime = self._get_file_mtime(sentry_unit, filename)
395 if file_mtime >= mtime:
396 self.log.debug('File mtime is newer than provided mtime '
397 '(%s >= %s)' % (file_mtime, mtime))
398 return True
399 else:
400 self.log.warn('File mtime %s is older than provided mtime %s'
401 % (file_mtime, mtime))
402 return False
403
404 def validate_service_config_changed(self, sentry_unit, mtime, service,
405 filename, pgrep_full=None,
406 sleep_time=20, retry_count=2,
407 retry_sleep_time=30):
408 """Check service and file were updated after mtime
409
410 Args:
411 sentry_unit (sentry): The sentry unit to check for the service on
412 mtime (float): The epoch time to check against
413 service (string): service name to look for in process table
414 filename (string): The file to check mtime of
415 pgrep_full: No longer implemented, passed for WARNs
416 sleep_time (int): Initial sleep in seconds to pass to test helpers
417 retry_count (int): If service is not found, how many times to retry
418 retry_sleep_time (int): Time in seconds to wait between retries
419
420 Typical Usage:
421 u = OpenStackAmuletUtils(ERROR)
422 ...
423 mtime = u.get_sentry_time(self.cinder_sentry)
424 self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
425 if not u.validate_service_config_changed(self.cinder_sentry,
426 mtime,
427 'cinder-api',
428 '/etc/cinder/cinder.conf')
429 amulet.raise_status(amulet.FAIL, msg='update failed')
430 Returns:
431 bool: True if both service and file where updated/restarted after
432 mtime, False if service is older than mtime or if service was
433 not found or if filename was modified before mtime.
434 """
435
436 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
437 # used instead of pgrep. pgrep_full is still passed through to ensure
438 # deprecation WARNS. lp1474030
439
440 service_restart = self.service_restarted_since(
441 sentry_unit, mtime,
442 service,
443 pgrep_full=pgrep_full,
444 sleep_time=sleep_time,
445 retry_count=retry_count,
446 retry_sleep_time=retry_sleep_time)
447
448 config_update = self.config_updated_since(
449 sentry_unit,
450 filename,
451 mtime,
452 sleep_time=0)
453
454 return service_restart and config_update
455
456 def get_sentry_time(self, sentry_unit):
457 """Return current epoch time on a sentry"""
458 cmd = "date +'%s'"
459 return float(sentry_unit.run(cmd)[0])
460
461 def relation_error(self, name, data):
462 return 'unexpected relation data in {} - {}'.format(name, data)
463
464 def endpoint_error(self, name, data):
465 return 'unexpected endpoint data in {} - {}'.format(name, data)
466
467 def get_ubuntu_releases(self):
468 """Return a list of all Ubuntu releases in order of release."""
469 _d = distro_info.UbuntuDistroInfo()
470 _release_list = _d.all
471 return _release_list
472
473 def file_to_url(self, file_rel_path):
474 """Convert a relative file path to a file URL."""
475 _abs_path = os.path.abspath(file_rel_path)
476 return urlparse.urlparse(_abs_path, scheme='file').geturl()
477
478 def check_commands_on_units(self, commands, sentry_units):
479 """Check that all commands in a list exit zero on all
480 sentry units in a list.
481
482 :param commands: list of bash commands
483 :param sentry_units: list of sentry unit pointers
484 :returns: None if successful; Failure message otherwise
485 """
486 self.log.debug('Checking exit codes for {} commands on {} '
487 'sentry units...'.format(len(commands),
488 len(sentry_units)))
489 for sentry_unit in sentry_units:
490 for cmd in commands:
491 output, code = sentry_unit.run(cmd)
492 if code == 0:
493 self.log.debug('{} `{}` returned {} '
494 '(OK)'.format(sentry_unit.info['unit_name'],
495 cmd, code))
496 else:
497 return ('{} `{}` returned {} '
498 '{}'.format(sentry_unit.info['unit_name'],
499 cmd, code, output))
500 return None
501
502 def get_process_id_list(self, sentry_unit, process_name,
503 expect_success=True):
504 """Get a list of process ID(s) from a single sentry juju unit
505 for a single process name.
506
507 :param sentry_unit: Amulet sentry instance (juju unit)
508 :param process_name: Process name
509 :param expect_success: If False, expect the PID to be missing,
510 raise if it is present.
511 :returns: List of process IDs
512 """
513 cmd = 'pidof -x {}'.format(process_name)
514 if not expect_success:
515 cmd += " || exit 0 && exit 1"
516 output, code = sentry_unit.run(cmd)
517 if code != 0:
518 msg = ('{} `{}` returned {} '
519 '{}'.format(sentry_unit.info['unit_name'],
520 cmd, code, output))
521 amulet.raise_status(amulet.FAIL, msg=msg)
522 return str(output).split()
523
524 def get_unit_process_ids(self, unit_processes, expect_success=True):
525 """Construct a dict containing unit sentries, process names, and
526 process IDs.
527
528 :param unit_processes: A dictionary of Amulet sentry instance
529 to list of process names.
530 :param expect_success: if False expect the processes to not be
531 running, raise if they are.
532 :returns: Dictionary of Amulet sentry instance to dictionary
533 of process names to PIDs.
534 """
535 pid_dict = {}
536 for sentry_unit, process_list in six.iteritems(unit_processes):
537 pid_dict[sentry_unit] = {}
538 for process in process_list:
539 pids = self.get_process_id_list(
540 sentry_unit, process, expect_success=expect_success)
541 pid_dict[sentry_unit].update({process: pids})
542 return pid_dict
543
544 def validate_unit_process_ids(self, expected, actual):
545 """Validate process id quantities for services on units."""
546 self.log.debug('Checking units for running processes...')
547 self.log.debug('Expected PIDs: {}'.format(expected))
548 self.log.debug('Actual PIDs: {}'.format(actual))
549
550 if len(actual) != len(expected):
551 return ('Unit count mismatch. expected, actual: {}, '
552 '{} '.format(len(expected), len(actual)))
553
554 for (e_sentry, e_proc_names) in six.iteritems(expected):
555 e_sentry_name = e_sentry.info['unit_name']
556 if e_sentry in actual.keys():
557 a_proc_names = actual[e_sentry]
558 else:
559 return ('Expected sentry ({}) not found in actual dict data.'
560 '{}'.format(e_sentry_name, e_sentry))
561
562 if len(e_proc_names.keys()) != len(a_proc_names.keys()):
563 return ('Process name count mismatch. expected, actual: {}, '
564 '{}'.format(len(expected), len(actual)))
565
566 for (e_proc_name, e_pids_length), (a_proc_name, a_pids) in \
567 zip(e_proc_names.items(), a_proc_names.items()):
568 if e_proc_name != a_proc_name:
569 return ('Process name mismatch. expected, actual: {}, '
570 '{}'.format(e_proc_name, a_proc_name))
571
572 a_pids_length = len(a_pids)
573 fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
574 '{}, {} ({})'.format(e_sentry_name, e_proc_name,
575 e_pids_length, a_pids_length,
576 a_pids))
577
578 # If expected is not bool, ensure PID quantities match
579 if not isinstance(e_pids_length, bool) and \
580 a_pids_length != e_pids_length:
581 return fail_msg
582 # If expected is bool True, ensure 1 or more PIDs exist
583 elif isinstance(e_pids_length, bool) and \
584 e_pids_length is True and a_pids_length < 1:
585 return fail_msg
586 # If expected is bool False, ensure 0 PIDs exist
587 elif isinstance(e_pids_length, bool) and \
588 e_pids_length is False and a_pids_length != 0:
589 return fail_msg
590 else:
591 self.log.debug('PID check OK: {} {} {}: '
592 '{}'.format(e_sentry_name, e_proc_name,
593 e_pids_length, a_pids))
594 return None
595
596 def validate_list_of_identical_dicts(self, list_of_dicts):
597 """Check that all dicts within a list are identical."""
598 hashes = []
599 for _dict in list_of_dicts:
600 hashes.append(hash(frozenset(_dict.items())))
601
602 self.log.debug('Hashes: {}'.format(hashes))
603 if len(set(hashes)) == 1:
604 self.log.debug('Dicts within list are identical')
605 else:
606 return 'Dicts within list are not identical'
607
608 return None
609
610 def validate_sectionless_conf(self, file_contents, expected):
611 """A crude conf parser. Useful to inspect configuration files which
612 do not have section headers (as would be necessary in order to use
613 the configparser). Such as openstack-dashboard or rabbitmq confs."""
614 for line in file_contents.split('\n'):
615 if '=' in line:
616 args = line.split('=')
617 if len(args) <= 1:
618 continue
619 key = args[0].strip()
620 value = args[1].strip()
621 if key in expected.keys():
622 if expected[key] != value:
623 msg = ('Config mismatch. Expected, actual: {}, '
624 '{}'.format(expected[key], value))
625 amulet.raise_status(amulet.FAIL, msg=msg)
626
627 def get_unit_hostnames(self, units):
628 """Return a dict of juju unit names to hostnames."""
629 host_names = {}
630 for unit in units:
631 host_names[unit.info['unit_name']] = \
632 str(unit.file_contents('/etc/hostname').strip())
633 self.log.debug('Unit host names: {}'.format(host_names))
634 return host_names
635
636 def run_cmd_unit(self, sentry_unit, cmd):
637 """Run a command on a unit, return the output and exit code."""
638 output, code = sentry_unit.run(cmd)
639 if code == 0:
640 self.log.debug('{} `{}` command returned {} '
641 '(OK)'.format(sentry_unit.info['unit_name'],
642 cmd, code))
643 else:
644 msg = ('{} `{}` command returned {} '
645 '{}'.format(sentry_unit.info['unit_name'],
646 cmd, code, output))
647 amulet.raise_status(amulet.FAIL, msg=msg)
648 return str(output), code
649
650 def file_exists_on_unit(self, sentry_unit, file_name):
651 """Check if a file exists on a unit."""
652 try:
653 sentry_unit.file_stat(file_name)
654 return True
655 except IOError:
656 return False
657 except Exception as e:
658 msg = 'Error checking file {}: {}'.format(file_name, e)
659 amulet.raise_status(amulet.FAIL, msg=msg)
660
661 def file_contents_safe(self, sentry_unit, file_name,
662 max_wait=60, fatal=False):
663 """Get file contents from a sentry unit. Wrap amulet file_contents
664 with retry logic to address races where a file checks as existing,
665 but no longer exists by the time file_contents is called.
666 Return None if file not found. Optionally raise if fatal is True."""
667 unit_name = sentry_unit.info['unit_name']
668 file_contents = False
669 tries = 0
670 while not file_contents and tries < (max_wait / 4):
671 try:
672 file_contents = sentry_unit.file_contents(file_name)
673 except IOError:
674 self.log.debug('Attempt {} to open file {} from {} '
675 'failed'.format(tries, file_name,
676 unit_name))
677 time.sleep(4)
678 tries += 1
679
680 if file_contents:
681 return file_contents
682 elif not fatal:
683 return None
684 elif fatal:
685 msg = 'Failed to get file contents from unit.'
686 amulet.raise_status(amulet.FAIL, msg)
687
688 def port_knock_tcp(self, host="localhost", port=22, timeout=15):
689 """Open a TCP socket to check for a listening sevice on a host.
690
691 :param host: host name or IP address, default to localhost
692 :param port: TCP port number, default to 22
693 :param timeout: Connect timeout, default to 15 seconds
694 :returns: True if successful, False if connect failed
695 """
696
697 # Resolve host name if possible
698 try:
699 connect_host = socket.gethostbyname(host)
700 host_human = "{} ({})".format(connect_host, host)
701 except socket.error, e:
702 self.log.warn('Unable to resolve address: '
703 '{} ({}) Trying anyway!'.format(host, e))
704 connect_host = host
705 host_human = connect_host
706
707 # Attempt socket connection
708 try:
709 knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
710 knock.settimeout(timeout)
711 knock.connect((connect_host, port))
712 knock.close()
713 self.log.debug('Socket connect OK for host '
714 '{} on port {}.'.format(host_human, port))
715 return True
716 except socket.error, e:
717 self.log.debug('Socket connect FAIL for'
718 ' {} port {} ({})'.format(host_human, port, e))
719 return False
720
721 def port_knock_units(self, sentry_units, port=22,
722 timeout=15, expect_success=True):
723 """Open a TCP socket to check for a listening sevice on each
724 listed juju unit.
725
726 :param sentry_units: list of sentry unit pointers
727 :param port: TCP port number, default to 22
728 :param timeout: Connect timeout, default to 15 seconds
729 :expect_success: True by default, set False to invert logic
730 :returns: None if successful, Failure message otherwise
731 """
732 for unit in sentry_units:
733 host = unit.info['public-address']
734 connected = self.port_knock_tcp(host, port, timeout)
735 if not connected and expect_success:
736 return 'Socket connect failed.'
737 elif connected and not expect_success:
738 return 'Socket connected unexpectedly.'
739
740 def get_uuid_epoch_stamp(self):
741 """Returns a stamp string based on uuid4 and epoch time. Useful in
742 generating test messages which need to be unique-ish."""
743 return '[{}-{}]'.format(uuid.uuid4(), time.time())
744
745# amulet juju action helpers:
746 def run_action(self, unit_sentry, action,
747 _check_output=subprocess.check_output):
748 """Run the named action on a given unit sentry.
749
750 _check_output parameter is used for dependency injection.
751
752 @return action_id.
753 """
754 unit_id = unit_sentry.info["unit_name"]
755 command = ["juju", "action", "do", "--format=json", unit_id, action]
756 self.log.info("Running command: %s\n" % " ".join(command))
757 output = _check_output(command, universal_newlines=True)
758 data = json.loads(output)
759 action_id = data[u'Action queued with id']
760 return action_id
761
762 def wait_on_action(self, action_id, _check_output=subprocess.check_output):
763 """Wait for a given action, returning if it completed or not.
764
765 _check_output parameter is used for dependency injection.
766 """
767 command = ["juju", "action", "fetch", "--format=json", "--wait=0",
768 action_id]
769 output = _check_output(command, universal_newlines=True)
770 data = json.loads(output)
771 return data.get(u"status") == "completed"
0772
=== added directory 'tests/charmhelpers/contrib/openstack'
=== added file 'tests/charmhelpers/contrib/openstack/__init__.py'
--- tests/charmhelpers/contrib/openstack/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/openstack/__init__.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== added directory 'tests/charmhelpers/contrib/openstack/amulet'
=== added file 'tests/charmhelpers/contrib/openstack/amulet/__init__.py'
--- tests/charmhelpers/contrib/openstack/amulet/__init__.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/openstack/amulet/__init__.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,15 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
016
=== added file 'tests/charmhelpers/contrib/openstack/amulet/deployment.py'
--- tests/charmhelpers/contrib/openstack/amulet/deployment.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/openstack/amulet/deployment.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,198 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import six
18from collections import OrderedDict
19from charmhelpers.contrib.amulet.deployment import (
20 AmuletDeployment
21)
22
23
24class OpenStackAmuletDeployment(AmuletDeployment):
25 """OpenStack amulet deployment.
26
27 This class inherits from AmuletDeployment and has additional support
28 that is specifically for use by OpenStack charms.
29 """
30
31 def __init__(self, series=None, openstack=None, source=None, stable=True):
32 """Initialize the deployment environment."""
33 super(OpenStackAmuletDeployment, self).__init__(series)
34 self.openstack = openstack
35 self.source = source
36 self.stable = stable
37 # Note(coreycb): this needs to be changed when new next branches come
38 # out.
39 self.current_next = "trusty"
40
41 def _determine_branch_locations(self, other_services):
42 """Determine the branch locations for the other services.
43
44 Determine if the local branch being tested is derived from its
45 stable or next (dev) branch, and based on this, use the corresonding
46 stable or next branches for the other_services."""
47
48 # Charms outside the lp:~openstack-charmers namespace
49 base_charms = ['mysql', 'mongodb', 'nrpe']
50
51 # Force these charms to current series even when using an older series.
52 # ie. Use trusty/nrpe even when series is precise, as the P charm
53 # does not possess the necessary external master config and hooks.
54 force_series_current = ['nrpe']
55
56 if self.series in ['precise', 'trusty']:
57 base_series = self.series
58 else:
59 base_series = self.current_next
60
61 if self.stable:
62 for svc in other_services:
63 if svc['name'] in force_series_current:
64 base_series = self.current_next
65
66 temp = 'lp:charms/{}/{}'
67 svc['location'] = temp.format(base_series,
68 svc['name'])
69 else:
70 for svc in other_services:
71 if svc['name'] in force_series_current:
72 base_series = self.current_next
73
74 if svc['name'] in base_charms:
75 temp = 'lp:charms/{}/{}'
76 svc['location'] = temp.format(base_series,
77 svc['name'])
78 else:
79 temp = 'lp:~openstack-charmers/charms/{}/{}/next'
80 svc['location'] = temp.format(self.current_next,
81 svc['name'])
82 return other_services
83
84 def _add_services(self, this_service, other_services):
85 """Add services to the deployment and set openstack-origin/source."""
86 other_services = self._determine_branch_locations(other_services)
87
88 super(OpenStackAmuletDeployment, self)._add_services(this_service,
89 other_services)
90
91 services = other_services
92 services.append(this_service)
93
94 # Charms which should use the source config option
95 use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
96 'ceph-osd', 'ceph-radosgw']
97
98 # Charms which can not use openstack-origin, ie. many subordinates
99 no_origin = ['cinder-ceph', 'hacluster', 'neutron-openvswitch', 'nrpe']
100
101 if self.openstack:
102 for svc in services:
103 if svc['name'] not in use_source + no_origin:
104 config = {'openstack-origin': self.openstack}
105 self.d.configure(svc['name'], config)
106
107 if self.source:
108 for svc in services:
109 if svc['name'] in use_source and svc['name'] not in no_origin:
110 config = {'source': self.source}
111 self.d.configure(svc['name'], config)
112
113 def _configure_services(self, configs):
114 """Configure all of the services."""
115 for service, config in six.iteritems(configs):
116 self.d.configure(service, config)
117
118 def _get_openstack_release(self):
119 """Get openstack release.
120
121 Return an integer representing the enum value of the openstack
122 release.
123 """
124 # Must be ordered by OpenStack release (not by Ubuntu release):
125 (self.precise_essex, self.precise_folsom, self.precise_grizzly,
126 self.precise_havana, self.precise_icehouse,
127 self.trusty_icehouse, self.trusty_juno, self.utopic_juno,
128 self.trusty_kilo, self.vivid_kilo, self.trusty_liberty,
129 self.wily_liberty) = range(12)
130
131 releases = {
132 ('precise', None): self.precise_essex,
133 ('precise', 'cloud:precise-folsom'): self.precise_folsom,
134 ('precise', 'cloud:precise-grizzly'): self.precise_grizzly,
135 ('precise', 'cloud:precise-havana'): self.precise_havana,
136 ('precise', 'cloud:precise-icehouse'): self.precise_icehouse,
137 ('trusty', None): self.trusty_icehouse,
138 ('trusty', 'cloud:trusty-juno'): self.trusty_juno,
139 ('trusty', 'cloud:trusty-kilo'): self.trusty_kilo,
140 ('trusty', 'cloud:trusty-liberty'): self.trusty_liberty,
141 ('utopic', None): self.utopic_juno,
142 ('vivid', None): self.vivid_kilo,
143 ('wily', None): self.wily_liberty}
144 return releases[(self.series, self.openstack)]
145
146 def _get_openstack_release_string(self):
147 """Get openstack release string.
148
149 Return a string representing the openstack release.
150 """
151 releases = OrderedDict([
152 ('precise', 'essex'),
153 ('quantal', 'folsom'),
154 ('raring', 'grizzly'),
155 ('saucy', 'havana'),
156 ('trusty', 'icehouse'),
157 ('utopic', 'juno'),
158 ('vivid', 'kilo'),
159 ('wily', 'liberty'),
160 ])
161 if self.openstack:
162 os_origin = self.openstack.split(':')[1]
163 return os_origin.split('%s-' % self.series)[1].split('/')[0]
164 else:
165 return releases[self.series]
166
167 def get_ceph_expected_pools(self, radosgw=False):
168 """Return a list of expected ceph pools in a ceph + cinder + glance
169 test scenario, based on OpenStack release and whether ceph radosgw
170 is flagged as present or not."""
171
172 if self._get_openstack_release() >= self.trusty_kilo:
173 # Kilo or later
174 pools = [
175 'rbd',
176 'cinder',
177 'glance'
178 ]
179 else:
180 # Juno or earlier
181 pools = [
182 'data',
183 'metadata',
184 'rbd',
185 'cinder',
186 'glance'
187 ]
188
189 if radosgw:
190 pools.extend([
191 '.rgw.root',
192 '.rgw.control',
193 '.rgw',
194 '.rgw.gc',
195 '.users.uid'
196 ])
197
198 return pools
0199
=== added file 'tests/charmhelpers/contrib/openstack/amulet/utils.py'
--- tests/charmhelpers/contrib/openstack/amulet/utils.py 1970-01-01 00:00:00 +0000
+++ tests/charmhelpers/contrib/openstack/amulet/utils.py 2015-09-01 02:46:36 +0000
@@ -0,0 +1,965 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import amulet
18import json
19import logging
20import os
21import six
22import time
23import urllib
24
25import cinderclient.v1.client as cinder_client
26import glanceclient.v1.client as glance_client
27import heatclient.v1.client as heat_client
28import keystoneclient.v2_0 as keystone_client
29import novaclient.v1_1.client as nova_client
30import pika
31import swiftclient
32
33from charmhelpers.contrib.amulet.utils import (
34 AmuletUtils
35)
36
37DEBUG = logging.DEBUG
38ERROR = logging.ERROR
39
40
41class OpenStackAmuletUtils(AmuletUtils):
42 """OpenStack amulet utilities.
43
44 This class inherits from AmuletUtils and has additional support
45 that is specifically for use by OpenStack charm tests.
46 """
47
48 def __init__(self, log_level=ERROR):
49 """Initialize the deployment environment."""
50 super(OpenStackAmuletUtils, self).__init__(log_level)
51
52 def validate_endpoint_data(self, endpoints, admin_port, internal_port,
53 public_port, expected):
54 """Validate endpoint data.
55
56 Validate actual endpoint data vs expected endpoint data. The ports
57 are used to find the matching endpoint.
58 """
59 self.log.debug('Validating endpoint data...')
60 self.log.debug('actual: {}'.format(repr(endpoints)))
61 found = False
62 for ep in endpoints:
63 self.log.debug('endpoint: {}'.format(repr(ep)))
64 if (admin_port in ep.adminurl and
65 internal_port in ep.internalurl and
66 public_port in ep.publicurl):
67 found = True
68 actual = {'id': ep.id,
69 'region': ep.region,
70 'adminurl': ep.adminurl,
71 'internalurl': ep.internalurl,
72 'publicurl': ep.publicurl,
73 'service_id': ep.service_id}
74 ret = self._validate_dict_data(expected, actual)
75 if ret:
76 return 'unexpected endpoint data - {}'.format(ret)
77
78 if not found:
79 return 'endpoint not found'
80
81 def validate_svc_catalog_endpoint_data(self, expected, actual):
82 """Validate service catalog endpoint data.
83
84 Validate a list of actual service catalog endpoints vs a list of
85 expected service catalog endpoints.
86 """
87 self.log.debug('Validating service catalog endpoint data...')
88 self.log.debug('actual: {}'.format(repr(actual)))
89 for k, v in six.iteritems(expected):
90 if k in actual:
91 ret = self._validate_dict_data(expected[k][0], actual[k][0])
92 if ret:
93 return self.endpoint_error(k, ret)
94 else:
95 return "endpoint {} does not exist".format(k)
96 return ret
97
98 def validate_tenant_data(self, expected, actual):
99 """Validate tenant data.
100
101 Validate a list of actual tenant data vs list of expected tenant
102 data.
103 """
104 self.log.debug('Validating tenant data...')
105 self.log.debug('actual: {}'.format(repr(actual)))
106 for e in expected:
107 found = False
108 for act in actual:
109 a = {'enabled': act.enabled, 'description': act.description,
110 'name': act.name, 'id': act.id}
111 if e['name'] == a['name']:
112 found = True
113 ret = self._validate_dict_data(e, a)
114 if ret:
115 return "unexpected tenant data - {}".format(ret)
116 if not found:
117 return "tenant {} does not exist".format(e['name'])
118 return ret
119
120 def validate_role_data(self, expected, actual):
121 """Validate role data.
122
123 Validate a list of actual role data vs a list of expected role
124 data.
125 """
126 self.log.debug('Validating role data...')
127 self.log.debug('actual: {}'.format(repr(actual)))
128 for e in expected:
129 found = False
130 for act in actual:
131 a = {'name': act.name, 'id': act.id}
132 if e['name'] == a['name']:
133 found = True
134 ret = self._validate_dict_data(e, a)
135 if ret:
136 return "unexpected role data - {}".format(ret)
137 if not found:
138 return "role {} does not exist".format(e['name'])
139 return ret
140
141 def validate_user_data(self, expected, actual):
142 """Validate user data.
143
144 Validate a list of actual user data vs a list of expected user
145 data.
146 """
147 self.log.debug('Validating user data...')
148 self.log.debug('actual: {}'.format(repr(actual)))
149 for e in expected:
150 found = False
151 for act in actual:
152 a = {'enabled': act.enabled, 'name': act.name,
153 'email': act.email, 'tenantId': act.tenantId,
154 'id': act.id}
155 if e['name'] == a['name']:
156 found = True
157 ret = self._validate_dict_data(e, a)
158 if ret:
159 return "unexpected user data - {}".format(ret)
160 if not found:
161 return "user {} does not exist".format(e['name'])
162 return ret
163
164 def validate_flavor_data(self, expected, actual):
165 """Validate flavor data.
166
167 Validate a list of actual flavors vs a list of expected flavors.
168 """
169 self.log.debug('Validating flavor data...')
170 self.log.debug('actual: {}'.format(repr(actual)))
171 act = [a.name for a in actual]
172 return self._validate_list_data(expected, act)
173
174 def tenant_exists(self, keystone, tenant):
175 """Return True if tenant exists."""
176 self.log.debug('Checking if tenant exists ({})...'.format(tenant))
177 return tenant in [t.name for t in keystone.tenants.list()]
178
179 def authenticate_cinder_admin(self, keystone_sentry, username,
180 password, tenant):
181 """Authenticates admin user with cinder."""
182 # NOTE(beisner): cinder python client doesn't accept tokens.
183 service_ip = \
184 keystone_sentry.relation('shared-db',
185 'mysql:shared-db')['private-address']
186 ept = "http://{}:5000/v2.0".format(service_ip.strip().decode('utf-8'))
187 return cinder_client.Client(username, password, tenant, ept)
188
189 def authenticate_keystone_admin(self, keystone_sentry, user, password,
190 tenant):
191 """Authenticates admin user with the keystone admin endpoint."""
192 self.log.debug('Authenticating keystone admin...')
193 unit = keystone_sentry
194 service_ip = unit.relation('shared-db',
195 'mysql:shared-db')['private-address']
196 ep = "http://{}:35357/v2.0".format(service_ip.strip().decode('utf-8'))
197 return keystone_client.Client(username=user, password=password,
198 tenant_name=tenant, auth_url=ep)
199
200 def authenticate_keystone_user(self, keystone, user, password, tenant):
201 """Authenticates a regular user with the keystone public endpoint."""
202 self.log.debug('Authenticating keystone user ({})...'.format(user))
203 ep = keystone.service_catalog.url_for(service_type='identity',
204 endpoint_type='publicURL')
205 return keystone_client.Client(username=user, password=password,
206 tenant_name=tenant, auth_url=ep)
207
208 def authenticate_glance_admin(self, keystone):
209 """Authenticates admin user with glance."""
210 self.log.debug('Authenticating glance admin...')
211 ep = keystone.service_catalog.url_for(service_type='image',
212 endpoint_type='adminURL')
213 return glance_client.Client(ep, token=keystone.auth_token)
214
215 def authenticate_heat_admin(self, keystone):
216 """Authenticates the admin user with heat."""
217 self.log.debug('Authenticating heat admin...')
218 ep = keystone.service_catalog.url_for(service_type='orchestration',
219 endpoint_type='publicURL')
220 return heat_client.Client(endpoint=ep, token=keystone.auth_token)
221
222 def authenticate_nova_user(self, keystone, user, password, tenant):
223 """Authenticates a regular user with nova-api."""
224 self.log.debug('Authenticating nova user ({})...'.format(user))
225 ep = keystone.service_catalog.url_for(service_type='identity',
226 endpoint_type='publicURL')
227 return nova_client.Client(username=user, api_key=password,
228 project_id=tenant, auth_url=ep)
229
230 def authenticate_swift_user(self, keystone, user, password, tenant):
231 """Authenticates a regular user with swift api."""
232 self.log.debug('Authenticating swift user ({})...'.format(user))
233 ep = keystone.service_catalog.url_for(service_type='identity',
234 endpoint_type='publicURL')
235 return swiftclient.Connection(authurl=ep,
236 user=user,
237 key=password,
238 tenant_name=tenant,
239 auth_version='2.0')
240
241 def create_cirros_image(self, glance, image_name):
242 """Download the latest cirros image and upload it to glance,
243 validate and return a resource pointer.
244
245 :param glance: pointer to authenticated glance connection
246 :param image_name: display name for new image
247 :returns: glance image pointer
248 """
249 self.log.debug('Creating glance cirros image '
250 '({})...'.format(image_name))
251
252 # Download cirros image
253 http_proxy = os.getenv('AMULET_HTTP_PROXY')
254 self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
255 if http_proxy:
256 proxies = {'http': http_proxy}
257 opener = urllib.FancyURLopener(proxies)
258 else:
259 opener = urllib.FancyURLopener()
260
261 f = opener.open('http://download.cirros-cloud.net/version/released')
262 version = f.read().strip()
263 cirros_img = 'cirros-{}-x86_64-disk.img'.format(version)
264 local_path = os.path.join('tests', cirros_img)
265
266 if not os.path.exists(local_path):
267 cirros_url = 'http://{}/{}/{}'.format('download.cirros-cloud.net',
268 version, cirros_img)
269 opener.retrieve(cirros_url, local_path)
270 f.close()
271
272 # Create glance image
273 with open(local_path) as f:
274 image = glance.images.create(name=image_name, is_public=True,
275 disk_format='qcow2',
276 container_format='bare', data=f)
277
278 # Wait for image to reach active status
279 img_id = image.id
280 ret = self.resource_reaches_status(glance.images, img_id,
281 expected_stat='active',
282 msg='Image status wait')
283 if not ret:
284 msg = 'Glance image failed to reach expected state.'
285 amulet.raise_status(amulet.FAIL, msg=msg)
286
287 # Re-validate new image
288 self.log.debug('Validating image attributes...')
289 val_img_name = glance.images.get(img_id).name
290 val_img_stat = glance.images.get(img_id).status
291 val_img_pub = glance.images.get(img_id).is_public
292 val_img_cfmt = glance.images.get(img_id).container_format
293 val_img_dfmt = glance.images.get(img_id).disk_format
294 msg_attr = ('Image attributes - name:{} public:{} id:{} stat:{} '
295 'container fmt:{} disk fmt:{}'.format(
296 val_img_name, val_img_pub, img_id,
297 val_img_stat, val_img_cfmt, val_img_dfmt))
298
299 if val_img_name == image_name and val_img_stat == 'active' \
300 and val_img_pub is True and val_img_cfmt == 'bare' \
301 and val_img_dfmt == 'qcow2':
302 self.log.debug(msg_attr)
303 else:
304 msg = ('Volume validation failed, {}'.format(msg_attr))
305 amulet.raise_status(amulet.FAIL, msg=msg)
306
307 return image
308
309 def delete_image(self, glance, image):
310 """Delete the specified image."""
311
312 # /!\ DEPRECATION WARNING
313 self.log.warn('/!\\ DEPRECATION WARNING: use '
314 'delete_resource instead of delete_image.')
315 self.log.debug('Deleting glance image ({})...'.format(image))
316 return self.delete_resource(glance.images, image, msg='glance image')
317
318 def create_instance(self, nova, image_name, instance_name, flavor):
319 """Create the specified instance."""
320 self.log.debug('Creating instance '
321 '({}|{}|{})'.format(instance_name, image_name, flavor))
322 image = nova.images.find(name=image_name)
323 flavor = nova.flavors.find(name=flavor)
324 instance = nova.servers.create(name=instance_name, image=image,
325 flavor=flavor)
326
327 count = 1
328 status = instance.status
329 while status != 'ACTIVE' and count < 60:
330 time.sleep(3)
331 instance = nova.servers.get(instance.id)
332 status = instance.status
333 self.log.debug('instance status: {}'.format(status))
334 count += 1
335
336 if status != 'ACTIVE':
337 self.log.error('instance creation timed out')
338 return None
339
340 return instance
341
342 def delete_instance(self, nova, instance):
343 """Delete the specified instance."""
344
345 # /!\ DEPRECATION WARNING
346 self.log.warn('/!\\ DEPRECATION WARNING: use '
347 'delete_resource instead of delete_instance.')
348 self.log.debug('Deleting instance ({})...'.format(instance))
349 return self.delete_resource(nova.servers, instance,
350 msg='nova instance')
351
352 def create_or_get_keypair(self, nova, keypair_name="testkey"):
353 """Create a new keypair, or return pointer if it already exists."""
354 try:
355 _keypair = nova.keypairs.get(keypair_name)
356 self.log.debug('Keypair ({}) already exists, '
357 'using it.'.format(keypair_name))
358 return _keypair
359 except:
360 self.log.debug('Keypair ({}) does not exist, '
361 'creating it.'.format(keypair_name))
362
363 _keypair = nova.keypairs.create(name=keypair_name)
364 return _keypair
365
366 def create_cinder_volume(self, cinder, vol_name="demo-vol", vol_size=1,
367 img_id=None, src_vol_id=None, snap_id=None):
368 """Create cinder volume, optionally from a glance image, OR
369 optionally as a clone of an existing volume, OR optionally
370 from a snapshot. Wait for the new volume status to reach
371 the expected status, validate and return a resource pointer.
372
373 :param vol_name: cinder volume display name
374 :param vol_size: size in gigabytes
375 :param img_id: optional glance image id
376 :param src_vol_id: optional source volume id to clone
377 :param snap_id: optional snapshot id to use
378 :returns: cinder volume pointer
379 """
380 # Handle parameter input and avoid impossible combinations
381 if img_id and not src_vol_id and not snap_id:
382 # Create volume from image
383 self.log.debug('Creating cinder volume from glance image...')
384 bootable = 'true'
385 elif src_vol_id and not img_id and not snap_id:
386 # Clone an existing volume
387 self.log.debug('Cloning cinder volume...')
388 bootable = cinder.volumes.get(src_vol_id).bootable
389 elif snap_id and not src_vol_id and not img_id:
390 # Create volume from snapshot
391 self.log.debug('Creating cinder volume from snapshot...')
392 snap = cinder.volume_snapshots.find(id=snap_id)
393 vol_size = snap.size
394 snap_vol_id = cinder.volume_snapshots.get(snap_id).volume_id
395 bootable = cinder.volumes.get(snap_vol_id).bootable
396 elif not img_id and not src_vol_id and not snap_id:
397 # Create volume
398 self.log.debug('Creating cinder volume...')
399 bootable = 'false'
400 else:
401 # Impossible combination of parameters
402 msg = ('Invalid method use - name:{} size:{} img_id:{} '
403 'src_vol_id:{} snap_id:{}'.format(vol_name, vol_size,
404 img_id, src_vol_id,
405 snap_id))
406 amulet.raise_status(amulet.FAIL, msg=msg)
407
408 # Create new volume
409 try:
410 vol_new = cinder.volumes.create(display_name=vol_name,
411 imageRef=img_id,
412 size=vol_size,
413 source_volid=src_vol_id,
414 snapshot_id=snap_id)
415 vol_id = vol_new.id
416 except Exception as e:
417 msg = 'Failed to create volume: {}'.format(e)
418 amulet.raise_status(amulet.FAIL, msg=msg)
419
420 # Wait for volume to reach available status
421 ret = self.resource_reaches_status(cinder.volumes, vol_id,
422 expected_stat="available",
423 msg="Volume status wait")
424 if not ret:
425 msg = 'Cinder volume failed to reach expected state.'
426 amulet.raise_status(amulet.FAIL, msg=msg)
427
428 # Re-validate new volume
429 self.log.debug('Validating volume attributes...')
430 val_vol_name = cinder.volumes.get(vol_id).display_name
431 val_vol_boot = cinder.volumes.get(vol_id).bootable
432 val_vol_stat = cinder.volumes.get(vol_id).status
433 val_vol_size = cinder.volumes.get(vol_id).size
434 msg_attr = ('Volume attributes - name:{} id:{} stat:{} boot:'
435 '{} size:{}'.format(val_vol_name, vol_id,
436 val_vol_stat, val_vol_boot,
437 val_vol_size))
438
439 if val_vol_boot == bootable and val_vol_stat == 'available' \
440 and val_vol_name == vol_name and val_vol_size == vol_size:
441 self.log.debug(msg_attr)
442 else:
443 msg = ('Volume validation failed, {}'.format(msg_attr))
444 amulet.raise_status(amulet.FAIL, msg=msg)
445
446 return vol_new
447
448 def delete_resource(self, resource, resource_id,
449 msg="resource", max_wait=120):
450 """Delete one openstack resource, such as one instance, keypair,
451 image, volume, stack, etc., and confirm deletion within max wait time.
452
453 :param resource: pointer to os resource type, ex:glance_client.images
454 :param resource_id: unique name or id for the openstack resource
455 :param msg: text to identify purpose in logging
456 :param max_wait: maximum wait time in seconds
457 :returns: True if successful, otherwise False
458 """
459 self.log.debug('Deleting OpenStack resource '
460 '{} ({})'.format(resource_id, msg))
461 num_before = len(list(resource.list()))
462 resource.delete(resource_id)
463
464 tries = 0
465 num_after = len(list(resource.list()))
466 while num_after != (num_before - 1) and tries < (max_wait / 4):
467 self.log.debug('{} delete check: '
468 '{} [{}:{}] {}'.format(msg, tries,
469 num_before,
470 num_after,
471 resource_id))
472 time.sleep(4)
473 num_after = len(list(resource.list()))
474 tries += 1
475
476 self.log.debug('{}: expected, actual count = {}, '
477 '{}'.format(msg, num_before - 1, num_after))
478
479 if num_after == (num_before - 1):
480 return True
481 else:
482 self.log.error('{} delete timed out'.format(msg))
483 return False
484
485 def resource_reaches_status(self, resource, resource_id,
486 expected_stat='available',
487 msg='resource', max_wait=120):
488 """Wait for an openstack resources status to reach an
489 expected status within a specified time. Useful to confirm that
490 nova instances, cinder vols, snapshots, glance images, heat stacks
491 and other resources eventually reach the expected status.
492
493 :param resource: pointer to os resource type, ex: heat_client.stacks
494 :param resource_id: unique id for the openstack resource
495 :param expected_stat: status to expect resource to reach
496 :param msg: text to identify purpose in logging
497 :param max_wait: maximum wait time in seconds
498 :returns: True if successful, False if status is not reached
499 """
500
501 tries = 0
502 resource_stat = resource.get(resource_id).status
503 while resource_stat != expected_stat and tries < (max_wait / 4):
504 self.log.debug('{} status check: '
505 '{} [{}:{}] {}'.format(msg, tries,
506 resource_stat,
507 expected_stat,
508 resource_id))
509 time.sleep(4)
510 resource_stat = resource.get(resource_id).status
511 tries += 1
512
513 self.log.debug('{}: expected, actual status = {}, '
514 '{}'.format(msg, resource_stat, expected_stat))
515
516 if resource_stat == expected_stat:
517 return True
518 else:
519 self.log.debug('{} never reached expected status: '
520 '{}'.format(resource_id, expected_stat))
521 return False
522
523 def get_ceph_osd_id_cmd(self, index):
524 """Produce a shell command that will return a ceph-osd id."""
525 return ("`initctl list | grep 'ceph-osd ' | "
526 "awk 'NR=={} {{ print $2 }}' | "
527 "grep -o '[0-9]*'`".format(index + 1))
528
529 def get_ceph_pools(self, sentry_unit):
530 """Return a dict of ceph pools from a single ceph unit, with
531 pool name as keys, pool id as vals."""
532 pools = {}
533 cmd = 'sudo ceph osd lspools'
534 output, code = sentry_unit.run(cmd)
535 if code != 0:
536 msg = ('{} `{}` returned {} '
537 '{}'.format(sentry_unit.info['unit_name'],
538 cmd, code, output))
539 amulet.raise_status(amulet.FAIL, msg=msg)
540
541 # Example output: 0 data,1 metadata,2 rbd,3 cinder,4 glance,
542 for pool in str(output).split(','):
543 pool_id_name = pool.split(' ')
544 if len(pool_id_name) == 2:
545 pool_id = pool_id_name[0]
546 pool_name = pool_id_name[1]
547 pools[pool_name] = int(pool_id)
548
549 self.log.debug('Pools on {}: {}'.format(sentry_unit.info['unit_name'],
550 pools))
551 return pools
552
553 def get_ceph_df(self, sentry_unit):
554 """Return dict of ceph df json output, including ceph pool state.
555
556 :param sentry_unit: Pointer to amulet sentry instance (juju unit)
557 :returns: Dict of ceph df output
558 """
559 cmd = 'sudo ceph df --format=json'
560 output, code = sentry_unit.run(cmd)
561 if code != 0:
562 msg = ('{} `{}` returned {} '
563 '{}'.format(sentry_unit.info['unit_name'],
564 cmd, code, output))
565 amulet.raise_status(amulet.FAIL, msg=msg)
566 return json.loads(output)
567
568 def get_ceph_pool_sample(self, sentry_unit, pool_id=0):
569 """Take a sample of attributes of a ceph pool, returning ceph
570 pool name, object count and disk space used for the specified
571 pool ID number.
572
573 :param sentry_unit: Pointer to amulet sentry instance (juju unit)
574 :param pool_id: Ceph pool ID
575 :returns: List of pool name, object count, kb disk space used
576 """
577 df = self.get_ceph_df(sentry_unit)
578 pool_name = df['pools'][pool_id]['name']
579 obj_count = df['pools'][pool_id]['stats']['objects']
580 kb_used = df['pools'][pool_id]['stats']['kb_used']
581 self.log.debug('Ceph {} pool (ID {}): {} objects, '
582 '{} kb used'.format(pool_name, pool_id,
583 obj_count, kb_used))
584 return pool_name, obj_count, kb_used
585
586 def validate_ceph_pool_samples(self, samples, sample_type="resource pool"):
587 """Validate ceph pool samples taken over time, such as pool
588 object counts or pool kb used, before adding, after adding, and
589 after deleting items which affect those pool attributes. The
590 2nd element is expected to be greater than the 1st; 3rd is expected
591 to be less than the 2nd.
592
593 :param samples: List containing 3 data samples
594 :param sample_type: String for logging and usage context
595 :returns: None if successful, Failure message otherwise
596 """
597 original, created, deleted = range(3)
598 if samples[created] <= samples[original] or \
599 samples[deleted] >= samples[created]:
600 return ('Ceph {} samples ({}) '
601 'unexpected.'.format(sample_type, samples))
602 else:
603 self.log.debug('Ceph {} samples (OK): '
604 '{}'.format(sample_type, samples))
605 return None
606
607# rabbitmq/amdqp specific helpers:
608 def add_rmq_test_user(self, sentry_units,
609 username="testuser1", password="changeme"):
610 """Add a test user via the first rmq juju unit, check connection as
611 the new user against all sentry units.
612
613 :param sentry_units: list of sentry unit pointers
614 :param username: amqp user name, default to testuser1
615 :param password: amqp user password
616 :returns: None if successful. Raise on error.
617 """
618 self.log.debug('Adding rmq user ({})...'.format(username))
619
620 # Check that user does not already exist
621 cmd_user_list = 'rabbitmqctl list_users'
622 output, _ = self.run_cmd_unit(sentry_units[0], cmd_user_list)
623 if username in output:
624 self.log.warning('User ({}) already exists, returning '
625 'gracefully.'.format(username))
626 return
627
628 perms = '".*" ".*" ".*"'
629 cmds = ['rabbitmqctl add_user {} {}'.format(username, password),
630 'rabbitmqctl set_permissions {} {}'.format(username, perms)]
631
632 # Add user via first unit
633 for cmd in cmds:
634 output, _ = self.run_cmd_unit(sentry_units[0], cmd)
635
636 # Check connection against the other sentry_units
637 self.log.debug('Checking user connect against units...')
638 for sentry_unit in sentry_units:
639 connection = self.connect_amqp_by_unit(sentry_unit, ssl=False,
640 fatal=False,
641 username=username,
642 password=password)
643 connection.close()
644
645 def delete_rmq_test_user(self, sentry_units, username="testuser1"):
646 """Delete a rabbitmq user via the first rmq juju unit.
647
648 :param sentry_units: list of sentry unit pointers
649 :param username: amqp user name, default to testuser1
650 :param password: amqp user password
651 :returns: None if successful or no such user.
652 """
653 self.log.debug('Deleting rmq user ({})...'.format(username))
654
655 # Check that user does not already exist
656 cmd_user_list = 'rabbitmqctl list_users'
657 output, _ = self.run_cmd_unit(sentry_units[0], cmd_user_list)
658
659 if username not in output:
660 self.log.warning('User ({}) does not exist, returning '
661 'gracefully.'.format(username))
662 return
663
664 # Delete the user
665 cmd_user_del = 'rabbitmqctl delete_user {}'.format(username)
666 output, _ = self.run_cmd_unit(sentry_units[0], cmd_user_del)
667
668 def get_rmq_cluster_status(self, sentry_unit):
669 """Execute rabbitmq cluster status command on a unit and return
670 the full output.
671
672 :param unit: sentry unit
673 :returns: String containing console output of cluster status command
674 """
675 cmd = 'rabbitmqctl cluster_status'
676 output, _ = self.run_cmd_unit(sentry_unit, cmd)
677 self.log.debug('{} cluster_status:\n{}'.format(
678 sentry_unit.info['unit_name'], output))
679 return str(output)
680
681 def get_rmq_cluster_running_nodes(self, sentry_unit):
682 """Parse rabbitmqctl cluster_status output string, return list of
683 running rabbitmq cluster nodes.
684
685 :param unit: sentry unit
686 :returns: List containing node names of running nodes
687 """
688 # NOTE(beisner): rabbitmqctl cluster_status output is not
689 # json-parsable, do string chop foo, then json.loads that.
690 str_stat = self.get_rmq_cluster_status(sentry_unit)
691 if 'running_nodes' in str_stat:
692 pos_start = str_stat.find("{running_nodes,") + 15
693 pos_end = str_stat.find("]},", pos_start) + 1
694 str_run_nodes = str_stat[pos_start:pos_end].replace("'", '"')
695 run_nodes = json.loads(str_run_nodes)
696 return run_nodes
697 else:
698 return []
699
700 def validate_rmq_cluster_running_nodes(self, sentry_units):
701 """Check that all rmq unit hostnames are represented in the
702 cluster_status output of all units.
703
704 :param host_names: dict of juju unit names to host names
705 :param units: list of sentry unit pointers (all rmq units)
706 :returns: None if successful, otherwise return error message
707 """
708 host_names = self.get_unit_hostnames(sentry_units)
709 errors = []
710
711 # Query every unit for cluster_status running nodes
712 for query_unit in sentry_units:
713 query_unit_name = query_unit.info['unit_name']
714 running_nodes = self.get_rmq_cluster_running_nodes(query_unit)
715
716 # Confirm that every unit is represented in the queried unit's
717 # cluster_status running nodes output.
718 for validate_unit in sentry_units:
719 val_host_name = host_names[validate_unit.info['unit_name']]
720 val_node_name = 'rabbit@{}'.format(val_host_name)
721
722 if val_node_name not in running_nodes:
723 errors.append('Cluster member check failed on {}: {} not '
724 'in {}\n'.format(query_unit_name,
725 val_node_name,
726 running_nodes))
727 if errors:
728 return ''.join(errors)
729
730 def rmq_ssl_is_enabled_on_unit(self, sentry_unit, port=None):
731 """Check a single juju rmq unit for ssl and port in the config file."""
732 host = sentry_unit.info['public-address']
733 unit_name = sentry_unit.info['unit_name']
734
735 conf_file = '/etc/rabbitmq/rabbitmq.config'
736 conf_contents = str(self.file_contents_safe(sentry_unit,
737 conf_file, max_wait=16))
738 # Checks
739 conf_ssl = 'ssl' in conf_contents
740 conf_port = str(port) in conf_contents
741
742 # Port explicitly checked in config
743 if port and conf_port and conf_ssl:
744 self.log.debug('SSL is enabled @{}:{} '
745 '({})'.format(host, port, unit_name))
746 return True
747 elif port and not conf_port and conf_ssl:
748 self.log.debug('SSL is enabled @{} but not on port {} '
749 '({})'.format(host, port, unit_name))
750 return False
751 # Port not checked (useful when checking that ssl is disabled)
752 elif not port and conf_ssl:
753 self.log.debug('SSL is enabled @{}:{} '
754 '({})'.format(host, port, unit_name))
755 return True
756 elif not port and not conf_ssl:
757 self.log.debug('SSL not enabled @{}:{} '
758 '({})'.format(host, port, unit_name))
759 return False
760 else:
761 msg = ('Unknown condition when checking SSL status @{}:{} '
762 '({})'.format(host, port, unit_name))
763 amulet.raise_status(amulet.FAIL, msg)
764
765 def validate_rmq_ssl_enabled_units(self, sentry_units, port=None):
766 """Check that ssl is enabled on rmq juju sentry units.
767
768 :param sentry_units: list of all rmq sentry units
769 :param port: optional ssl port override to validate
770 :returns: None if successful, otherwise return error message
771 """
772 for sentry_unit in sentry_units:
773 if not self.rmq_ssl_is_enabled_on_unit(sentry_unit, port=port):
774 return ('Unexpected condition: ssl is disabled on unit '
775 '({})'.format(sentry_unit.info['unit_name']))
776 return None
777
778 def validate_rmq_ssl_disabled_units(self, sentry_units):
779 """Check that ssl is enabled on listed rmq juju sentry units.
780
781 :param sentry_units: list of all rmq sentry units
782 :returns: True if successful. Raise on error.
783 """
784 for sentry_unit in sentry_units:
785 if self.rmq_ssl_is_enabled_on_unit(sentry_unit):
786 return ('Unexpected condition: ssl is enabled on unit '
787 '({})'.format(sentry_unit.info['unit_name']))
788 return None
789
790 def configure_rmq_ssl_on(self, sentry_units, deployment,
791 port=None, max_wait=60):
792 """Turn ssl charm config option on, with optional non-default
793 ssl port specification. Confirm that it is enabled on every
794 unit.
795
796 :param sentry_units: list of sentry units
797 :param deployment: amulet deployment object pointer
798 :param port: amqp port, use defaults if None
799 :param max_wait: maximum time to wait in seconds to confirm
800 :returns: None if successful. Raise on error.
801 """
802 self.log.debug('Setting ssl charm config option: on')
803
804 # Enable RMQ SSL
805 config = {'ssl': 'on'}
806 if port:
807 config['ssl_port'] = port
808
809 deployment.configure('rabbitmq-server', config)
810
811 # Confirm
812 tries = 0
813 ret = self.validate_rmq_ssl_enabled_units(sentry_units, port=port)
814 while ret and tries < (max_wait / 4):
815 time.sleep(4)
816 self.log.debug('Attempt {}: {}'.format(tries, ret))
817 ret = self.validate_rmq_ssl_enabled_units(sentry_units, port=port)
818 tries += 1
819
820 if ret:
821 amulet.raise_status(amulet.FAIL, ret)
822
823 def configure_rmq_ssl_off(self, sentry_units, deployment, max_wait=60):
824 """Turn ssl charm config option off, confirm that it is disabled
825 on every unit.
826
827 :param sentry_units: list of sentry units
828 :param deployment: amulet deployment object pointer
829 :param max_wait: maximum time to wait in seconds to confirm
830 :returns: None if successful. Raise on error.
831 """
832 self.log.debug('Setting ssl charm config option: off')
833
834 # Disable RMQ SSL
835 config = {'ssl': 'off'}
836 deployment.configure('rabbitmq-server', config)
837
838 # Confirm
839 tries = 0
840 ret = self.validate_rmq_ssl_disabled_units(sentry_units)
841 while ret and tries < (max_wait / 4):
842 time.sleep(4)
843 self.log.debug('Attempt {}: {}'.format(tries, ret))
844 ret = self.validate_rmq_ssl_disabled_units(sentry_units)
845 tries += 1
846
847 if ret:
848 amulet.raise_status(amulet.FAIL, ret)
849
850 def connect_amqp_by_unit(self, sentry_unit, ssl=False,
851 port=None, fatal=True,
852 username="testuser1", password="changeme"):
853 """Establish and return a pika amqp connection to the rabbitmq service
854 running on a rmq juju unit.
855
856 :param sentry_unit: sentry unit pointer
857 :param ssl: boolean, default to False
858 :param port: amqp port, use defaults if None
859 :param fatal: boolean, default to True (raises on connect error)
860 :param username: amqp user name, default to testuser1
861 :param password: amqp user password
862 :returns: pika amqp connection pointer or None if failed and non-fatal
863 """
864 host = sentry_unit.info['public-address']
865 unit_name = sentry_unit.info['unit_name']
866
867 # Default port logic if port is not specified
868 if ssl and not port:
869 port = 5671
870 elif not ssl and not port:
871 port = 5672
872
873 self.log.debug('Connecting to amqp on {}:{} ({}) as '
874 '{}...'.format(host, port, unit_name, username))
875
876 try:
877 credentials = pika.PlainCredentials(username, password)
878 parameters = pika.ConnectionParameters(host=host, port=port,
879 credentials=credentials,
880 ssl=ssl,
881 #heartbeat_interval=0,
882 connection_attempts=3,
883 retry_delay=5,
884 socket_timeout=1)
885 connection = pika.BlockingConnection(parameters)
886 assert connection.server_properties['product'] == 'RabbitMQ'
887 self.log.debug('Connect OK')
888 return connection
889 except Exception as e:
890 msg = ('amqp connection failed to {}:{} as '
891 '{} ({})'.format(host, port, username, str(e)))
892 if fatal:
893 amulet.raise_status(amulet.FAIL, msg)
894 else:
895 self.log.warn(msg)
896 return None
897
898 def publish_amqp_message_by_unit(self, sentry_unit, message,
899 queue="test", ssl=False,
900 username="testuser1",
901 password="changeme",
902 port=None):
903 """Publish an amqp message to a rmq juju unit.
904
905 :param sentry_unit: sentry unit pointer
906 :param message: amqp message string
907 :param queue: message queue, default to test
908 :param username: amqp user name, default to testuser1
909 :param password: amqp user password
910 :param ssl: boolean, default to False
911 :param port: amqp port, use defaults if None
912 :returns: None. Raises exception if publish failed.
913 """
914 self.log.debug('Publishing message to {} queue:\n{}'.format(queue,
915 message))
916 connection = self.connect_amqp_by_unit(sentry_unit, ssl=ssl,
917 port=port,
918 username=username,
919 password=password)
920
921 # NOTE(beisner): extra debug here re: pika hang potential:
922 # https://github.com/pika/pika/issues/297
923 # https://groups.google.com/forum/#!topic/rabbitmq-users/Ja0iyfF0Szw
924 self.log.debug('Defining channel...')
925 channel = connection.channel()
926 self.log.debug('Declaring queue...')
927 channel.queue_declare(queue=queue, auto_delete=False, durable=True)
928 self.log.debug('Publishing message...')
929 channel.basic_publish(exchange='', routing_key=queue, body=message)
930 self.log.debug('Closing channel...')
931 channel.close()
932 self.log.debug('Closing connection...')
933 connection.close()
934
935 def get_amqp_message_by_unit(self, sentry_unit, queue="test",
936 username="testuser1",
937 password="changeme",
938 ssl=False, port=None):
939 """Get an amqp message from a rmq juju unit.
940
941 :param sentry_unit: sentry unit pointer
942 :param queue: message queue, default to test
943 :param username: amqp user name, default to testuser1
944 :param password: amqp user password
945 :param ssl: boolean, default to False
946 :param port: amqp port, use defaults if None
947 :returns: amqp message body as string. Raise if get fails.
948 """
949 connection = self.connect_amqp_by_unit(sentry_unit, ssl=ssl,
950 port=port,
951 username=username,
952 password=password)
953 channel = connection.channel()
954 method_frame, _, body = channel.basic_get(queue)
955
956 if method_frame:
957 self.log.debug('Retreived message from {} queue:\n{}'.format(queue,
958 body))
959 channel.basic_ack(method_frame.delivery_tag)
960 channel.close()
961 connection.close()
962 return body
963 else:
964 msg = 'No message retrieved.'
965 amulet.raise_status(amulet.FAIL, msg)
0966
=== removed directory 'tests/charmhelpers/contrib/ssl'
=== removed file 'tests/charmhelpers/contrib/ssl/__init__.py'
--- tests/charmhelpers/contrib/ssl/__init__.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/contrib/ssl/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,94 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import subprocess
18from charmhelpers.core import hookenv
19
20
21def generate_selfsigned(keyfile, certfile, keysize="1024", config=None, subject=None, cn=None):
22 """Generate selfsigned SSL keypair
23
24 You must provide one of the 3 optional arguments:
25 config, subject or cn
26 If more than one is provided the leftmost will be used
27
28 Arguments:
29 keyfile -- (required) full path to the keyfile to be created
30 certfile -- (required) full path to the certfile to be created
31 keysize -- (optional) SSL key length
32 config -- (optional) openssl configuration file
33 subject -- (optional) dictionary with SSL subject variables
34 cn -- (optional) cerfificate common name
35
36 Required keys in subject dict:
37 cn -- Common name (eq. FQDN)
38
39 Optional keys in subject dict
40 country -- Country Name (2 letter code)
41 state -- State or Province Name (full name)
42 locality -- Locality Name (eg, city)
43 organization -- Organization Name (eg, company)
44 organizational_unit -- Organizational Unit Name (eg, section)
45 email -- Email Address
46 """
47
48 cmd = []
49 if config:
50 cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
51 "rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
52 "-keyout", keyfile,
53 "-out", certfile, "-config", config]
54 elif subject:
55 ssl_subject = ""
56 if "country" in subject:
57 ssl_subject = ssl_subject + "/C={}".format(subject["country"])
58 if "state" in subject:
59 ssl_subject = ssl_subject + "/ST={}".format(subject["state"])
60 if "locality" in subject:
61 ssl_subject = ssl_subject + "/L={}".format(subject["locality"])
62 if "organization" in subject:
63 ssl_subject = ssl_subject + "/O={}".format(subject["organization"])
64 if "organizational_unit" in subject:
65 ssl_subject = ssl_subject + "/OU={}".format(subject["organizational_unit"])
66 if "cn" in subject:
67 ssl_subject = ssl_subject + "/CN={}".format(subject["cn"])
68 else:
69 hookenv.log("When using \"subject\" argument you must "
70 "provide \"cn\" field at very least")
71 return False
72 if "email" in subject:
73 ssl_subject = ssl_subject + "/emailAddress={}".format(subject["email"])
74
75 cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
76 "rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
77 "-keyout", keyfile,
78 "-out", certfile, "-subj", ssl_subject]
79 elif cn:
80 cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
81 "rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
82 "-keyout", keyfile,
83 "-out", certfile, "-subj", "/CN={}".format(cn)]
84
85 if not cmd:
86 hookenv.log("No config, subject or cn provided,"
87 "unable to generate self signed SSL certificates")
88 return False
89 try:
90 subprocess.check_call(cmd)
91 return True
92 except Exception as e:
93 print("Execution of openssl command failed:\n{}".format(e))
94 return False
950
=== removed file 'tests/charmhelpers/contrib/ssl/service.py'
--- tests/charmhelpers/contrib/ssl/service.py 2015-04-16 21:35:24 +0000
+++ tests/charmhelpers/contrib/ssl/service.py 1970-01-01 00:00:00 +0000
@@ -1,279 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18from os.path import join as path_join
19from os.path import exists
20import subprocess
21
22from charmhelpers.core.hookenv import log, DEBUG
23
24STD_CERT = "standard"
25
26# Mysql server is fairly picky about cert creation
27# and types, spec its creation separately for now.
28MYSQL_CERT = "mysql"
29
30
31class ServiceCA(object):
32
33 default_expiry = str(365 * 2)
34 default_ca_expiry = str(365 * 6)
35
36 def __init__(self, name, ca_dir, cert_type=STD_CERT):
37 self.name = name
38 self.ca_dir = ca_dir
39 self.cert_type = cert_type
40
41 ###############
42 # Hook Helper API
43 @staticmethod
44 def get_ca(type=STD_CERT):
45 service_name = os.environ['JUJU_UNIT_NAME'].split('/')[0]
46 ca_path = os.path.join(os.environ['CHARM_DIR'], 'ca')
47 ca = ServiceCA(service_name, ca_path, type)
48 ca.init()
49 return ca
50
51 @classmethod
52 def get_service_cert(cls, type=STD_CERT):
53 service_name = os.environ['JUJU_UNIT_NAME'].split('/')[0]
54 ca = cls.get_ca()
55 crt, key = ca.get_or_create_cert(service_name)
56 return crt, key, ca.get_ca_bundle()
57
58 ###############
59
60 def init(self):
61 log("initializing service ca", level=DEBUG)
62 if not exists(self.ca_dir):
63 self._init_ca_dir(self.ca_dir)
64 self._init_ca()
65
66 @property
67 def ca_key(self):
68 return path_join(self.ca_dir, 'private', 'cacert.key')
69
70 @property
71 def ca_cert(self):
72 return path_join(self.ca_dir, 'cacert.pem')
73
74 @property
75 def ca_conf(self):
76 return path_join(self.ca_dir, 'ca.cnf')
77
78 @property
79 def signing_conf(self):
80 return path_join(self.ca_dir, 'signing.cnf')
81
82 def _init_ca_dir(self, ca_dir):
83 os.mkdir(ca_dir)
84 for i in ['certs', 'crl', 'newcerts', 'private']:
85 sd = path_join(ca_dir, i)
86 if not exists(sd):
87 os.mkdir(sd)
88
89 if not exists(path_join(ca_dir, 'serial')):
90 with open(path_join(ca_dir, 'serial'), 'w') as fh:
91 fh.write('02\n')
92
93 if not exists(path_join(ca_dir, 'index.txt')):
94 with open(path_join(ca_dir, 'index.txt'), 'w') as fh:
95 fh.write('')
96
97 def _init_ca(self):
98 """Generate the root ca's cert and key.
99 """
100 if not exists(path_join(self.ca_dir, 'ca.cnf')):
101 with open(path_join(self.ca_dir, 'ca.cnf'), 'w') as fh:
102 fh.write(
103 CA_CONF_TEMPLATE % (self.get_conf_variables()))
104
105 if not exists(path_join(self.ca_dir, 'signing.cnf')):
106 with open(path_join(self.ca_dir, 'signing.cnf'), 'w') as fh:
107 fh.write(
108 SIGNING_CONF_TEMPLATE % (self.get_conf_variables()))
109
110 if exists(self.ca_cert) or exists(self.ca_key):
111 raise RuntimeError("Initialized called when CA already exists")
112 cmd = ['openssl', 'req', '-config', self.ca_conf,
113 '-x509', '-nodes', '-newkey', 'rsa',
114 '-days', self.default_ca_expiry,
115 '-keyout', self.ca_key, '-out', self.ca_cert,
116 '-outform', 'PEM']
117 output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
118 log("CA Init:\n %s" % output, level=DEBUG)
119
120 def get_conf_variables(self):
121 return dict(
122 org_name="juju",
123 org_unit_name="%s service" % self.name,
124 common_name=self.name,
125 ca_dir=self.ca_dir)
126
127 def get_or_create_cert(self, common_name):
128 if common_name in self:
129 return self.get_certificate(common_name)
130 return self.create_certificate(common_name)
131
132 def create_certificate(self, common_name):
133 if common_name in self:
134 return self.get_certificate(common_name)
135 key_p = path_join(self.ca_dir, "certs", "%s.key" % common_name)
136 crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
137 csr_p = path_join(self.ca_dir, "certs", "%s.csr" % common_name)
138 self._create_certificate(common_name, key_p, csr_p, crt_p)
139 return self.get_certificate(common_name)
140
141 def get_certificate(self, common_name):
142 if common_name not in self:
143 raise ValueError("No certificate for %s" % common_name)
144 key_p = path_join(self.ca_dir, "certs", "%s.key" % common_name)
145 crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
146 with open(crt_p) as fh:
147 crt = fh.read()
148 with open(key_p) as fh:
149 key = fh.read()
150 return crt, key
151
152 def __contains__(self, common_name):
153 crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
154 return exists(crt_p)
155
156 def _create_certificate(self, common_name, key_p, csr_p, crt_p):
157 template_vars = self.get_conf_variables()
158 template_vars['common_name'] = common_name
159 subj = '/O=%(org_name)s/OU=%(org_unit_name)s/CN=%(common_name)s' % (
160 template_vars)
161
162 log("CA Create Cert %s" % common_name, level=DEBUG)
163 cmd = ['openssl', 'req', '-sha1', '-newkey', 'rsa:2048',
164 '-nodes', '-days', self.default_expiry,
165 '-keyout', key_p, '-out', csr_p, '-subj', subj]
166 subprocess.check_call(cmd, stderr=subprocess.PIPE)
167 cmd = ['openssl', 'rsa', '-in', key_p, '-out', key_p]
168 subprocess.check_call(cmd, stderr=subprocess.PIPE)
169
170 log("CA Sign Cert %s" % common_name, level=DEBUG)
171 if self.cert_type == MYSQL_CERT:
172 cmd = ['openssl', 'x509', '-req',
173 '-in', csr_p, '-days', self.default_expiry,
174 '-CA', self.ca_cert, '-CAkey', self.ca_key,
175 '-set_serial', '01', '-out', crt_p]
176 else:
177 cmd = ['openssl', 'ca', '-config', self.signing_conf,
178 '-extensions', 'req_extensions',
179 '-days', self.default_expiry, '-notext',
180 '-in', csr_p, '-out', crt_p, '-subj', subj, '-batch']
181 log("running %s" % " ".join(cmd), level=DEBUG)
182 subprocess.check_call(cmd, stderr=subprocess.PIPE)
183
184 def get_ca_bundle(self):
185 with open(self.ca_cert) as fh:
186 return fh.read()
187
188
189CA_CONF_TEMPLATE = """
190[ ca ]
191default_ca = CA_default
192
193[ CA_default ]
194dir = %(ca_dir)s
195policy = policy_match
196database = $dir/index.txt
197serial = $dir/serial
198certs = $dir/certs
199crl_dir = $dir/crl
200new_certs_dir = $dir/newcerts
201certificate = $dir/cacert.pem
202private_key = $dir/private/cacert.key
203RANDFILE = $dir/private/.rand
204default_md = default
205
206[ req ]
207default_bits = 1024
208default_md = sha1
209
210prompt = no
211distinguished_name = ca_distinguished_name
212
213x509_extensions = ca_extensions
214
215[ ca_distinguished_name ]
216organizationName = %(org_name)s
217organizationalUnitName = %(org_unit_name)s Certificate Authority
218
219
220[ policy_match ]
221countryName = optional
222stateOrProvinceName = optional
223organizationName = match
224organizationalUnitName = optional
225commonName = supplied
226
227[ ca_extensions ]
228basicConstraints = critical,CA:true
229subjectKeyIdentifier = hash
230authorityKeyIdentifier = keyid:always, issuer
231keyUsage = cRLSign, keyCertSign
232"""
233
234
235SIGNING_CONF_TEMPLATE = """
236[ ca ]
237default_ca = CA_default
238
239[ CA_default ]
240dir = %(ca_dir)s
241policy = policy_match
242database = $dir/index.txt
243serial = $dir/serial
244certs = $dir/certs
245crl_dir = $dir/crl
246new_certs_dir = $dir/newcerts
247certificate = $dir/cacert.pem
248private_key = $dir/private/cacert.key
249RANDFILE = $dir/private/.rand
250default_md = default
251
252[ req ]
253default_bits = 1024
254default_md = sha1
255
256prompt = no
257distinguished_name = req_distinguished_name
258
259x509_extensions = req_extensions
260
261[ req_distinguished_name ]
262organizationName = %(org_name)s
263organizationalUnitName = %(org_unit_name)s machine resources
264commonName = %(common_name)s
265
266[ policy_match ]
267countryName = optional
268stateOrProvinceName = optional
269organizationName = match
270organizationalUnitName = optional
271commonName = supplied
272
273[ req_extensions ]
274basicConstraints = CA:false
275subjectKeyIdentifier = hash
276authorityKeyIdentifier = keyid:always, issuer
277keyUsage = digitalSignature, keyEncipherment, keyAgreement
278extendedKeyUsage = serverAuth, clientAuth
279"""
2800
=== removed directory 'tests/charmhelpers/core'
=== removed file 'tests/charmhelpers/core/__init__.py'
--- tests/charmhelpers/core/__init__.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/core/__init__.py 1970-01-01 00:00:00 +0000
@@ -1,15 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
160
=== removed file 'tests/charmhelpers/core/decorators.py'
--- tests/charmhelpers/core/decorators.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/core/decorators.py 1970-01-01 00:00:00 +0000
@@ -1,57 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17#
18# Copyright 2014 Canonical Ltd.
19#
20# Authors:
21# Edward Hope-Morley <opentastic@gmail.com>
22#
23
24import time
25
26from charmhelpers.core.hookenv import (
27 log,
28 INFO,
29)
30
31
32def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
33 """If the decorated function raises exception exc_type, allow num_retries
34 retry attempts before raise the exception.
35 """
36 def _retry_on_exception_inner_1(f):
37 def _retry_on_exception_inner_2(*args, **kwargs):
38 retries = num_retries
39 multiplier = 1
40 while True:
41 try:
42 return f(*args, **kwargs)
43 except exc_type:
44 if not retries:
45 raise
46
47 delay = base_delay * multiplier
48 multiplier += 1
49 log("Retrying '%s' %d more times (delay=%s)" %
50 (f.__name__, retries, delay), level=INFO)
51 retries -= 1
52 if delay:
53 time.sleep(delay)
54
55 return _retry_on_exception_inner_2
56
57 return _retry_on_exception_inner_1
580
=== removed file 'tests/charmhelpers/core/files.py'
--- tests/charmhelpers/core/files.py 2015-07-29 10:48:05 +0000
+++ tests/charmhelpers/core/files.py 1970-01-01 00:00:00 +0000
@@ -1,45 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
20__author__ = 'Jorge Niedbalski <niedbalski@ubuntu.com>'
21
22import os
23import subprocess
24
25
26def sed(filename, before, after, flags='g'):
27 """
28 Search and replaces the given pattern on filename.
29
30 :param filename: relative or absolute file path.
31 :param before: expression to be replaced (see 'man sed')
32 :param after: expression to replace with (see 'man sed')
33 :param flags: sed-compatible regex flags in example, to make
34 the search and replace case insensitive, specify ``flags="i"``.
35 The ``g`` flag is always specified regardless, so you do not
36 need to remember to include it when overriding this parameter.
37 :returns: If the sed command exit code was zero then return,
38 otherwise raise CalledProcessError.
39 """
40 expression = r's/{0}/{1}/{2}'.format(before,
41 after, flags)
42
43 return subprocess.check_call(["sed", "-i", "-r", "-e",
44 expression,
45 os.path.expanduser(filename)])
460
=== removed file 'tests/charmhelpers/core/fstab.py'
--- tests/charmhelpers/core/fstab.py 2015-04-13 22:11:34 +0000
+++ tests/charmhelpers/core/fstab.py 1970-01-01 00:00:00 +0000
@@ -1,134 +0,0 @@
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4# Copyright 2014-2015 Canonical Limited.
5#
6# This file is part of charm-helpers.
7#
8# charm-helpers is free software: you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License version 3 as
10# published by the Free Software Foundation.
11#
12# charm-helpers is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
20import io
21import os
22
23__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
24
25
26class Fstab(io.FileIO):
27 """This class extends file in order to implement a file reader/writer
28 for file `/etc/fstab`
29 """
30
31 class Entry(object):
32 """Entry class represents a non-comment line on the `/etc/fstab` file
33 """
34 def __init__(self, device, mountpoint, filesystem,
35 options, d=0, p=0):
36 self.device = device
37 self.mountpoint = mountpoint
38 self.filesystem = filesystem
39
40 if not options:
41 options = "defaults"
42
43 self.options = options
44 self.d = int(d)
45 self.p = int(p)
46
47 def __eq__(self, o):
48 return str(self) == str(o)
49
50 def __str__(self):
51 return "{} {} {} {} {} {}".format(self.device,
52 self.mountpoint,
53 self.filesystem,
54 self.options,
55 self.d,
56 self.p)
57
58 DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
59
60 def __init__(self, path=None):
61 if path:
62 self._path = path
63 else:
64 self._path = self.DEFAULT_PATH
65 super(Fstab, self).__init__(self._path, 'rb+')
66
67 def _hydrate_entry(self, line):
68 # NOTE: use split with no arguments to split on any
69 # whitespace including tabs
70 return Fstab.Entry(*filter(
71 lambda x: x not in ('', None),
72 line.strip("\n").split()))
73
74 @property
75 def entries(self):
76 self.seek(0)
77 for line in self.readlines():
78 line = line.decode('us-ascii')
79 try:
80 if line.strip() and not line.strip().startswith("#"):
81 yield self._hydrate_entry(line)
82 except ValueError:
83 pass
84
85 def get_entry_by_attr(self, attr, value):
86 for entry in self.entries:
87 e_attr = getattr(entry, attr)
88 if e_attr == value:
89 return entry
90 return None
91
92 def add_entry(self, entry):
93 if self.get_entry_by_attr('device', entry.device):
94 return False
95
96 self.write((str(entry) + '\n').encode('us-ascii'))
97 self.truncate()
98 return entry
99
100 def remove_entry(self, entry):
101 self.seek(0)
102
103 lines = [l.decode('us-ascii') for l in self.readlines()]
104
105 found = False
106 for index, line in enumerate(lines):
107 if line.strip() and not line.strip().startswith("#"):
108 if self._hydrate_entry(line) == entry:
109 found = True
110 break
111
112 if not found:
113 return False
114
115 lines.remove(line)
116
117 self.seek(0)
118 self.write(''.join(lines).encode('us-ascii'))
119 self.truncate()
120 return True
121
122 @classmethod
123 def remove_by_mountpoint(cls, mountpoint, path=None):
124 fstab = cls(path=path)
125 entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
126 if entry:
127 return fstab.remove_entry(entry)
128 return False
129
130 @classmethod
131 def add(cls, device, mountpoint, filesystem, options=None, path=None):
132 return cls(path=path).add_entry(Fstab.Entry(device,
133 mountpoint, filesystem,
134 options=options))
1350
=== removed file 'tests/charmhelpers/core/hookenv.py'
--- tests/charmhelpers/core/hookenv.py 2015-08-19 13:49:53 +0000
+++ tests/charmhelpers/core/hookenv.py 1970-01-01 00:00:00 +0000
@@ -1,896 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17"Interactions with the Juju environment"
18# Copyright 2013 Canonical Ltd.
19#
20# Authors:
21# Charm Helpers Developers <juju@lists.ubuntu.com>
22
23from __future__ import print_function
24import copy
25from distutils.version import LooseVersion
26from functools import wraps
27import glob
28import os
29import json
30import yaml
31import subprocess
32import sys
33import errno
34import tempfile
35from subprocess import CalledProcessError
36
37import six
38if not six.PY3:
39 from UserDict import UserDict
40else:
41 from collections import UserDict
42
43CRITICAL = "CRITICAL"
44ERROR = "ERROR"
45WARNING = "WARNING"
46INFO = "INFO"
47DEBUG = "DEBUG"
48MARKER = object()
49
50cache = {}
51
52
53def cached(func):
54 """Cache return values for multiple executions of func + args
55
56 For example::
57
58 @cached
59 def unit_get(attribute):
60 pass
61
62 unit_get('test')
63
64 will cache the result of unit_get + 'test' for future calls.
65 """
66 @wraps(func)
67 def wrapper(*args, **kwargs):
68 global cache
69 key = str((func, args, kwargs))
70 try:
71 return cache[key]
72 except KeyError:
73 pass # Drop out of the exception handler scope.
74 res = func(*args, **kwargs)
75 cache[key] = res
76 return res
77 wrapper._wrapped = func
78 return wrapper
79
80
81def flush(key):
82 """Flushes any entries from function cache where the
83 key is found in the function+args """
84 flush_list = []
85 for item in cache:
86 if key in item:
87 flush_list.append(item)
88 for item in flush_list:
89 del cache[item]
90
91
92def log(message, level=None):
93 """Write a message to the juju log"""
94 command = ['juju-log']
95 if level:
96 command += ['-l', level]
97 if not isinstance(message, six.string_types):
98 message = repr(message)
99 command += [message]
100 # Missing juju-log should not cause failures in unit tests
101 # Send log output to stderr
102 try:
103 subprocess.call(command)
104 except OSError as e:
105 if e.errno == errno.ENOENT:
106 if level:
107 message = "{}: {}".format(level, message)
108 message = "juju-log: {}".format(message)
109 print(message, file=sys.stderr)
110 else:
111 raise
112
113
114class Serializable(UserDict):
115 """Wrapper, an object that can be serialized to yaml or json"""
116
117 def __init__(self, obj):
118 # wrap the object
119 UserDict.__init__(self)
120 self.data = obj
121
122 def __getattr__(self, attr):
123 # See if this object has attribute.
124 if attr in ("json", "yaml", "data"):
125 return self.__dict__[attr]
126 # Check for attribute in wrapped object.
127 got = getattr(self.data, attr, MARKER)
128 if got is not MARKER:
129 return got
130 # Proxy to the wrapped object via dict interface.
131 try:
132 return self.data[attr]
133 except KeyError:
134 raise AttributeError(attr)
135
136 def __getstate__(self):
137 # Pickle as a standard dictionary.
138 return self.data
139
140 def __setstate__(self, state):
141 # Unpickle into our wrapper.
142 self.data = state
143
144 def json(self):
145 """Serialize the object to json"""
146 return json.dumps(self.data)
147
148 def yaml(self):
149 """Serialize the object to yaml"""
150 return yaml.dump(self.data)
151
152
153def execution_environment():
154 """A convenient bundling of the current execution context"""
155 context = {}
156 context['conf'] = config()
157 if relation_id():
158 context['reltype'] = relation_type()
159 context['relid'] = relation_id()
160 context['rel'] = relation_get()
161 context['unit'] = local_unit()
162 context['rels'] = relations()
163 context['env'] = os.environ
164 return context
165
166
167def in_relation_hook():
168 """Determine whether we're running in a relation hook"""
169 return 'JUJU_RELATION' in os.environ
170
171
172def relation_type():
173 """The scope for the current relation hook"""
174 return os.environ.get('JUJU_RELATION', None)
175
176
177@cached
178def relation_id(relation_name=None, service_or_unit=None):
179 """The relation ID for the current or a specified relation"""
180 if not relation_name and not service_or_unit:
181 return os.environ.get('JUJU_RELATION_ID', None)
182 elif relation_name and service_or_unit:
183 service_name = service_or_unit.split('/')[0]
184 for relid in relation_ids(relation_name):
185 remote_service = remote_service_name(relid)
186 if remote_service == service_name:
187 return relid
188 else:
189 raise ValueError('Must specify neither or both of relation_name and service_or_unit')
190
191
192def local_unit():
193 """Local unit ID"""
194 return os.environ['JUJU_UNIT_NAME']
195
196
197def remote_unit():
198 """The remote unit for the current relation hook"""
199 return os.environ.get('JUJU_REMOTE_UNIT', None)
200
201
202def service_name():
203 """The name service group this unit belongs to"""
204 return local_unit().split('/')[0]
205
206
207@cached
208def remote_service_name(relid=None):
209 """The remote service name for a given relation-id (or the current relation)"""
210 if relid is None:
211 unit = remote_unit()
212 else:
213 units = related_units(relid)
214 unit = units[0] if units else None
215 return unit.split('/')[0] if unit else None
216
217
218def hook_name():
219 """The name of the currently executing hook"""
220 return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
221
222
223class Config(dict):
224 """A dictionary representation of the charm's config.yaml, with some
225 extra features:
226
227 - See which values in the dictionary have changed since the previous hook.
228 - For values that have changed, see what the previous value was.
229 - Store arbitrary data for use in a later hook.
230
231 NOTE: Do not instantiate this object directly - instead call
232 ``hookenv.config()``, which will return an instance of :class:`Config`.
233
234 Example usage::
235
236 >>> # inside a hook
237 >>> from charmhelpers.core import hookenv
238 >>> config = hookenv.config()
239 >>> config['foo']
240 'bar'
241 >>> # store a new key/value for later use
242 >>> config['mykey'] = 'myval'
243
244
245 >>> # user runs `juju set mycharm foo=baz`
246 >>> # now we're inside subsequent config-changed hook
247 >>> config = hookenv.config()
248 >>> config['foo']
249 'baz'
250 >>> # test to see if this val has changed since last hook
251 >>> config.changed('foo')
252 True
253 >>> # what was the previous value?
254 >>> config.previous('foo')
255 'bar'
256 >>> # keys/values that we add are preserved across hooks
257 >>> config['mykey']
258 'myval'
259
260 """
261 CONFIG_FILE_NAME = '.juju-persistent-config'
262
263 def __init__(self, *args, **kw):
264 super(Config, self).__init__(*args, **kw)
265 self.implicit_save = True
266 self._prev_dict = None
267 self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
268 if os.path.exists(self.path):
269 self.load_previous()
270 atexit(self._implicit_save)
271
272 def load_previous(self, path=None):
273 """Load previous copy of config from disk.
274
275 In normal usage you don't need to call this method directly - it
276 is called automatically at object initialization.
277
278 :param path:
279
280 File path from which to load the previous config. If `None`,
281 config is loaded from the default location. If `path` is
282 specified, subsequent `save()` calls will write to the same
283 path.
284
285 """
286 self.path = path or self.path
287 with open(self.path) as f:
288 self._prev_dict = json.load(f)
289 for k, v in copy.deepcopy(self._prev_dict).items():
290 if k not in self:
291 self[k] = v
292
293 def changed(self, key):
294 """Return True if the current value for this key is different from
295 the previous value.
296
297 """
298 if self._prev_dict is None:
299 return True
300 return self.previous(key) != self.get(key)
301
302 def previous(self, key):
303 """Return previous value for this key, or None if there
304 is no previous value.
305
306 """
307 if self._prev_dict:
308 return self._prev_dict.get(key)
309 return None
310
311 def save(self):
312 """Save this config to disk.
313
314 If the charm is using the :mod:`Services Framework <services.base>`
315 or :meth:'@hook <Hooks.hook>' decorator, this
316 is called automatically at the end of successful hook execution.
317 Otherwise, it should be called directly by user code.
318
319 To disable automatic saves, set ``implicit_save=False`` on this
320 instance.
321
322 """
323 with open(self.path, 'w') as f:
324 json.dump(self, f)
325
326 def _implicit_save(self):
327 if self.implicit_save:
328 self.save()
329
330
331@cached
332def config(scope=None):
333 """Juju charm configuration"""
334 config_cmd_line = ['config-get']
335 if scope is not None:
336 config_cmd_line.append(scope)
337 config_cmd_line.append('--format=json')
338 try:
339 config_data = json.loads(
340 subprocess.check_output(config_cmd_line).decode('UTF-8'))
341 if scope is not None:
342 return config_data
343 return Config(config_data)
344 except ValueError:
345 return None
346
347
348@cached
349def relation_get(attribute=None, unit=None, rid=None):
350 """Get relation information"""
351 _args = ['relation-get', '--format=json']
352 if rid:
353 _args.append('-r')
354 _args.append(rid)
355 _args.append(attribute or '-')
356 if unit:
357 _args.append(unit)
358 try:
359 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
360 except ValueError:
361 return None
362 except CalledProcessError as e:
363 if e.returncode == 2:
364 return None
365 raise
366
367
368def relation_set(relation_id=None, relation_settings=None, **kwargs):
369 """Set relation information for the current unit"""
370 relation_settings = relation_settings if relation_settings else {}
371 relation_cmd_line = ['relation-set']
372 accepts_file = "--file" in subprocess.check_output(
373 relation_cmd_line + ["--help"], universal_newlines=True)
374 if relation_id is not None:
375 relation_cmd_line.extend(('-r', relation_id))
376 settings = relation_settings.copy()
377 settings.update(kwargs)
378 for key, value in settings.items():
379 # Force value to be a string: it always should, but some call
380 # sites pass in things like dicts or numbers.
381 if value is not None:
382 settings[key] = "{}".format(value)
383 if accepts_file:
384 # --file was introduced in Juju 1.23.2. Use it by default if
385 # available, since otherwise we'll break if the relation data is
386 # too big. Ideally we should tell relation-set to read the data from
387 # stdin, but that feature is broken in 1.23.2: Bug #1454678.
388 with tempfile.NamedTemporaryFile(delete=False) as settings_file:
389 settings_file.write(yaml.safe_dump(settings).encode("utf-8"))
390 subprocess.check_call(
391 relation_cmd_line + ["--file", settings_file.name])
392 os.remove(settings_file.name)
393 else:
394 for key, value in settings.items():
395 if value is None:
396 relation_cmd_line.append('{}='.format(key))
397 else:
398 relation_cmd_line.append('{}={}'.format(key, value))
399 subprocess.check_call(relation_cmd_line)
400 # Flush cache of any relation-gets for local unit
401 flush(local_unit())
402
403
404def relation_clear(r_id=None):
405 ''' Clears any relation data already set on relation r_id '''
406 settings = relation_get(rid=r_id,
407 unit=local_unit())
408 for setting in settings:
409 if setting not in ['public-address', 'private-address']:
410 settings[setting] = None
411 relation_set(relation_id=r_id,
412 **settings)
413
414
415@cached
416def relation_ids(reltype=None):
417 """A list of relation_ids"""
418 reltype = reltype or relation_type()
419 relid_cmd_line = ['relation-ids', '--format=json']
420 if reltype is not None:
421 relid_cmd_line.append(reltype)
422 return json.loads(
423 subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
424 return []
425
426
427@cached
428def related_units(relid=None):
429 """A list of related units"""
430 relid = relid or relation_id()
431 units_cmd_line = ['relation-list', '--format=json']
432 if relid is not None:
433 units_cmd_line.extend(('-r', relid))
434 return json.loads(
435 subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
436
437
438@cached
439def relation_for_unit(unit=None, rid=None):
440 """Get the json represenation of a unit's relation"""
441 unit = unit or remote_unit()
442 relation = relation_get(unit=unit, rid=rid)
443 for key in relation:
444 if key.endswith('-list'):
445 relation[key] = relation[key].split()
446 relation['__unit__'] = unit
447 return relation
448
449
450@cached
451def relations_for_id(relid=None):
452 """Get relations of a specific relation ID"""
453 relation_data = []
454 relid = relid or relation_ids()
455 for unit in related_units(relid):
456 unit_data = relation_for_unit(unit, relid)
457 unit_data['__relid__'] = relid
458 relation_data.append(unit_data)
459 return relation_data
460
461
462@cached
463def relations_of_type(reltype=None):
464 """Get relations of a specific type"""
465 relation_data = []
466 reltype = reltype or relation_type()
467 for relid in relation_ids(reltype):
468 for relation in relations_for_id(relid):
469 relation['__relid__'] = relid
470 relation_data.append(relation)
471 return relation_data
472
473
474@cached
475def metadata():
476 """Get the current charm metadata.yaml contents as a python object"""
477 with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
478 return yaml.safe_load(md)
479
480
481@cached
482def relation_types():
483 """Get a list of relation types supported by this charm"""
484 rel_types = []
485 md = metadata()
486 for key in ('provides', 'requires', 'peers'):
487 section = md.get(key)
488 if section:
489 rel_types.extend(section.keys())
490 return rel_types
491
492
493@cached
494def relation_to_interface(relation_name):
495 """
496 Given the name of a relation, return the interface that relation uses.
497
498 :returns: The interface name, or ``None``.
499 """
500 return relation_to_role_and_interface(relation_name)[1]
501
502
503@cached
504def relation_to_role_and_interface(relation_name):
505 """
506 Given the name of a relation, return the role and the name of the interface
507 that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
508
509 :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
510 """
511 _metadata = metadata()
512 for role in ('provides', 'requires', 'peer'):
513 interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
514 if interface:
515 return role, interface
516 return None, None
517
518
519@cached
520def role_and_interface_to_relations(role, interface_name):
521 """
522 Given a role and interface name, return a list of relation names for the
523 current charm that use that interface under that role (where role is one
524 of ``provides``, ``requires``, or ``peer``).
525
526 :returns: A list of relation names.
527 """
528 _metadata = metadata()
529 results = []
530 for relation_name, relation in _metadata.get(role, {}).items():
531 if relation['interface'] == interface_name:
532 results.append(relation_name)
533 return results
534
535
536@cached
537def interface_to_relations(interface_name):
538 """
539 Given an interface, return a list of relation names for the current
540 charm that use that interface.
541
542 :returns: A list of relation names.
543 """
544 results = []
545 for role in ('provides', 'requires', 'peer'):
546 results.extend(role_and_interface_to_relations(role, interface_name))
547 return results
548
549
550@cached
551def charm_name():
552 """Get the name of the current charm as is specified on metadata.yaml"""
553 return metadata().get('name')
554
555
556@cached
557def relations():
558 """Get a nested dictionary of relation data for all related units"""
559 rels = {}
560 for reltype in relation_types():
561 relids = {}
562 for relid in relation_ids(reltype):
563 units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
564 for unit in related_units(relid):
565 reldata = relation_get(unit=unit, rid=relid)
566 units[unit] = reldata
567 relids[relid] = units
568 rels[reltype] = relids
569 return rels
570
571
572@cached
573def is_relation_made(relation, keys='private-address'):
574 '''
575 Determine whether a relation is established by checking for
576 presence of key(s). If a list of keys is provided, they
577 must all be present for the relation to be identified as made
578 '''
579 if isinstance(keys, str):
580 keys = [keys]
581 for r_id in relation_ids(relation):
582 for unit in related_units(r_id):
583 context = {}
584 for k in keys:
585 context[k] = relation_get(k, rid=r_id,
586 unit=unit)
587 if None not in context.values():
588 return True
589 return False
590
591
592def open_port(port, protocol="TCP"):
593 """Open a service network port"""
594 _args = ['open-port']
595 _args.append('{}/{}'.format(port, protocol))
596 subprocess.check_call(_args)
597
598
599def close_port(port, protocol="TCP"):
600 """Close a service network port"""
601 _args = ['close-port']
602 _args.append('{}/{}'.format(port, protocol))
603 subprocess.check_call(_args)
604
605
606@cached
607def unit_get(attribute):
608 """Get the unit ID for the remote unit"""
609 _args = ['unit-get', '--format=json', attribute]
610 try:
611 return json.loads(subprocess.check_output(_args).decode('UTF-8'))
612 except ValueError:
613 return None
614
615
616def unit_public_ip():
617 """Get this unit's public IP address"""
618 return unit_get('public-address')
619
620
621def unit_private_ip():
622 """Get this unit's private IP address"""
623 return unit_get('private-address')
624
625
626class UnregisteredHookError(Exception):
627 """Raised when an undefined hook is called"""
628 pass
629
630
631class Hooks(object):
632 """A convenient handler for hook functions.
633
634 Example::
635
636 hooks = Hooks()
637
638 # register a hook, taking its name from the function name
639 @hooks.hook()
640 def install():
641 pass # your code here
642
643 # register a hook, providing a custom hook name
644 @hooks.hook("config-changed")
645 def config_changed():
646 pass # your code here
647
648 if __name__ == "__main__":
649 # execute a hook based on the name the program is called by
650 hooks.execute(sys.argv)
651 """
652
653 def __init__(self, config_save=None):
654 super(Hooks, self).__init__()
655 self._hooks = {}
656
657 # For unknown reasons, we allow the Hooks constructor to override
658 # config().implicit_save.
659 if config_save is not None:
660 config().implicit_save = config_save
661
662 def register(self, name, function):
663 """Register a hook"""
664 self._hooks[name] = function
665
666 def execute(self, args):
667 """Execute a registered hook based on args[0]"""
668 _run_atstart()
669 hook_name = os.path.basename(args[0])
670 if hook_name in self._hooks:
671 try:
672 self._hooks[hook_name]()
673 except SystemExit as x:
674 if x.code is None or x.code == 0:
675 _run_atexit()
676 raise
677 _run_atexit()
678 else:
679 raise UnregisteredHookError(hook_name)
680
681 def hook(self, *hook_names):
682 """Decorator, registering them as hooks"""
683 def wrapper(decorated):
684 for hook_name in hook_names:
685 self.register(hook_name, decorated)
686 else:
687 self.register(decorated.__name__, decorated)
688 if '_' in decorated.__name__:
689 self.register(
690 decorated.__name__.replace('_', '-'), decorated)
691 return decorated
692 return wrapper
693
694
695def charm_dir():
696 """Return the root directory of the current charm"""
697 return os.environ.get('CHARM_DIR')
698
699
700@cached
701def action_get(key=None):
702 """Gets the value of an action parameter, or all key/value param pairs"""
703 cmd = ['action-get']
704 if key is not None:
705 cmd.append(key)
706 cmd.append('--format=json')
707 action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
708 return action_data
709
710
711def action_set(values):
712 """Sets the values to be returned after the action finishes"""
713 cmd = ['action-set']
714 for k, v in list(values.items()):
715 cmd.append('{}={}'.format(k, v))
716 subprocess.check_call(cmd)
717
718
719def action_fail(message):
720 """Sets the action status to failed and sets the error message.
721
722 The results set by action_set are preserved."""
723 subprocess.check_call(['action-fail', message])
724
725
726def action_name():
727 """Get the name of the currently executing action."""
728 return os.environ.get('JUJU_ACTION_NAME')
729
730
731def action_uuid():
732 """Get the UUID of the currently executing action."""
733 return os.environ.get('JUJU_ACTION_UUID')
734
735
736def action_tag():
737 """Get the tag for the currently executing action."""
738 return os.environ.get('JUJU_ACTION_TAG')
739
740
741def status_set(workload_state, message):
742 """Set the workload state with a message
743
744 Use status-set to set the workload state with a message which is visible
745 to the user via juju status. If the status-set command is not found then
746 assume this is juju < 1.23 and juju-log the message unstead.
747
748 workload_state -- valid juju workload state.
749 message -- status update message
750 """
751 valid_states = ['maintenance', 'blocked', 'waiting', 'active']
752 if workload_state not in valid_states:
753 raise ValueError(
754 '{!r} is not a valid workload state'.format(workload_state)
755 )
756 cmd = ['status-set', workload_state, message]
757 try:
758 ret = subprocess.call(cmd)
759 if ret == 0:
760 return
761 except OSError as e:
762 if e.errno != errno.ENOENT:
763 raise
764 log_message = 'status-set failed: {} {}'.format(workload_state,
765 message)
766 log(log_message, level='INFO')
767
768
769def status_get():
770 """Retrieve the previously set juju workload state
771
772 If the status-set command is not found then assume this is juju < 1.23 and
773 return 'unknown'
774 """
775 cmd = ['status-get']
776 try:
777 raw_status = subprocess.check_output(cmd, universal_newlines=True)
778 status = raw_status.rstrip()
779 return status
780 except OSError as e:
781 if e.errno == errno.ENOENT:
782 return 'unknown'
783 else:
784 raise
785
786
787def translate_exc(from_exc, to_exc):
788 def inner_translate_exc1(f):
789 def inner_translate_exc2(*args, **kwargs):
790 try:
791 return f(*args, **kwargs)
792 except from_exc:
793 raise to_exc
794
795 return inner_translate_exc2
796
797 return inner_translate_exc1
798
799
800@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
801def is_leader():
802 """Does the current unit hold the juju leadership
803
804 Uses juju to determine whether the current unit is the leader of its peers
805 """
806 cmd = ['is-leader', '--format=json']
807 return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
808
809
810@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
811def leader_get(attribute=None):
812 """Juju leader get value(s)"""
813 cmd = ['leader-get', '--format=json'] + [attribute or '-']
814 return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
815
816
817@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
818def leader_set(settings=None, **kwargs):
819 """Juju leader set value(s)"""
820 # Don't log secrets.
821 # log("Juju leader-set '%s'" % (settings), level=DEBUG)
822 cmd = ['leader-set']
823 settings = settings or {}
824 settings.update(kwargs)
825 for k, v in settings.items():
826 if v is None:
827 cmd.append('{}='.format(k))
828 else:
829 cmd.append('{}={}'.format(k, v))
830 subprocess.check_call(cmd)
831
832
833@cached
834def juju_version():
835 """Full version string (eg. '1.23.3.1-trusty-amd64')"""
836 # Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
837 jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
838 return subprocess.check_output([jujud, 'version'],
839 universal_newlines=True).strip()
840
841
842@cached
843def has_juju_version(minimum_version):
844 """Return True if the Juju version is at least the provided version"""
845 return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
846
847
848_atexit = []
849_atstart = []
850
851
852def atstart(callback, *args, **kwargs):
853 '''Schedule a callback to run before the main hook.
854
855 Callbacks are run in the order they were added.
856
857 This is useful for modules and classes to perform initialization
858 and inject behavior. In particular:
859
860 - Run common code before all of your hooks, such as logging
861 the hook name or interesting relation data.
862 - Defer object or module initialization that requires a hook
863 context until we know there actually is a hook context,
864 making testing easier.
865 - Rather than requiring charm authors to include boilerplate to
866 invoke your helper's behavior, have it run automatically if
867 your object is instantiated or module imported.
868
869 This is not at all useful after your hook framework as been launched.
870 '''
871 global _atstart
872 _atstart.append((callback, args, kwargs))
873
874
875def atexit(callback, *args, **kwargs):
876 '''Schedule a callback to run on successful hook completion.
877
878 Callbacks are run in the reverse order that they were added.'''
879 _atexit.append((callback, args, kwargs))
880
881
882def _run_atstart():
883 '''Hook frameworks must invoke this before running the main hook body.'''
884 global _atstart
885 for callback, args, kwargs in _atstart:
886 callback(*args, **kwargs)
887 del _atstart[:]
888
889
890def _run_atexit():
891 '''Hook frameworks must invoke this after the main hook body has
892 successfully completed. Do not invoke it if the hook fails.'''
893 global _atexit
894 for callback, args, kwargs in reversed(_atexit):
895 callback(*args, **kwargs)
896 del _atexit[:]
8970
=== removed file 'tests/charmhelpers/core/host.py'
--- tests/charmhelpers/core/host.py 2015-08-19 13:49:53 +0000
+++ tests/charmhelpers/core/host.py 1970-01-01 00:00:00 +0000
@@ -1,570 +0,0 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# This file is part of charm-helpers.
4#
5# charm-helpers is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License version 3 as
7# published by the Free Software Foundation.
8#
9# charm-helpers is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tools for working with the host system"""
18# Copyright 2012 Canonical Ltd.
19#
20# Authors:
21# Nick Moffitt <nick.moffitt@canonical.com>
22# Matthew Wedgwood <matthew.wedgwood@canonical.com>
23
24import os
25import re
26import pwd
27import glob
28import grp
29import random
30import string
31import subprocess
32import hashlib
33from contextlib import contextmanager
34from collections import OrderedDict
35
36import six
37
38from .hookenv import log
39from .fstab import Fstab
40
41
42def service_start(service_name):
43 """Start a system service"""
44 return service('start', service_name)
45
46
47def service_stop(service_name):
48 """Stop a system service"""
49 return service('stop', service_name)
50
51
52def service_restart(service_name):
53 """Restart a system service"""
54 return service('restart', service_name)
55
56
57def service_reload(service_name, restart_on_failure=False):
58 """Reload a system service, optionally falling back to restart if
59 reload fails"""
60 service_result = service('reload', service_name)
61 if not service_result and restart_on_failure:
62 service_result = service('restart', service_name)
63 return service_result
64
65
66def service_pause(service_name, init_dir=None):
67 """Pause a system service.
68
69 Stop it, and prevent it from starting again at boot."""
70 if init_dir is None:
71 init_dir = "/etc/init"
72 stopped = service_stop(service_name)
73 # XXX: Support systemd too
74 override_path = os.path.join(
75 init_dir, '{}.override'.format(service_name))
76 with open(override_path, 'w') as fh:
77 fh.write("manual\n")
78 return stopped
79
80
81def service_resume(service_name, init_dir=None):
82 """Resume a system service.
83
84 Reenable starting again at boot. Start the service"""
85 # XXX: Support systemd too
86 if init_dir is None:
87 init_dir = "/etc/init"
88 override_path = os.path.join(
89 init_dir, '{}.override'.format(service_name))
90 if os.path.exists(override_path):
91 os.unlink(override_path)
92 started = service_start(service_name)
93 return started
94
95
96def service(action, service_name):
97 """Control a system service"""
98 cmd = ['service', service_name, action]
99 return subprocess.call(cmd) == 0
100
101
102def service_running(service):
103 """Determine whether a system service is running"""
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches