Merge lp:~abentley/charms/trusty/apache2/apache-website into lp:charms/trusty/apache2

Proposed by Aaron Bentley
Status: Merged
Merged at revision: 66
Proposed branch: lp:~abentley/charms/trusty/apache2/apache-website
Merge into: lp:charms/trusty/apache2
Prerequisite: lp:~abentley/charms/trusty/apache2/apache2-ports
Diff against target: 405 lines (+298/-20)
5 files modified
README.md (+20/-0)
hooks/hooks.py (+115/-9)
hooks/tests/test_config_changed.py (+15/-11)
hooks/tests/test_hooks.py (+145/-0)
metadata.yaml (+3/-0)
To merge this branch: bzr merge lp:~abentley/charms/trusty/apache2/apache-website
Reviewer Review Type Date Requested Status
Tim Van Steenburgh (community) Approve
Adam Israel (community) Approve
Review via email: mp+249758@code.launchpad.net

Commit message

Support apache-website interface.

Description of the change

This branch updates the apache2 charm to support a new configuration method: the apache-website interface.

Basically, this is a way to push some configuration responsibility out to subordinate charms. The subordinate charms can then make opinionated decisions, and many vhosts can be supported simultaneously in an understandable way.

juju-qa is using this approach already. We have one charm, apache2-reverse-proxy, which supports proxying arbitrary web sites, and we are discussing having another for proxying s3. The mechanism is also suitable for supplying entire static web sites as subordinate charms. The version of apache2-reverse-proxy which supports the apache-website interface is at https://code.launchpad.net/~abentley/charms/trusty/apache2-reverseproxy/apache-website-interface/

In this approach
1. The subordinate charm supplies the apache config for its particular vhost
2. The subordinate charm indicates whether it is "enabled" or not. (This allows subordinates to delay activating their site until they, themselves are fully configured.)
3. The subordinate charm indicates all the ports it uses. The Apache charm then ensures all ports for all subordinates are open, both on the Apache daemon and via juju open-port. When a port is no longer in use by any configured site, the charm can detect it and close that port.
4. The subordinate indicates which modules its site requires. If Apache is configured to disable those modules, it disables the site. Otherwise, it makes sure that all modules for all sites are enabled.
5. The configurations are named after the relation-id rather than the site name, because the relation-id is permanent, unlike site names.

All tests pass locally.

To post a comment you must log in.
Revision history for this message
Adam Israel (aisrael) wrote :

Hi Aaron,

Thanks for your efforts to land this new functionality in the apache2 charm. I had the opportunity to review the merge proposal today, but ran into a few issues.

There was a merge conflict against trunk, so I used your branch directly.

One of the modules installed into .venv, linecache2, failed with a syntax error:

Compiling /charms/trusty/apache2/.venv/build/linecache2/linecache2/tests/inspect_fodder2.py ...
  File "/charms/trusty/apache2/.venv/build/linecache2/linecache2/tests/inspect_fodder2.py", line 102
    def keyworded(*arg1, arg2=1):

The install continued and the tests ran, however, two of them failed:

tests.test_cert.CertTests.test_is_selfsigned_cert_stale_private_address_changed
tests.test_cert.CertTests.test_is_selfsigned_cert_stale_public_address_changed

I don't believe your changes have anything to do with these two failures; they also fail in trunk. I'm not going to hold those failures against this merge. I've posted the complete log of the test run here:

http://pastebin.ubuntu.com/10622663/

I would like to see the interface documented in the README, so developers unfamiliar with the interface will know how to construct or use subordinates that take advantage of the new functionality, before I'm comfortable pushing this forward. With that, and the clean merge into trunk, this should be good to move forward.

Thanks again for your time on this!

review: Needs Fixing
Revision history for this message
Aaron Bentley (abentley) wrote :

Thanks for your review. The linecache failure appears to be python3 syntax that somehow slipped into their python 2 release. arg2 is a "keyword-only" parameter, because it follows a *arg. https://docs.python.org/3/glossary.html#term-parameter

I'll push a revised branch when I get a chance.

Revision history for this message
Marco Ceppi (marcoceppi) wrote :

Moving this to Work In Progress, when you're ready for another review move this Needs Review

Revision history for this message
Aaron Bentley (abentley) wrote :

I've fixed the conflicts and added docs to the README.

80. By Aaron Bentley

Add docs.

Revision history for this message
Adam Israel (aisrael) wrote :

Hi Aaron,

Thanks for the updated README. Other than the previously noted test failures in trunk, everything here looks good to me. You have my +1

review: Approve
Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

+1, LGTM, nice addition, thanks abentley!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'README.md'
2--- README.md 2015-02-27 14:12:58 +0000
3+++ README.md 2015-04-15 23:00:26 +0000
4@@ -245,6 +245,26 @@
5 be available in this setting as a base64 encoded string.
6
7
8+### Using the apache-website relation
9+
10+The apache-website relation provides a very flexible way of configuring an
11+Apache2 website, using subordinate charms. It can support reverse proxies,
12+static websites, and probably many other forms.
13+
14+To support this relation, a charm must set
15+
16+ * `domain` - The fully-qualified domain name of the site.
17+
18+ * `enabled` - Must be set to 'true' when the web site is ready to be used.
19+
20+ * `site_config` - A vhost configuration block.
21+
22+ * `site_modules` - A list of modules required by the site. If any of these
23+ appear in `disable_modules`, the site will not be enabled. Otherwise, any
24+ required modules will be loaded.
25+
26+ * `ports` - A space-separated list of ports that the site uses.
27+
28 ## Certs, keys and chains
29
30 `ssl_keylocation`, `ssl_certlocation` and `ssl_chainlocation` are
31
32=== added symlink 'hooks/apache-website-relation-changed'
33=== target is u'hooks.py'
34=== modified file 'hooks/hooks.py'
35--- hooks/hooks.py 2015-03-13 16:05:48 +0000
36+++ hooks/hooks.py 2015-04-15 23:00:26 +0000
37@@ -596,23 +596,29 @@
38 if is_apache24():
39 enable_module('lbmethod_byrequests')
40
41- if config_data['enable_modules']:
42- module_list = config_data['enable_modules'].split()
43- for module in module_list:
44- enable_module(module)
45+ disabled_modules = config_data['disable_modules'].split()
46+ apache_websites = ApacheWebsites.from_config(
47+ relations_of_type("apache-website"), disabled_modules)
48+ enabled_modules = config_data.get('enable_modules', '').split()
49+ enabled_modules = apache_websites.list_enabled_modules(enabled_modules)
50+ for module in enabled_modules:
51+ enable_module(module)
52
53 if config_data['disable_modules']:
54- module_list = config_data['disable_modules'].split()
55- for module in module_list:
56+ for module in disabled_modules:
57 disable_module(module)
58
59+ apache_websites.disable_sites()
60+ apache_websites.write_configs()
61+ apache_websites.enable_sites()
62+ apache_websites.configure_extra_ports()
63+ all_ports = apache_websites.list_enabled_ports()
64 enable_mpm(config_data)
65 # XXX we only configure the worker mpm?
66 create_mpm_workerfile()
67 create_security()
68
69 ports = {'http': 80, 'https': 443}
70- all_ports = set()
71 for protocol, port in ports.iteritems():
72 if create_vhost(
73 port,
74@@ -710,8 +716,7 @@
75 # Disable the default website because we don't want people to see the
76 # "It works!" page on production services and remove the
77 # conf.d/other-vhosts-access-log conf.
78- if os.path.exists(site_filename("000-default", True)):
79- run(["/usr/sbin/a2dissite", "000-default"])
80+ ensure_disabled(["000-default"])
81 conf_disable("other-vhosts-access-log")
82 if os.path.exists(conf_filename("other-vhosts-access-log")):
83 os.unlink(conf_filename("other-vhosts-access-log"))
84@@ -734,6 +739,105 @@
85 ship_logrotate_conf()
86
87
88+def ensure_disabled(sites):
89+ to_disable = [s for s in sites if os.path.exists(site_filename(s, True))]
90+ if len(to_disable) == 0:
91+ return
92+ run(["/usr/sbin/a2dissite"] + to_disable)
93+
94+
95+def ensure_removed(filename):
96+ try:
97+ os.unlink(filename)
98+ except OSError as e:
99+ if e.errno != errno.ENOENT:
100+ raise
101+
102+
103+class ApacheWebsites:
104+
105+ @classmethod
106+ def from_config(cls, relations, disabled_modules):
107+ """Return an ApacheWebsites with information about all sites."""
108+ if relations is None:
109+ relations = []
110+ self_relations = {}
111+ for relation in relations:
112+ self_relation = {'domain': relation.get('domain')}
113+ enabled = bool(relation.get('enabled', 'False').lower() == 'true')
114+ site_modules = relation.get('site_modules', '').split()
115+ for module in site_modules:
116+ if module in disabled_modules:
117+ enabled = False
118+ log('site {} requires disabled_module {}'.format(
119+ relation['__relid__'], module))
120+ break
121+ self_relation['site_modules'] = site_modules
122+ self_relation['enabled'] = enabled
123+ self_relation['site_config'] = relation.get('site_config')
124+ self_relation['ports'] = [
125+ int(p) for p in relation.get('ports', '').split()]
126+ self_relations[relation['__relid__']] = self_relation
127+ return cls(self_relations)
128+
129+ def __init__(self, relations):
130+ self.relations = relations
131+
132+ def write_configs(self):
133+ for key, relation in self.relations.items():
134+ config_file = site_filename(key)
135+ site_config = relation['site_config']
136+ if site_config is None:
137+ ensure_removed(config_file)
138+ else:
139+ with open(config_file, 'w') as output:
140+ output.write(site_config)
141+
142+ def iter_enabled_sites(self):
143+ return ((k, v) for k, v in self.relations.items() if v['enabled'])
144+
145+ def enable_sites(self):
146+ enabled_sites = [k for k, v in self.iter_enabled_sites()]
147+ enabled_sites.sort()
148+ if len(enabled_sites) == 0:
149+ return
150+ subprocess.check_call(['/usr/sbin/a2ensite'] + enabled_sites)
151+
152+ def disable_sites(self):
153+ disabled_sites = [k for k, v in self.relations.items()
154+ if not v['enabled']]
155+ disabled_sites.sort()
156+ if len(disabled_sites) == 0:
157+ return
158+ ensure_disabled(disabled_sites)
159+
160+ def list_enabled_modules(self, enabled_modules):
161+ enabled_modules = set(enabled_modules)
162+ for key, relation in self.iter_enabled_sites():
163+ enabled_modules.update(relation['site_modules'])
164+ return enabled_modules
165+
166+ def list_enabled_ports(self):
167+ enabled_ports = set()
168+ for key, relation in self.iter_enabled_sites():
169+ enabled_ports.update(relation['ports'])
170+ return enabled_ports
171+
172+ def configure_extra_ports(self):
173+ extra_ports = self.list_enabled_ports()
174+ extra_ports.discard(80)
175+ extra_ports.discard(443)
176+ extra_ports_conf = conf_filename('extra_ports')
177+ if len(extra_ports) > 0:
178+ with file(extra_ports_conf, 'w') as f:
179+ for port in sorted(extra_ports):
180+ f.write('Listen {}\n'.format(port))
181+ conf_enable('extra_ports')
182+ else:
183+ conf_disable('extra_ports')
184+ ensure_removed(extra_ports_conf)
185+
186+
187 def update_vhost_config_relation():
188 """
189 Update the vhost file and include the certificate in the relation
190@@ -884,6 +988,8 @@
191 config_changed()
192 elif hook_name == "website-relation-joined":
193 website_interface("joined")
194+ elif hook_name == 'apache-website-relation-changed':
195+ config_changed()
196 elif hook_name in ("nrpe-external-master-relation-changed",
197 "local-monitors-relation-changed"):
198 update_nrpe_checks()
199
200=== modified file 'hooks/tests/test_config_changed.py'
201--- hooks/tests/test_config_changed.py 2015-03-13 15:24:47 +0000
202+++ hooks/tests/test_config_changed.py 2015-04-15 23:00:26 +0000
203@@ -64,17 +64,21 @@
204 "vhost_http_template": "",
205 "vhost_https_template": "",
206 }
207- hooks.default_apache_base_dir = self.dirname
208- hooks.default_apache22_config_dir = "%s/conf.d" % self.dirname
209- hooks.default_apache24_config_dir = "%s/conf-available" % self.dirname
210- os.mkdir("%s/sites-enabled" % self.dirname)
211- os.mkdir("%s/sites-available" % self.dirname)
212- os.mkdir("%s/conf.d" % self.dirname)
213- hooks.config_changed()
214- self.assertEqual(
215- len(os.listdir("%s/%s" % (self.dirname, "sites-enabled"))), 0)
216- self.assertEqual(
217- len(os.listdir("%s/%s" % (self.dirname, "sites-available"))), 0)
218+ base = patch.object(hooks, 'default_apache_base_dir', self.dirname)
219+ config22 = patch.object(hooks, 'default_apache22_config_dir',
220+ "%s/conf.d" % self.dirname)
221+ config24 = patch.object(hooks, 'default_apache24_config_dir',
222+ "%s/conf-available" % self.dirname)
223+ with base, config22, config24:
224+ os.mkdir("%s/sites-enabled" % self.dirname)
225+ os.mkdir("%s/sites-available" % self.dirname)
226+ os.mkdir("%s/conf.d" % self.dirname)
227+ hooks.config_changed()
228+ self.assertEqual(
229+ len(os.listdir("%s/%s" % (self.dirname, "sites-enabled"))), 0)
230+ self.assertEqual(
231+ len(os.listdir("%s/%s" % (self.dirname, "sites-available"))),
232+ 0)
233
234 @patch('hooks.orig_config_get')
235 @patch('hooks.unit_get')
236
237=== modified file 'hooks/tests/test_hooks.py'
238--- hooks/tests/test_hooks.py 2014-12-29 19:16:25 +0000
239+++ hooks/tests/test_hooks.py 2015-04-15 23:00:26 +0000
240@@ -54,6 +54,151 @@
241 {"ssl_chainlocation": "foo"}), "/etc/ssl/certs/foo")
242
243
244+class TestApacheWebsites(TestCase):
245+
246+ def test_from_config_empty(self):
247+ relations = [{
248+ '__relid__': 'idrel',
249+ }]
250+ websites = hooks.ApacheWebsites.from_config(relations, [])
251+ self.assertEqual(websites.relations, {'idrel': {
252+ 'domain': None,
253+ 'enabled': False,
254+ 'ports': [],
255+ 'site_config': None,
256+ 'site_modules': [],
257+ }})
258+
259+ def test_from_config_filled(self):
260+ relations = [{
261+ '__relid__': 'idrel',
262+ 'domain': 'foo.example.com',
263+ 'ports': '80 8080',
264+ 'site_config': 'configfoo',
265+ 'site_modules': 'foo bar',
266+ }]
267+ websites = hooks.ApacheWebsites.from_config(relations, [])
268+ self.assertEqual(websites.relations, {'idrel': {
269+ 'domain': 'foo.example.com',
270+ 'enabled': False,
271+ 'ports': [80, 8080],
272+ 'site_config': 'configfoo',
273+ 'site_modules': ['foo', 'bar'],
274+ }})
275+
276+ def test_from_config_disabled_module_disables_site(self):
277+ relations = [{
278+ '__relid__': 'idrel',
279+ 'site_modules': 'foo bar',
280+ 'enabled': 'true',
281+ }]
282+ with patch('hooks.log') as log_mock:
283+ websites = hooks.ApacheWebsites.from_config(relations, ['foo'])
284+ self.assertEqual(websites.relations['idrel']['enabled'], False)
285+ log_mock.assert_called_once_with(
286+ 'site idrel requires disabled_module foo')
287+
288+ def test_write_configs(self):
289+ with temp_dir() as configs_temp:
290+
291+ def site_filename(name):
292+ return os.path.join(configs_temp, name)
293+
294+ websites = hooks.ApacheWebsites({
295+ 'cfg': {'site_config': 'foobar'},
296+ 'fcg': {'site_config': None},
297+ })
298+ open(site_filename('fcg'), 'w').write('')
299+ self.assertTrue(os.path.exists(site_filename('fcg')))
300+ with patch('hooks.site_filename', site_filename):
301+ websites.write_configs()
302+ self.assertEqual('foobar', open(site_filename('cfg')).read())
303+ self.assertFalse(os.path.exists(site_filename('fcg')))
304+
305+ def test_iter_enabled_sites(self):
306+ websites = hooks.ApacheWebsites({
307+ 'fcg': {'site_config': None, 'enabled': True},
308+ 'cgf': {'site_config': None, 'enabled': False},
309+ })
310+ self.assertItemsEqual(
311+ websites.iter_enabled_sites(),
312+ [('fcg', {'site_config': None, 'enabled': True})])
313+
314+ def test_enable_sites(self):
315+ websites = hooks.ApacheWebsites({
316+ 'fcg': {'site_config': None, 'enabled': False},
317+ 'cgf': {'site_config': None, 'enabled': False},
318+ })
319+ with patch('subprocess.check_call') as cc_mock:
320+ websites.enable_sites()
321+ self.assertEqual(len(cc_mock.mock_calls), 0)
322+ websites.relations['fcg']['enabled'] = True
323+ with patch('subprocess.check_call') as cc_mock:
324+ websites.enable_sites()
325+ cc_mock.assert_called_once_with(['/usr/sbin/a2ensite', 'fcg'])
326+ websites.relations['cgf']['enabled'] = True
327+ with patch('subprocess.check_call') as cc_mock:
328+ websites.enable_sites()
329+ cc_mock.assert_called_once_with(['/usr/sbin/a2ensite', 'cgf', 'fcg'])
330+
331+ def test_disable_sites(self):
332+ websites = hooks.ApacheWebsites({
333+ 'fcg': {'site_config': None, 'enabled': True},
334+ 'cgf': {'site_config': None, 'enabled': True},
335+ })
336+ with temp_dir() as conf_dir:
337+
338+ def site_filename(f, e=False):
339+ return os.path.join(conf_dir, f)
340+
341+ with patch('hooks.site_filename', site_filename):
342+ open(hooks.site_filename('fcg'), 'w').close()
343+ open(hooks.site_filename('cgf'), 'w').close()
344+ with patch('subprocess.check_output') as co_mock:
345+ websites.disable_sites()
346+ self.assertEqual(len(co_mock.mock_calls), 0)
347+ websites.relations['fcg']['enabled'] = False
348+ with patch('subprocess.check_output') as co_mock:
349+ websites.disable_sites()
350+ co_mock.assert_called_once_with(['/usr/sbin/a2dissite', 'fcg'])
351+ websites.relations['cgf']['enabled'] = False
352+ with patch('subprocess.check_output') as co_mock:
353+ websites.disable_sites()
354+ co_mock.assert_called_once_with(['/usr/sbin/a2dissite', 'cgf', 'fcg'])
355+
356+ def test_list_enabled_modules(self):
357+ websites = hooks.ApacheWebsites({
358+ 'fcg': {'site_modules': ['foo'], 'enabled': True},
359+ 'cgf': {'site_modules': ['foo', 'bar'], 'enabled': True},
360+ 'gcf': {'site_modules': ['foo', 'baz'], 'enabled': False},
361+ })
362+ self.assertEqual(
363+ websites.list_enabled_modules(['qux']), set(['foo', 'bar', 'qux']))
364+
365+ def test_list_enabled_ports(self):
366+ websites = hooks.ApacheWebsites({
367+ 'fcg': {'ports': [80], 'enabled': True},
368+ 'cgf': {'ports': [80, 81], 'enabled': True},
369+ 'gcf': {'ports': [81, 82], 'enabled': False},
370+ })
371+ self.assertEqual(
372+ websites.list_enabled_ports(), set([80, 81]))
373+
374+ def test_configure_extra_ports(self):
375+ websites = hooks.ApacheWebsites({
376+ 'fcg': {'ports': [80], 'enabled': True},
377+ 'cgf': {'ports': [80, 81], 'enabled': True},
378+ 'gcf': {'ports': [81, 82], 'enabled': False},
379+ })
380+ with patch('hooks.conf_enable') as ce_mock:
381+ with temp_dir() as conf_dir:
382+ fake_conf = os.path.join(conf_dir, 'extra_ports.conf')
383+ with patch('hooks.conf_filename', return_value=fake_conf):
384+ with patch('hooks.open_port'):
385+ websites.configure_extra_ports()
386+ ce_mock.assert_called_once_with('extra_ports')
387+
388+
389 class TestEnsurePorts(TestCase):
390
391 def test_ensure_ports(self):
392
393=== modified file 'metadata.yaml'
394--- metadata.yaml 2014-10-29 00:10:14 +0000
395+++ metadata.yaml 2015-04-15 23:00:26 +0000
396@@ -18,6 +18,9 @@
397 scope: container
398 website:
399 interface: http
400+ apache-website:
401+ interface: apache-website
402+ scope: container
403 requires:
404 reverseproxy:
405 interface: http

Subscribers

People subscribed via source and target branches

to all changes: