Merge ~pjdc/ubuntu-mirror-charm/+git/ubuntu-mirror-charm:shared-triggers into ubuntu-mirror-charm:master

Proposed by Paul Collins
Status: Merged
Approved by: Paul Collins
Approved revision: 262d992bb2b05049712d32999c02290307657e1c
Merged at revision: 9667b405eac9fc0e223190172d63ea22fc97b1e9
Proposed branch: ~pjdc/ubuntu-mirror-charm/+git/ubuntu-mirror-charm:shared-triggers
Merge into: ubuntu-mirror-charm:master
Prerequisite: ~pjdc/ubuntu-mirror-charm/+git/ubuntu-mirror-charm:remove-python-apt
Diff against target: 300 lines (+205/-17)
8 files modified
.coveragerc (+2/-0)
.gitignore (+2/-0)
hooks/hooks.py (+59/-13)
requirements.txt (+0/-0)
tests/unit/requirements.txt (+7/-0)
tests/unit/test_charm.py (+97/-0)
tests/unit/utils.py (+34/-0)
tox.ini (+4/-4)
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Haw Loeung +1 Approve
Review via email: mp+388534@code.launchpad.net

This proposal supersedes a proposal from 2020-07-31.

Commit message

merge triggers where key is the same, which is the case when a unit is both a releases and a cdimage mirror

To post a comment you must log in.
Revision history for this message
Paul Collins (pjdc) wrote :

Note pre-req.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
Paul Collins (pjdc) wrote :
Revision history for this message
Haw Loeung (hloeung) wrote :

LGTM

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

This seems good. The CallableDict implementation can be cut down significantly, per inline comments.

review: Approve
Revision history for this message
Paul Collins (pjdc) wrote :

