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
=== modified file 'README.md'
--- README.md 2015-02-27 14:12:58 +0000
+++ README.md 2015-04-15 23:00:26 +0000
@@ -245,6 +245,26 @@
245 be available in this setting as a base64 encoded string.245 be available in this setting as a base64 encoded string.
246246
247247
248### Using the apache-website relation
249
250The apache-website relation provides a very flexible way of configuring an
251Apache2 website, using subordinate charms. It can support reverse proxies,
252static websites, and probably many other forms.
253
254To support this relation, a charm must set
255
256 * `domain` - The fully-qualified domain name of the site.
257
258 * `enabled` - Must be set to 'true' when the web site is ready to be used.
259
260 * `site_config` - A vhost configuration block.
261
262 * `site_modules` - A list of modules required by the site. If any of these
263 appear in `disable_modules`, the site will not be enabled. Otherwise, any
264 required modules will be loaded.
265
266 * `ports` - A space-separated list of ports that the site uses.
267
248## Certs, keys and chains268## Certs, keys and chains
249269
250`ssl_keylocation`, `ssl_certlocation` and `ssl_chainlocation` are270`ssl_keylocation`, `ssl_certlocation` and `ssl_chainlocation` are
251271
=== added symlink 'hooks/apache-website-relation-changed'
=== target is u'hooks.py'
=== modified file 'hooks/hooks.py'
--- hooks/hooks.py 2015-03-13 16:05:48 +0000
+++ hooks/hooks.py 2015-04-15 23:00:26 +0000
@@ -596,23 +596,29 @@
596 if is_apache24():596 if is_apache24():
597 enable_module('lbmethod_byrequests')597 enable_module('lbmethod_byrequests')
598598
599 if config_data['enable_modules']:599 disabled_modules = config_data['disable_modules'].split()
600 module_list = config_data['enable_modules'].split()600 apache_websites = ApacheWebsites.from_config(
601 for module in module_list:601 relations_of_type("apache-website"), disabled_modules)
602 enable_module(module)602 enabled_modules = config_data.get('enable_modules', '').split()
603 enabled_modules = apache_websites.list_enabled_modules(enabled_modules)
604 for module in enabled_modules:
605 enable_module(module)
603606
604 if config_data['disable_modules']:607 if config_data['disable_modules']:
605 module_list = config_data['disable_modules'].split()608 for module in disabled_modules:
606 for module in module_list:
607 disable_module(module)609 disable_module(module)
608610
611 apache_websites.disable_sites()
612 apache_websites.write_configs()
613 apache_websites.enable_sites()
614 apache_websites.configure_extra_ports()
615 all_ports = apache_websites.list_enabled_ports()
609 enable_mpm(config_data)616 enable_mpm(config_data)
610 # XXX we only configure the worker mpm?617 # XXX we only configure the worker mpm?
611 create_mpm_workerfile()618 create_mpm_workerfile()
612 create_security()619 create_security()
613620
614 ports = {'http': 80, 'https': 443}621 ports = {'http': 80, 'https': 443}
615 all_ports = set()
616 for protocol, port in ports.iteritems():622 for protocol, port in ports.iteritems():
617 if create_vhost(623 if create_vhost(
618 port,624 port,
@@ -710,8 +716,7 @@
710 # Disable the default website because we don't want people to see the716 # Disable the default website because we don't want people to see the
711 # "It works!" page on production services and remove the717 # "It works!" page on production services and remove the
712 # conf.d/other-vhosts-access-log conf.718 # conf.d/other-vhosts-access-log conf.
713 if os.path.exists(site_filename("000-default", True)):719 ensure_disabled(["000-default"])
714 run(["/usr/sbin/a2dissite", "000-default"])
715 conf_disable("other-vhosts-access-log")720 conf_disable("other-vhosts-access-log")
716 if os.path.exists(conf_filename("other-vhosts-access-log")):721 if os.path.exists(conf_filename("other-vhosts-access-log")):
717 os.unlink(conf_filename("other-vhosts-access-log"))722 os.unlink(conf_filename("other-vhosts-access-log"))
@@ -734,6 +739,105 @@
734 ship_logrotate_conf()739 ship_logrotate_conf()
735740
736741
742def ensure_disabled(sites):
743 to_disable = [s for s in sites if os.path.exists(site_filename(s, True))]
744 if len(to_disable) == 0:
745 return
746 run(["/usr/sbin/a2dissite"] + to_disable)
747
748
749def ensure_removed(filename):
750 try:
751 os.unlink(filename)
752 except OSError as e:
753 if e.errno != errno.ENOENT:
754 raise
755
756
757class ApacheWebsites:
758
759 @classmethod
760 def from_config(cls, relations, disabled_modules):
761 """Return an ApacheWebsites with information about all sites."""
762 if relations is None:
763 relations = []
764 self_relations = {}
765 for relation in relations:
766 self_relation = {'domain': relation.get('domain')}
767 enabled = bool(relation.get('enabled', 'False').lower() == 'true')
768 site_modules = relation.get('site_modules', '').split()
769 for module in site_modules:
770 if module in disabled_modules:
771 enabled = False
772 log('site {} requires disabled_module {}'.format(
773 relation['__relid__'], module))
774 break
775 self_relation['site_modules'] = site_modules
776 self_relation['enabled'] = enabled
777 self_relation['site_config'] = relation.get('site_config')
778 self_relation['ports'] = [
779 int(p) for p in relation.get('ports', '').split()]
780 self_relations[relation['__relid__']] = self_relation
781 return cls(self_relations)
782
783 def __init__(self, relations):
784 self.relations = relations
785
786 def write_configs(self):
787 for key, relation in self.relations.items():
788 config_file = site_filename(key)
789 site_config = relation['site_config']
790 if site_config is None:
791 ensure_removed(config_file)
792 else:
793 with open(config_file, 'w') as output:
794 output.write(site_config)
795
796 def iter_enabled_sites(self):
797 return ((k, v) for k, v in self.relations.items() if v['enabled'])
798
799 def enable_sites(self):
800 enabled_sites = [k for k, v in self.iter_enabled_sites()]
801 enabled_sites.sort()
802 if len(enabled_sites) == 0:
803 return
804 subprocess.check_call(['/usr/sbin/a2ensite'] + enabled_sites)
805
806 def disable_sites(self):
807 disabled_sites = [k for k, v in self.relations.items()
808 if not v['enabled']]
809 disabled_sites.sort()
810 if len(disabled_sites) == 0:
811 return
812 ensure_disabled(disabled_sites)
813
814 def list_enabled_modules(self, enabled_modules):
815 enabled_modules = set(enabled_modules)
816 for key, relation in self.iter_enabled_sites():
817 enabled_modules.update(relation['site_modules'])
818 return enabled_modules
819
820 def list_enabled_ports(self):
821 enabled_ports = set()
822 for key, relation in self.iter_enabled_sites():
823 enabled_ports.update(relation['ports'])
824 return enabled_ports
825
826 def configure_extra_ports(self):
827 extra_ports = self.list_enabled_ports()
828 extra_ports.discard(80)
829 extra_ports.discard(443)
830 extra_ports_conf = conf_filename('extra_ports')
831 if len(extra_ports) > 0:
832 with file(extra_ports_conf, 'w') as f:
833 for port in sorted(extra_ports):
834 f.write('Listen {}\n'.format(port))
835 conf_enable('extra_ports')
836 else:
837 conf_disable('extra_ports')
838 ensure_removed(extra_ports_conf)
839
840
737def update_vhost_config_relation():841def update_vhost_config_relation():
738 """842 """
739 Update the vhost file and include the certificate in the relation843 Update the vhost file and include the certificate in the relation
@@ -884,6 +988,8 @@
884 config_changed()988 config_changed()
885 elif hook_name == "website-relation-joined":989 elif hook_name == "website-relation-joined":
886 website_interface("joined")990 website_interface("joined")
991 elif hook_name == 'apache-website-relation-changed':
992 config_changed()
887 elif hook_name in ("nrpe-external-master-relation-changed",993 elif hook_name in ("nrpe-external-master-relation-changed",
888 "local-monitors-relation-changed"):994 "local-monitors-relation-changed"):
889 update_nrpe_checks()995 update_nrpe_checks()
890996
=== modified file 'hooks/tests/test_config_changed.py'
--- hooks/tests/test_config_changed.py 2015-03-13 15:24:47 +0000
+++ hooks/tests/test_config_changed.py 2015-04-15 23:00:26 +0000
@@ -64,17 +64,21 @@
64 "vhost_http_template": "",64 "vhost_http_template": "",
65 "vhost_https_template": "",65 "vhost_https_template": "",
66 }66 }
67 hooks.default_apache_base_dir = self.dirname67 base = patch.object(hooks, 'default_apache_base_dir', self.dirname)
68 hooks.default_apache22_config_dir = "%s/conf.d" % self.dirname68 config22 = patch.object(hooks, 'default_apache22_config_dir',
69 hooks.default_apache24_config_dir = "%s/conf-available" % self.dirname69 "%s/conf.d" % self.dirname)
70 os.mkdir("%s/sites-enabled" % self.dirname)70 config24 = patch.object(hooks, 'default_apache24_config_dir',
71 os.mkdir("%s/sites-available" % self.dirname)71 "%s/conf-available" % self.dirname)
72 os.mkdir("%s/conf.d" % self.dirname)72 with base, config22, config24:
73 hooks.config_changed()73 os.mkdir("%s/sites-enabled" % self.dirname)
74 self.assertEqual(74 os.mkdir("%s/sites-available" % self.dirname)
75 len(os.listdir("%s/%s" % (self.dirname, "sites-enabled"))), 0)75 os.mkdir("%s/conf.d" % self.dirname)
76 self.assertEqual(76 hooks.config_changed()
77 len(os.listdir("%s/%s" % (self.dirname, "sites-available"))), 0)77 self.assertEqual(
78 len(os.listdir("%s/%s" % (self.dirname, "sites-enabled"))), 0)
79 self.assertEqual(
80 len(os.listdir("%s/%s" % (self.dirname, "sites-available"))),
81 0)
7882
79 @patch('hooks.orig_config_get')83 @patch('hooks.orig_config_get')
80 @patch('hooks.unit_get')84 @patch('hooks.unit_get')
8185
=== modified file 'hooks/tests/test_hooks.py'
--- hooks/tests/test_hooks.py 2014-12-29 19:16:25 +0000
+++ hooks/tests/test_hooks.py 2015-04-15 23:00:26 +0000
@@ -54,6 +54,151 @@
54 {"ssl_chainlocation": "foo"}), "/etc/ssl/certs/foo")54 {"ssl_chainlocation": "foo"}), "/etc/ssl/certs/foo")
5555
5656
57class TestApacheWebsites(TestCase):
58
59 def test_from_config_empty(self):
60 relations = [{
61 '__relid__': 'idrel',
62 }]
63 websites = hooks.ApacheWebsites.from_config(relations, [])
64 self.assertEqual(websites.relations, {'idrel': {
65 'domain': None,
66 'enabled': False,
67 'ports': [],
68 'site_config': None,
69 'site_modules': [],
70 }})
71
72 def test_from_config_filled(self):
73 relations = [{
74 '__relid__': 'idrel',
75 'domain': 'foo.example.com',
76 'ports': '80 8080',
77 'site_config': 'configfoo',
78 'site_modules': 'foo bar',
79 }]
80 websites = hooks.ApacheWebsites.from_config(relations, [])
81 self.assertEqual(websites.relations, {'idrel': {
82 'domain': 'foo.example.com',
83 'enabled': False,
84 'ports': [80, 8080],
85 'site_config': 'configfoo',
86 'site_modules': ['foo', 'bar'],
87 }})
88
89 def test_from_config_disabled_module_disables_site(self):
90 relations = [{
91 '__relid__': 'idrel',
92 'site_modules': 'foo bar',
93 'enabled': 'true',
94 }]
95 with patch('hooks.log') as log_mock:
96 websites = hooks.ApacheWebsites.from_config(relations, ['foo'])
97 self.assertEqual(websites.relations['idrel']['enabled'], False)
98 log_mock.assert_called_once_with(
99 'site idrel requires disabled_module foo')
100
101 def test_write_configs(self):
102 with temp_dir() as configs_temp:
103
104 def site_filename(name):
105 return os.path.join(configs_temp, name)
106
107 websites = hooks.ApacheWebsites({
108 'cfg': {'site_config': 'foobar'},
109 'fcg': {'site_config': None},
110 })
111 open(site_filename('fcg'), 'w').write('')
112 self.assertTrue(os.path.exists(site_filename('fcg')))
113 with patch('hooks.site_filename', site_filename):
114 websites.write_configs()
115 self.assertEqual('foobar', open(site_filename('cfg')).read())
116 self.assertFalse(os.path.exists(site_filename('fcg')))
117
118 def test_iter_enabled_sites(self):
119 websites = hooks.ApacheWebsites({
120 'fcg': {'site_config': None, 'enabled': True},
121 'cgf': {'site_config': None, 'enabled': False},
122 })
123 self.assertItemsEqual(
124 websites.iter_enabled_sites(),
125 [('fcg', {'site_config': None, 'enabled': True})])
126
127 def test_enable_sites(self):
128 websites = hooks.ApacheWebsites({
129 'fcg': {'site_config': None, 'enabled': False},
130 'cgf': {'site_config': None, 'enabled': False},
131 })
132 with patch('subprocess.check_call') as cc_mock:
133 websites.enable_sites()
134 self.assertEqual(len(cc_mock.mock_calls), 0)
135 websites.relations['fcg']['enabled'] = True
136 with patch('subprocess.check_call') as cc_mock:
137 websites.enable_sites()
138 cc_mock.assert_called_once_with(['/usr/sbin/a2ensite', 'fcg'])
139 websites.relations['cgf']['enabled'] = True
140 with patch('subprocess.check_call') as cc_mock:
141 websites.enable_sites()
142 cc_mock.assert_called_once_with(['/usr/sbin/a2ensite', 'cgf', 'fcg'])
143
144 def test_disable_sites(self):
145 websites = hooks.ApacheWebsites({
146 'fcg': {'site_config': None, 'enabled': True},
147 'cgf': {'site_config': None, 'enabled': True},
148 })
149 with temp_dir() as conf_dir:
150
151 def site_filename(f, e=False):
152 return os.path.join(conf_dir, f)
153
154 with patch('hooks.site_filename', site_filename):
155 open(hooks.site_filename('fcg'), 'w').close()
156 open(hooks.site_filename('cgf'), 'w').close()
157 with patch('subprocess.check_output') as co_mock:
158 websites.disable_sites()
159 self.assertEqual(len(co_mock.mock_calls), 0)
160 websites.relations['fcg']['enabled'] = False
161 with patch('subprocess.check_output') as co_mock:
162 websites.disable_sites()
163 co_mock.assert_called_once_with(['/usr/sbin/a2dissite', 'fcg'])
164 websites.relations['cgf']['enabled'] = False
165 with patch('subprocess.check_output') as co_mock:
166 websites.disable_sites()
167 co_mock.assert_called_once_with(['/usr/sbin/a2dissite', 'cgf', 'fcg'])
168
169 def test_list_enabled_modules(self):
170 websites = hooks.ApacheWebsites({
171 'fcg': {'site_modules': ['foo'], 'enabled': True},
172 'cgf': {'site_modules': ['foo', 'bar'], 'enabled': True},
173 'gcf': {'site_modules': ['foo', 'baz'], 'enabled': False},
174 })
175 self.assertEqual(
176 websites.list_enabled_modules(['qux']), set(['foo', 'bar', 'qux']))
177
178 def test_list_enabled_ports(self):
179 websites = hooks.ApacheWebsites({
180 'fcg': {'ports': [80], 'enabled': True},
181 'cgf': {'ports': [80, 81], 'enabled': True},
182 'gcf': {'ports': [81, 82], 'enabled': False},
183 })
184 self.assertEqual(
185 websites.list_enabled_ports(), set([80, 81]))
186
187 def test_configure_extra_ports(self):
188 websites = hooks.ApacheWebsites({
189 'fcg': {'ports': [80], 'enabled': True},
190 'cgf': {'ports': [80, 81], 'enabled': True},
191 'gcf': {'ports': [81, 82], 'enabled': False},
192 })
193 with patch('hooks.conf_enable') as ce_mock:
194 with temp_dir() as conf_dir:
195 fake_conf = os.path.join(conf_dir, 'extra_ports.conf')
196 with patch('hooks.conf_filename', return_value=fake_conf):
197 with patch('hooks.open_port'):
198 websites.configure_extra_ports()
199 ce_mock.assert_called_once_with('extra_ports')
200
201
57class TestEnsurePorts(TestCase):202class TestEnsurePorts(TestCase):
58203
59 def test_ensure_ports(self):204 def test_ensure_ports(self):
60205
=== modified file 'metadata.yaml'
--- metadata.yaml 2014-10-29 00:10:14 +0000
+++ metadata.yaml 2015-04-15 23:00:26 +0000
@@ -18,6 +18,9 @@
18 scope: container18 scope: container
19 website:19 website:
20 interface: http20 interface: http
21 apache-website:
22 interface: apache-website
23 scope: container
21requires:24requires:
22 reverseproxy:25 reverseproxy:
23 interface: http26 interface: http

Subscribers

People subscribed via source and target branches

to all changes: