Merge lp:~michael.nelson/charm-helpers/ansible-support into lp:charm-helpers

Proposed by Michael Nelson
Status: Merged
Merged at revision: 65
Proposed branch: lp:~michael.nelson/charm-helpers/ansible-support
Merge into: lp:charm-helpers
Prerequisite: lp:~michael.nelson/charm-helpers/namespace-relation-data
Diff against target: 545 lines (+346/-62)
7 files modified
charmhelpers/contrib/ansible/__init__.py (+101/-0)
charmhelpers/contrib/saltstack/__init__.py (+17/-14)
charmhelpers/fetch/__init__.py (+1/-1)
setup.py (+1/-0)
tests/contrib/ansible/test_ansible.py (+136/-0)
tests/contrib/saltstack/test_saltstates.py (+87/-46)
tests/fetch/test_fetch.py (+3/-1)
To merge this branch: bzr merge lp:~michael.nelson/charm-helpers/ansible-support
Reviewer Review Type Date Requested Status
Matthew Wedgwood (community) Approve
Review via email: mp+176973@code.launchpad.net

Commit message

Add support for using ansible within charms.

Description of the change

Adds ansible support to charm-helpers.

I've converted a (private - sorry) charm to test out this support [1] - all works fine.

The juju config data, as well as any relation data is available to the ansible playbooks and templates using standard jinja2 syntax: {{ instance_type }}, where relation data is namespaced by the relation type, using django-like syntax: {{ solr__hostname }}.

One question I have is whether there's a better place for the juju_state_to_yaml() helper - it's now being used by both the saltstack and ansible support modules, and I assume it would be useful outside of those contexts also (to cache the juju config+relation data etc).

[1] https://code.launchpad.net/~michael.nelson/canonical-marshal/click-package-index-ansible/+merge/176972

To post a comment you must log in.
Revision history for this message
Matthew Wedgwood (mew) wrote :

Hey, Michael,

This looks awesome, thanks! I can't wait to try it out.

Regarding juju_state_to_yaml(), I'd say it belongs in core.hookenv. Bonus points for including a larger chunk of execution_environment().

-Matthew

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added directory 'charmhelpers/contrib/ansible'
=== added file 'charmhelpers/contrib/ansible/__init__.py'
--- charmhelpers/contrib/ansible/__init__.py 1970-01-01 00:00:00 +0000
+++ charmhelpers/contrib/ansible/__init__.py 2013-07-25 15:55:36 +0000
@@ -0,0 +1,101 @@
1# Copyright 2013 Canonical Ltd.
2#
3# Authors:
4# Charm Helpers Developers <juju@lists.ubuntu.com>
5"""Charm Helpers ansible - declare the state of your machines.
6
7This helper enables you to declare your machine state, rather than
8program it procedurally (and have to test each change to your procedures).
9Your install hook can be as simple as:
10
11{{{
12import charmhelpers.contrib.ansible
13
14
15def install():
16 charmhelpers.contrib.ansible.install_ansible_support()
17 charmhelpers.contrib.ansible.apply_playbook('playbooks/install.yaml')
18}}}
19
20and won't need to change (nor will its tests) when you change the machine
21state.
22
23All of your juju config and relation-data are available as template
24variables within your playbooks and templates. An install playbook looks
25something like:
26
27{{{
28---
29- hosts: localhost
30 user: root
31
32 tasks:
33 - name: Add private repositories.
34 template:
35 src: ../templates/private-repositories.list.jinja2
36 dest: /etc/apt/sources.list.d/private.list
37
38 - name: Update the cache.
39 apt: update_cache=yes
40
41 - name: Install dependencies.
42 apt: pkg={{ item }}
43 with_items:
44 - python-mimeparse
45 - python-webob
46 - sunburnt
47
48 - name: Setup groups.
49 group: name={{ item.name }} gid={{ item.gid }}
50 with_items:
51 - { name: 'deploy_user', gid: 1800 }
52 - { name: 'service_user', gid: 1500 }
53
54 ...
55}}}
56
57Read more online about playbooks[1] and standard ansible modules[2].
58
59[1] http://www.ansibleworks.com/docs/playbooks.html
60[2] http://www.ansibleworks.com/docs/modules.html
61"""
62import os
63import subprocess
64
65import charmhelpers.contrib.saltstack
66import charmhelpers.core.host
67import charmhelpers.core.hookenv
68import charmhelpers.fetch
69
70
71charm_dir = os.environ.get('CHARM_DIR', '')
72ansible_hosts_path = '/etc/ansible/hosts'
73# Ansible will automatically include any vars in the following
74# file in its inventory when run locally.
75ansible_vars_path = '/etc/ansible/host_vars/localhost'
76
77
78def install_ansible_support(from_ppa=True):
79 """Installs the ansible package.
80
81 By default it is installed from the PPA [1] linked from
82 the ansible website [2].
83
84 [1] https://launchpad.net/~rquillo/+archive/ansible
85 [2] http://www.ansibleworks.com/docs/gettingstarted.html#ubuntu-and-debian
86
87 If from_ppa is false, you must ensure that the package is available
88 from a configured repository.
89 """
90 if from_ppa:
91 charmhelpers.fetch.add_source('ppa:rquillo/ansible')
92 charmhelpers.core.host.apt_update(fatal=True)
93 charmhelpers.core.host.apt_install('ansible')
94 with open(ansible_hosts_path, 'w+') as hosts_file:
95 hosts_file.write('localhost ansible_connection=local')
96
97
98def apply_playbook(playbook):
99 charmhelpers.contrib.saltstack.juju_state_to_yaml(
100 ansible_vars_path, namespace_separator='__')
101 subprocess.check_call(['ansible-playbook', '-c', 'local', playbook])
0102
=== modified file 'charmhelpers/contrib/saltstack/__init__.py'
--- charmhelpers/contrib/saltstack/__init__.py 2013-07-11 13:38:36 +0000
+++ charmhelpers/contrib/saltstack/__init__.py 2013-07-25 15:55:36 +0000
@@ -94,7 +94,7 @@
9494
95def update_machine_state(state_path):95def update_machine_state(state_path):
96 """Update the machine state using the provided state declaration."""96 """Update the machine state using the provided state declaration."""
97 juju_config_2_grains()97 juju_state_to_yaml(salt_grains_path)
98 subprocess.check_call([98 subprocess.check_call([
99 'salt-call',99 'salt-call',
100 '--local',100 '--local',
@@ -103,8 +103,8 @@
103 ])103 ])
104104
105105
106def juju_config_2_grains():106def juju_state_to_yaml(yaml_path, namespace_separator=':'):
107 """Insert the juju config as salt grains for use in state templates.107 """Update the juju config and state in a yaml file.
108108
109 This includes any current relation-get data, and the charm109 This includes any current relation-get data, and the charm
110 directory.110 directory.
@@ -121,7 +121,10 @@
121 if relation_type is not None:121 if relation_type is not None:
122 relation_data = charmhelpers.core.hookenv.relation_get()122 relation_data = charmhelpers.core.hookenv.relation_get()
123 relation_data = dict(123 relation_data = dict(
124 ("{}:{}".format(relation_type, key), val)124 ("{relation_type}{namespace_separator}{key}".format(
125 relation_type=relation_type.replace('-', '_'),
126 key=key,
127 namespace_separator=namespace_separator), val)
125 for key, val in relation_data.items())128 for key, val in relation_data.items())
126 config.update(relation_data)129 config.update(relation_data)
127130
@@ -131,16 +134,16 @@
131 value: dumper.represent_scalar(134 value: dumper.represent_scalar(
132 u'tag:yaml.org,2002:str', value))135 u'tag:yaml.org,2002:str', value))
133136
134 grains_dir = os.path.dirname(salt_grains_path)137 yaml_dir = os.path.dirname(yaml_path)
135 if not os.path.exists(grains_dir):138 if not os.path.exists(yaml_dir):
136 os.makedirs(grains_dir)139 os.makedirs(yaml_dir)
137140
138 if os.path.exists(salt_grains_path):141 if os.path.exists(yaml_path):
139 with open(salt_grains_path, "r") as grain_file:142 with open(yaml_path, "r") as existing_vars_file:
140 grains = yaml.load(grain_file.read())143 existing_vars = yaml.load(existing_vars_file.read())
141 else:144 else:
142 grains = {}145 existing_vars = {}
143146
144 grains.update(config)147 existing_vars.update(config)
145 with open(salt_grains_path, "w+") as fp:148 with open(yaml_path, "w+") as fp:
146 fp.write(yaml.dump(grains))149 fp.write(yaml.dump(existing_vars))
147150
=== modified file 'charmhelpers/fetch/__init__.py'
--- charmhelpers/fetch/__init__.py 2013-07-03 11:42:37 +0000
+++ charmhelpers/fetch/__init__.py 2013-07-25 15:55:36 +0000
@@ -27,7 +27,7 @@
27def add_source(source, key=None):27def add_source(source, key=None):
28 if ((source.startswith('ppa:') or28 if ((source.startswith('ppa:') or
29 source.startswith('http:'))):29 source.startswith('http:'))):
30 subprocess.check_call(['add-apt-repository', source])30 subprocess.check_call(['add-apt-repository', '--yes', source])
31 elif source.startswith('cloud:'):31 elif source.startswith('cloud:'):
32 apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),32 apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),
33 fatal=True)33 fatal=True)
3434
=== modified file 'setup.py'
--- setup.py 2013-06-19 09:54:19 +0000
+++ setup.py 2013-07-25 15:55:36 +0000
@@ -20,6 +20,7 @@
20 "charmhelpers.fetch",20 "charmhelpers.fetch",
21 "charmhelpers.payload",21 "charmhelpers.payload",
22 "charmhelpers.contrib",22 "charmhelpers.contrib",
23 "charmhelpers.contrib.ansible",
23 "charmhelpers.contrib.charmhelpers",24 "charmhelpers.contrib.charmhelpers",
24 "charmhelpers.contrib.charmsupport",25 "charmhelpers.contrib.charmsupport",
25 "charmhelpers.contrib.saltstack",26 "charmhelpers.contrib.saltstack",
2627
=== added directory 'tests/contrib/ansible'
=== added file 'tests/contrib/ansible/__init__.py'
=== added file 'tests/contrib/ansible/test_ansible.py'
--- tests/contrib/ansible/test_ansible.py 1970-01-01 00:00:00 +0000
+++ tests/contrib/ansible/test_ansible.py 2013-07-25 15:55:36 +0000
@@ -0,0 +1,136 @@
1# Copyright 2013 Canonical Ltd.
2#
3# Authors:
4# Charm Helpers Developers <juju@lists.ubuntu.com>
5import mock
6import os
7import shutil
8import tempfile
9import unittest
10import yaml
11
12
13import charmhelpers.contrib.ansible
14
15
16class InstallAnsibleSupportTestCase(unittest.TestCase):
17
18 def setUp(self):
19 super(InstallAnsibleSupportTestCase, self).setUp()
20
21 patcher = mock.patch('charmhelpers.fetch')
22 self.mock_fetch = patcher.start()
23 self.addCleanup(patcher.stop)
24
25 patcher = mock.patch('charmhelpers.core')
26 self.mock_core = patcher.start()
27 self.addCleanup(patcher.stop)
28
29
30 hosts_file = tempfile.NamedTemporaryFile()
31 self.ansible_hosts_path = hosts_file.name
32 self.addCleanup(hosts_file.close)
33 patcher = mock.patch.object(charmhelpers.contrib.ansible,
34 'ansible_hosts_path',
35 self.ansible_hosts_path)
36 patcher.start()
37 self.addCleanup(patcher.stop)
38
39 def test_adds_ppa_by_default(self):
40 charmhelpers.contrib.ansible.install_ansible_support()
41
42 self.mock_fetch.add_source.assert_called_once_with(
43 'ppa:rquillo/ansible')
44 self.mock_core.host.apt_update.assert_called_once_with(fatal=True)
45 self.mock_core.host.apt_install.assert_called_once_with(
46 'ansible')
47
48 def test_no_ppa(self):
49 charmhelpers.contrib.ansible.install_ansible_support(
50 from_ppa=False)
51
52 self.assertEqual(self.mock_fetch.add_source.call_count, 0)
53 self.mock_core.host.apt_install.assert_called_once_with(
54 'ansible')
55
56 def test_writes_ansible_hosts(self):
57 with open(self.ansible_hosts_path) as hosts_file:
58 self.assertEqual(hosts_file.read(), '')
59
60 charmhelpers.contrib.ansible.install_ansible_support()
61
62 with open(self.ansible_hosts_path) as hosts_file:
63 self.assertEqual(hosts_file.read(),
64 'localhost ansible_connection=local')
65
66
67class ApplyPlaybookTestCases(unittest.TestCase):
68
69 def setUp(self):
70 super(ApplyPlaybookTestCases, self).setUp()
71
72 # Hookenv patches (a single patch to hookenv doesn't work):
73 patcher = mock.patch('charmhelpers.core.hookenv.config')
74 self.mock_config = patcher.start()
75 self.addCleanup(patcher.stop)
76 Serializable = charmhelpers.core.hookenv.Serializable
77 self.mock_config.return_value = Serializable({})
78 patcher = mock.patch('charmhelpers.core.hookenv.relation_get')
79 self.mock_relation_get = patcher.start()
80 self.mock_relation_get.return_value = {}
81 self.addCleanup(patcher.stop)
82 patcher = mock.patch('charmhelpers.core.hookenv.relation_type')
83 self.mock_relation_type = patcher.start()
84 self.mock_relation_type.return_value = None
85 self.addCleanup(patcher.stop)
86 patcher = mock.patch('charmhelpers.core.hookenv.local_unit')
87 self.mock_local_unit = patcher.start()
88 self.addCleanup(patcher.stop)
89 self.mock_local_unit.return_value = {}
90
91 patcher = mock.patch('charmhelpers.contrib.ansible.subprocess')
92 self.mock_subprocess = patcher.start()
93 self.addCleanup(patcher.stop)
94
95 etc_dir = tempfile.mkdtemp()
96 self.addCleanup(shutil.rmtree, etc_dir)
97 self.vars_path = os.path.join(etc_dir, 'ansible', 'vars.yaml')
98 patcher = mock.patch.object(charmhelpers.contrib.ansible,
99 'ansible_vars_path', self.vars_path)
100 patcher.start()
101 self.addCleanup(patcher.stop)
102
103
104 def test_calls_ansible_playbook(self):
105 charmhelpers.contrib.ansible.apply_playbook(
106 'playbooks/dependencies.yaml')
107
108 self.mock_subprocess.check_call.assert_called_once_with([
109 'ansible-playbook', '-c', 'local', 'playbooks/dependencies.yaml'])
110
111 def test_writes_vars_file(self):
112 self.assertFalse(os.path.exists(self.vars_path))
113 self.mock_config.return_value = charmhelpers.core.hookenv.Serializable({
114 'group_code_owner': 'webops_deploy',
115 'user_code_runner': 'ubunet',
116 })
117 self.mock_relation_type.return_value = 'wsgi-file'
118 self.mock_relation_get.return_value = {
119 'relation_key1': 'relation_value1',
120 'relation_key2': 'relation_value2',
121 }
122
123 charmhelpers.contrib.ansible.apply_playbook(
124 'playbooks/dependencies.yaml')
125
126 self.assertTrue(os.path.exists(self.vars_path))
127 with open(self.vars_path, 'r') as vars_file:
128 result = yaml.load(vars_file.read())
129 self.assertEqual({
130 "group_code_owner": "webops_deploy",
131 "user_code_runner": "ubunet",
132 "charm_dir": "",
133 "local_unit": {},
134 "wsgi_file__relation_key1": "relation_value1",
135 "wsgi_file__relation_key2": "relation_value2",
136 }, result)
0137
=== modified file 'tests/contrib/saltstack/test_saltstates.py'
--- tests/contrib/saltstack/test_saltstates.py 2013-07-11 13:38:36 +0000
+++ tests/contrib/saltstack/test_saltstates.py 2013-07-25 15:55:36 +0000
@@ -9,7 +9,6 @@
9import unittest9import unittest
10import yaml10import yaml
1111
12import charmhelpers.core.hookenv
13import charmhelpers.contrib.saltstack12import charmhelpers.contrib.saltstack
1413
1514
@@ -62,7 +61,7 @@
62 self.addCleanup(patcher.stop)61 self.addCleanup(patcher.stop)
6362
64 patcher = mock.patch('charmhelpers.contrib.saltstack.'63 patcher = mock.patch('charmhelpers.contrib.saltstack.'
65 'juju_config_2_grains')64 'juju_state_to_yaml')
66 self.mock_config_2_grains = patcher.start()65 self.mock_config_2_grains = patcher.start()
67 self.addCleanup(patcher.stop)66 self.addCleanup(patcher.stop)
6867
@@ -81,7 +80,7 @@
81 charmhelpers.contrib.saltstack.update_machine_state(80 charmhelpers.contrib.saltstack.update_machine_state(
82 'states/install.yaml')81 'states/install.yaml')
8382
84 self.mock_config_2_grains.assert_called_once_with()83 self.mock_config_2_grains.assert_called_once_with('/etc/salt/grains')
8584
8685
87class JujuConfig2GrainsTestCase(unittest.TestCase):86class JujuConfig2GrainsTestCase(unittest.TestCase):
@@ -108,56 +107,98 @@
108 etc_dir = tempfile.mkdtemp()107 etc_dir = tempfile.mkdtemp()
109 self.addCleanup(shutil.rmtree, etc_dir)108 self.addCleanup(shutil.rmtree, etc_dir)
110 self.grain_path = os.path.join(etc_dir, 'salt', 'grains')109 self.grain_path = os.path.join(etc_dir, 'salt', 'grains')
111 patcher = mock.patch.object(charmhelpers.contrib.saltstack,
112 'salt_grains_path', self.grain_path)
113 patcher.start()
114 self.addCleanup(patcher.stop)
115110
116 patcher = mock.patch.object(charmhelpers.contrib.saltstack,111 patcher = mock.patch.object(charmhelpers.contrib.saltstack,
117 'charm_dir', '/tmp/charm_dir')112 'charm_dir', '/tmp/charm_dir')
118 patcher.start()113 patcher.start()
119 self.addCleanup(patcher.stop)114 self.addCleanup(patcher.stop)
120115
121 def test_output_without_relation(self):116 def test_output_with_empty_relation(self):
122 self.mock_config.return_value = charmhelpers.core.hookenv.Serializable({117 self.mock_config.return_value = {
123 'group_code_owner': 'webops_deploy',118 'group_code_owner': 'webops_deploy',
124 'user_code_runner': 'ubunet',119 'user_code_runner': 'ubunet',
125 })120 }
126 self.mock_local_unit.return_value = "click-index/3"121 self.mock_local_unit.return_value = "click-index/3"
127122
128 charmhelpers.contrib.saltstack.juju_config_2_grains()123 charmhelpers.contrib.saltstack.juju_state_to_yaml(self.grain_path)
129124
130 with open(self.grain_path, 'r') as grain_file:125 with open(self.grain_path, 'r') as grain_file:
131 result = yaml.load(grain_file.read())126 result = yaml.load(grain_file.read())
132 self.assertEqual({127 self.assertEqual({
133 "charm_dir": "/tmp/charm_dir",128 "charm_dir": "/tmp/charm_dir",
134 "group_code_owner": "webops_deploy",129 "group_code_owner": "webops_deploy",
135 "user_code_runner": "ubunet",130 "user_code_runner": "ubunet",
136 "local_unit": "click-index/3",131 "local_unit": "click-index/3",
137 }, result)132 }, result)
133
134 def test_output_with_no_relation(self):
135 self.mock_config.return_value = {
136 'group_code_owner': 'webops_deploy',
137 'user_code_runner': 'ubunet',
138 }
139 self.mock_local_unit.return_value = "click-index/3"
140 self.mock_relation_get.return_value = None
141
142 charmhelpers.contrib.saltstack.juju_state_to_yaml(self.grain_path)
143
144 with open(self.grain_path, 'r') as grain_file:
145 result = yaml.load(grain_file.read())
146 self.assertEqual({
147 "charm_dir": "/tmp/charm_dir",
148 "group_code_owner": "webops_deploy",
149 "user_code_runner": "ubunet",
150 "local_unit": "click-index/3",
151 }, result)
152
138153
139 def test_output_with_relation(self):154 def test_output_with_relation(self):
140 self.mock_config.return_value = charmhelpers.core.hookenv.Serializable({155 self.mock_config.return_value = {
141 'group_code_owner': 'webops_deploy',156 'group_code_owner': 'webops_deploy',
142 'user_code_runner': 'ubunet',157 'user_code_runner': 'ubunet',
143 })158 }
144 self.mock_relation_type.return_value = 'wsgi-file'159 self.mock_relation_type.return_value = 'wsgi-file'
145 self.mock_relation_get.return_value = {160 self.mock_relation_get.return_value = {
146 'relation_key1': 'relation_value1',161 'relation_key1': 'relation_value1',
147 'relation_key2': 'relation_value2',162 'relation_key2': 'relation_value2',
148 }163 }
149 self.mock_local_unit.return_value = "click-index/3"164 self.mock_local_unit.return_value = "click-index/3"
150165
151 charmhelpers.contrib.saltstack.juju_config_2_grains()166 charmhelpers.contrib.saltstack.juju_state_to_yaml(self.grain_path)
152167
153 with open(self.grain_path, 'r') as grain_file:168 with open(self.grain_path, 'r') as grain_file:
154 result = yaml.load(grain_file.read())169 result = yaml.load(grain_file.read())
155 self.assertEqual({170 self.assertEqual({
156 "charm_dir": "/tmp/charm_dir",171 "charm_dir": "/tmp/charm_dir",
157 "group_code_owner": "webops_deploy",172 "group_code_owner": "webops_deploy",
158 "user_code_runner": "ubunet",173 "user_code_runner": "ubunet",
159 "wsgi-file:relation_key1": "relation_value1",174 "wsgi_file:relation_key1": "relation_value1",
160 "wsgi-file:relation_key2": "relation_value2",175 "wsgi_file:relation_key2": "relation_value2",
176 "local_unit": "click-index/3",
177 }, result)
178
179 def test_relation_with_separator(self):
180 self.mock_config.return_value = {
181 'group_code_owner': 'webops_deploy',
182 'user_code_runner': 'ubunet',
183 }
184 self.mock_relation_type.return_value = 'wsgi-file'
185 self.mock_relation_get.return_value = {
186 'relation_key1': 'relation_value1',
187 'relation_key2': 'relation_value2',
188 }
189 self.mock_local_unit.return_value = "click-index/3"
190
191 charmhelpers.contrib.saltstack.juju_state_to_yaml(
192 self.grain_path, namespace_separator='__')
193
194 with open(self.grain_path, 'r') as grain_file:
195 result = yaml.load(grain_file.read())
196 self.assertEqual({
197 "charm_dir": "/tmp/charm_dir",
198 "group_code_owner": "webops_deploy",
199 "user_code_runner": "ubunet",
200 "wsgi_file__relation_key1": "relation_value1",
201 "wsgi_file__relation_key2": "relation_value2",
161 "local_unit": "click-index/3",202 "local_unit": "click-index/3",
162 }, result)203 }, result)
163204
@@ -180,7 +221,7 @@
180 })221 })
181 self.mock_local_unit.return_value = "click-index/3"222 self.mock_local_unit.return_value = "click-index/3"
182223
183 charmhelpers.contrib.saltstack.juju_config_2_grains()224 charmhelpers.contrib.saltstack.juju_state_to_yaml(self.grain_path)
184225
185 with open(self.grain_path, 'r') as grain_file:226 with open(self.grain_path, 'r') as grain_file:
186 result = yaml.load(grain_file.read())227 result = yaml.load(grain_file.read())
187228
=== modified file 'tests/fetch/test_fetch.py'
--- tests/fetch/test_fetch.py 2013-07-03 11:42:37 +0000
+++ tests/fetch/test_fetch.py 2013-07-25 15:55:36 +0000
@@ -34,6 +34,7 @@
34 source = "ppa:test-ppa"34 source = "ppa:test-ppa"
35 fetch.add_source(source=source)35 fetch.add_source(source=source)
36 check_call.assert_called_with(['add-apt-repository',36 check_call.assert_called_with(['add-apt-repository',
37 '--yes',
37 source])38 source])
3839
39 @patch('subprocess.check_call')40 @patch('subprocess.check_call')
@@ -41,6 +42,7 @@
41 source = "http://archive.ubuntu.com/ubuntu raring-backports main"42 source = "http://archive.ubuntu.com/ubuntu raring-backports main"
42 fetch.add_source(source=source)43 fetch.add_source(source=source)
43 check_call.assert_called_with(['add-apt-repository',44 check_call.assert_called_with(['add-apt-repository',
45 '--yes',
44 source])46 source])
4547
46 @patch.object(fetch, 'filter_installed_packages')48 @patch.object(fetch, 'filter_installed_packages')
@@ -72,7 +74,7 @@
72 key = "akey"74 key = "akey"
73 fetch.add_source(source=source, key=key)75 fetch.add_source(source=source, key=key)
74 check_call.assert_has_calls([76 check_call.assert_has_calls([
75 call(['add-apt-repository', source]),77 call(['add-apt-repository', '--yes', source]),
76 call(['apt-key', 'import', key])78 call(['apt-key', 'import', key])
77 ])79 ])
7880

Subscribers

People subscribed via source and target branches