Replies for the record.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 9667b405eac9fc0e223190172d63ea22fc97b1e9

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.coveragerc b/.coveragerc
2new file mode 100644
3index 0000000..f1565c6
4--- /dev/null
5+++ b/.coveragerc
6@@ -0,0 +1,2 @@
7+[run]
8+omit = hooks/charmhelpers/*
9diff --git a/.gitignore b/.gitignore
10index 490cc43..526bc62 100644
11--- a/.gitignore
12+++ b/.gitignore
13@@ -1,5 +1,7 @@
14 *~
15 *.charm
16+*.pyc
17+*.pyo
18 .tox
19 .coverage
20 __pycache__
21diff --git a/hooks/hooks.py b/hooks/hooks.py
22index e1f4d53..877fa43 100755
23--- a/hooks/hooks.py
24+++ b/hooks/hooks.py
25@@ -561,17 +561,48 @@ def configure_vsftp(conf, hostname):
26 log("CHARM: Finished configuring vsftp")
27
28
29-#
30-# Set up any external ssh sync triggers
31-#
32-def configure_triggers(conf, hostname):
33+SSH_TRIGGER_TEMPLATE = 'no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,command="{command}",{trigger}'
34+
35+
36+# cdimage-builder.internal triggers releases and cdimage using the
37+# same key, so when this unit has both roles, sync both of them.
38+def merge_shared_key_triggers(conf, triggers):
39+ """Merge (in place) triggers that use the same ssh key."""
40+
41+ cdimage_key = extract_ssh_public_key(triggers.get('cdimage'))
42+ releases_key = extract_ssh_public_key(triggers.get('releases'))
43+ if cdimage_key and releases_key and cdimage_key == releases_key:
44+ releases = conf.mirror_map('releases')
45+ cdimage = conf.mirror_map('cdimage')
46+ command = '( {releases_command} releases ; {cdimage_command} cdimage ) &'.format(
47+ releases_command=os.path.join(conf.script_dir(), releases['command']),
48+ cdimage_command=os.path.join(conf.script_dir(), cdimage['command']),
49+ )
50+ triggers['releases'] = SSH_TRIGGER_TEMPLATE.format(
51+ command=command,
52+ trigger=releases['trigger'],
53+ )
54+ del(triggers['cdimage'])
55+
56+
57+def extract_ssh_public_key(key):
58+ """Return the field after the field that begins with "ssh-", which
59+ should be the key itself. This function can therefore cope with
60+ fairly arbitrarily-decorated ssh keys, e.g. those with "from", etc."""
61+
62+ kelts = key.split(' ')
63+ while kelts:
64+ # This doesn't work for every type of key, but nobody should be using ECDSA anyway.
65+ if kelts.pop(0).startswith('ssh-'):
66+ return kelts[0]
67+ return None
68+
69+
70+def make_triggers(conf, hostname):
71+ """Return this unit's triggers in a dict, keyed by role."""
72 roles = conf.roles()
73- if hostname not in roles:
74- return
75
76- template = 'no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,command="${command}",${trigger}'
77- tmpl_data = {}
78- contents = ""
79+ triggers = {}
80
81 for role in roles[hostname]:
82 try:
83@@ -583,15 +614,30 @@ def configure_triggers(conf, hostname):
84 continue
85 else:
86 if mirror["trigger"]:
87- tmpl_data["command"] = "%s %s &" % (os.path.join(conf.script_dir(), mirror["command"]), role)
88- tmpl_data["trigger"] = mirror["trigger"]
89- contents += str(Template(template, tmpl_data)) + "\n"
90+ triggers[role] = SSH_TRIGGER_TEMPLATE.format(
91+ command="%s %s &" % (os.path.join(conf.script_dir(), mirror["command"]), role),
92+ trigger=mirror["trigger"],
93+ )
94+
95+ return triggers
96+
97+
98+#
99+# Set up any external ssh sync triggers
100+#
101+def configure_triggers(conf, hostname):
102+ roles = conf.roles()
103+ if hostname not in roles:
104+ return
105+
106+ triggers = make_triggers(conf, hostname)
107+ merge_shared_key_triggers(conf, triggers)
108
109 keyfile = get_ssh_keyfile(conf.mirror_user())
110 ssh_dir = os.path.dirname(keyfile)
111 if not os.path.isdir(ssh_dir):
112 mkdir(ssh_dir)
113- write_file(keyfile, contents)
114+ write_file(keyfile, '\n'.join(triggers.values()) + '\n')
115
116
117 def configure_nrpe(conf, hostname): # noqa: C901
118diff --git a/requirements.txt b/requirements.txt
119new file mode 100644
120index 0000000..e69de29
121--- /dev/null
122+++ b/requirements.txt
123diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
124new file mode 100644
125index 0000000..449073d
126--- /dev/null
127+++ b/tests/unit/requirements.txt
128@@ -0,0 +1,7 @@
129+# test requirements
130+mock
131+pytest
132+pytest-cov
133+# charm requirements
134+Cheetah
135+pyyaml
136diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
137new file mode 100644
138index 0000000..41508f6
139--- /dev/null
140+++ b/tests/unit/test_charm.py
141@@ -0,0 +1,97 @@
142+import json
143+import unittest
144+
145+from base64 import b64encode
146+from copy import deepcopy
147+from mock import patch
148+
149+import Config # for mockery
150+import hooks # noqa: F401; for mockery
151+
152+from hooks import (
153+ SSH_TRIGGER_TEMPLATE,
154+ configure_triggers,
155+ extract_ssh_public_key,
156+ make_triggers,
157+ merge_shared_key_triggers,
158+)
159+
160+from utils import (
161+ CallableDict,
162+ read_config_yaml,
163+)
164+
165+BARE_RELEASES_SSH_KEY = "AAAAB3NzaC1yc2EAAAABIwAAAQEA7TIXsws/s48o4N1MA+TpaudHS8XVF79JujYQGTumroZZqbsr04lbg1FaJ9HyS9P31e8lP2bmB5rexJaMyfAxDTV+DtrHXmeLi/XcGSOXoliOa/j3eQmdZyYme9z+Tfp8s3S6nxuDP3BRKkOuIzOPgPO5fr8AH7y83v3fyHi+H0sbm6agX2b+xV9oP8+DEgSxPNfZEVTgpdJ7/ULwAl68mWQW0w6VTVx0CMYF+cvcorcZMGmy+THzk8WH2XRalA5HqeiBtS7vJ+yFHJ5WbQOEUeR6gHRcrogR4Yd10eFWUNAFyvt53zF7W91PEgnJWjcCUN/uctybAFSgDK1UB6TPkQ==" # noqa: E501
166+RELEASES_SSH_KEY = 'from="91.189.90.151,91.189.89.127,10.22.96.61,10.22.96.161" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA7TIXsws/s48o4N1MA+TpaudHS8XVF79JujYQGTumroZZqbsr04lbg1FaJ9HyS9P31e8lP2bmB5rexJaMyfAxDTV+DtrHXmeLi/XcGSOXoliOa/j3eQmdZyYme9z+Tfp8s3S6nxuDP3BRKkOuIzOPgPO5fr8AH7y83v3fyHi+H0sbm6agX2b+xV9oP8+DEgSxPNfZEVTgpdJ7/ULwAl68mWQW0w6VTVx0CMYF+cvcorcZMGmy+THzk8WH2XRalA5HqeiBtS7vJ+yFHJ5WbQOEUeR6gHRcrogR4Yd10eFWUNAFyvt53zF7W91PEgnJWjcCUN/uctybAFSgDK1UB6TPkQ== cjwatson@little' # noqa: E501
167+
168+
169+MERGEABLE_TRIGGERS_CONFIG = {
170+ 'mirror_cdimage_trigger': b64encode(RELEASES_SSH_KEY),
171+ 'mirror_releases_trigger': b64encode(RELEASES_SSH_KEY),
172+ 'role_map': json.dumps({
173+ 'localhost': ['cdimage', 'releases'],
174+ })
175+}
176+
177+MERGEABLE_TRIGGERS = {
178+ 'cdimage': SSH_TRIGGER_TEMPLATE.format(
179+ command='/srv/ubuntu-mirror/bin/mirror-1stage.sh cdimage &',
180+ trigger=RELEASES_SSH_KEY,
181+ ),
182+ 'releases': SSH_TRIGGER_TEMPLATE.format(
183+ command='/srv/ubuntu-mirror/bin/mirror-1stage.sh releases &',
184+ trigger=RELEASES_SSH_KEY,
185+ ),
186+}
187+
188+MERGED_TRIGGERS = {
189+ 'releases': SSH_TRIGGER_TEMPLATE.format(
190+ command='( /srv/ubuntu-mirror/bin/mirror-1stage.sh releases ; /srv/ubuntu-mirror/bin/mirror-1stage.sh cdimage ) &', # noqa: E501
191+ trigger=RELEASES_SSH_KEY,
192+ ),
193+}
194+
195+
196+MERGEABLE_TRIGGERS_SSH_AUTHORIZED_KEYS = 'no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,command="( /srv/ubuntu-mirror/bin/mirror-1stage.sh releases ; /srv/ubuntu-mirror/bin/mirror-1stage.sh cdimage ) &",from="91.189.90.151,91.189.89.127,10.22.96.61,10.22.96.161" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA7TIXsws/s48o4N1MA+TpaudHS8XVF79JujYQGTumroZZqbsr04lbg1FaJ9HyS9P31e8lP2bmB5rexJaMyfAxDTV+DtrHXmeLi/XcGSOXoliOa/j3eQmdZyYme9z+Tfp8s3S6nxuDP3BRKkOuIzOPgPO5fr8AH7y83v3fyHi+H0sbm6agX2b+xV9oP8+DEgSxPNfZEVTgpdJ7/ULwAl68mWQW0w6VTVx0CMYF+cvcorcZMGmy+THzk8WH2XRalA5HqeiBtS7vJ+yFHJ5WbQOEUeR6gHRcrogR4Yd10eFWUNAFyvt53zF7W91PEgnJWjcCUN/uctybAFSgDK1UB6TPkQ== cjwatson@little\n' # noqa: E501
197+
198+
199+class TestUbuntuMirrorCharm(unittest.TestCase):
200+ def setUp(self):
201+ self.default_config = read_config_yaml()
202+ self.maxDiff = None
203+
204+ def test_extract_ssh_public_key(self):
205+ self.assertEqual(extract_ssh_public_key(RELEASES_SSH_KEY), BARE_RELEASES_SSH_KEY)
206+
207+ @patch('Config.config')
208+ def test_make_triggers(self, _config):
209+ config = CallableDict(self.default_config)
210+ config.update(MERGEABLE_TRIGGERS_CONFIG)
211+ _config.side_effect = config # i.e. we want our CallableDict to supply the charm config
212+ conf = Config.Config()
213+ triggers = make_triggers(conf, 'localhost')
214+ self.assertEqual(triggers, MERGEABLE_TRIGGERS)
215+
216+ @patch('Config.config')
217+ def test_merge_shared_key_triggers(self, _config):
218+ config = CallableDict(self.default_config)
219+ config.update(MERGEABLE_TRIGGERS_CONFIG)
220+ _config.side_effect = config # i.e. we want our CallableDict to supply the charm config
221+ conf = Config.Config()
222+ triggers = deepcopy(MERGEABLE_TRIGGERS)
223+ merge_shared_key_triggers(conf, triggers)
224+ self.assertEqual(triggers, MERGED_TRIGGERS)
225+
226+ @patch('hooks.os.path.isdir')
227+ @patch('hooks.get_ssh_keyfile')
228+ @patch('hooks.write_file')
229+ @patch('Config.config')
230+ def test_configure_triggers(self, _config, _write_file, _get_ssh_keyfile, _isdir):
231+ config = CallableDict(self.default_config)
232+ config.update(MERGEABLE_TRIGGERS_CONFIG)
233+ _config.side_effect = config # i.e. we want our CallableDict to supply the charm config
234+ conf = Config.Config()
235+ _get_ssh_keyfile.return_value = '/etc/ssh/user-authorized-keys/{}'.format(config('mirror_user'))
236+ _isdir.return_value = True
237+ configure_triggers(conf, 'localhost')
238+ _write_file.assert_called_with(_get_ssh_keyfile.return_value, MERGEABLE_TRIGGERS_SSH_AUTHORIZED_KEYS)
239diff --git a/tests/unit/utils.py b/tests/unit/utils.py
240new file mode 100644
241index 0000000..1e7cffd
242--- /dev/null
243+++ b/tests/unit/utils.py
244@@ -0,0 +1,34 @@
245+import yaml
246+
247+
248+# The Config class fails if config items aren't present, so it's
249+# simplest to just read config.yaml and mock hookenv.config().
250+def read_config_yaml():
251+ """Read config.yaml and return a dict keyed by setting, with their
252+ defaults as values, stringified a la config-get."""
253+
254+ with open('config.yaml') as config_yaml:
255+ config = yaml.safe_load(config_yaml)
256+ return {k: str(v['default']) for k, v in config['options'].items() if 'default' in v}
257+
258+
259+# Used to mock hookenv.config().
260+class CallableDict(dict):
261+ """Wrapper for dict type: d(x) <==> d[x]. Intended as a drop-in
262+ replacement (or mock) for functions such as config() while
263+ allowing responses to be easily configured. Example use, which
264+ assumes that something() is imported from somelib, and somelib did
265+ "from charmhelpers.core.hookenv import config":
266+
267+ from somelib import something
268+
269+ class TestSomething(unittest.TestCase):
270+ @patch('somelib.config')
271+ def test_something(self, _config):
272+ _config.side_effect = CallableDict({'key': 'value'})
273+ self.assertTrue(something())
274+
275+ """
276+
277+ def __call__(self, *args, **kwargs):
278+ return self.__getitem__(*args, **kwargs)
279diff --git a/tox.ini b/tox.ini
280index 7898f03..39a574c 100644
281--- a/tox.ini
282+++ b/tox.ini
283@@ -10,13 +10,13 @@ setenv =
284
285 [testenv:unit]
286 commands =
287- pytest --ignore mod --ignore {toxinidir}/tests/functional \
288- {posargs:-v --cov=src --cov-report=term-missing --cov-branch}
289+ pytest --ignore {toxinidir}/tests/functional --cov-config={toxinidir}/.coveragerc \
290+ {posargs:-v --cov=hooks --cov-report=term-missing --cov-branch}
291 deps = -r{toxinidir}/tests/unit/requirements.txt
292 -r{toxinidir}/requirements.txt
293 setenv =
294- PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv
295- TZ=UTC
296+ PYTHONPATH = {toxinidir}/hooks
297+ TZ = UTC
298
299 [testenv:functional]
300 passenv =

Subscribers

People subscribed via source and target branches