/
charm.py
executable file
·272 lines (195 loc) · 8.58 KB
/
charm.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
#!/usr/bin/env python3
import subprocess
import collections
import yaml
import base64
import logging
import sys
sys.path.append('lib') # noqa
from ops.charm import CharmBase, CharmEvents
from ops.framework import (
EventSource,
EventBase,
StoredState,
)
from ops.model import ActiveStatus
from ops.main import main
from pathlib import Path
from enum import (
Enum,
unique,
)
logger = logging.getLogger(__name__)
class ApacheReadyEvent(EventBase):
pass
class ApacheCharmEvents(CharmEvents):
apache_ready = EventSource(ApacheReadyEvent)
class ApacheModuleEnableException(Exception):
pass
class ApacheModuleDisableException(Exception):
pass
class ApacheSiteEnableException(Exception):
pass
class ApacheSiteDisableException(Exception):
pass
class SystemctlCommandException(Exception):
pass
@unique
class ModuleState(Enum):
"""a2query exit codes per apache2/debian/a2query.in
a2query only considers modules that it knows about from the state in /var/lib/apache2/module/ directory.
"""
Found = 0
NotFound = 1
OffByAdmin = 32
OffByMaintainer = 33
class Charm(CharmBase):
on = ApacheCharmEvents()
HTTPD_SERVICE_NAME = 'apache2'
state = StoredState()
SYSTEMCTL_COMMANDS = {'start', 'stop', 'restart', 'reload', 'daemon-reload', 'disable', 'enable'}
APACHE_CONFIG_DIR = Path('/etc/apache2')
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.install, self)
self.framework.observe(self.on.start, self)
self.framework.observe(self.on.stop, self)
self.framework.observe(self.on.config_changed, self)
self.framework.observe(self.on.vhost_config_relation_changed, self)
self.framework.observe(self.on.apache_ready, self)
def on_install(self, event):
# Initialize Charm state
self.state._current_modules = set()
logger.info(f'on_install: installing {self.HTTPD_SERVICE_NAME}')
self.apt_install(['apache2'])
# Disable vhosts that come from the default site.
self._disable_site('000-default.conf')
def on_start(self, event):
logger.info(f'on_start: starting {self.HTTPD_SERVICE_NAME}')
self._systemd_unit_command('start', self.HTTPD_SERVICE_NAME)
def on_stop(self, event):
logger.info(f'on_stop: stop {self.HTTPD_SERVICE_NAME}')
self._systemd_unit_command('stop', self.HTTPD_SERVICE_NAME)
def on_config_changed(self, event):
logger.info(f'on_config_changed: Updating {self.HTTPD_SERVICE_NAME} configuration.')
config_modules = set(self.framework.model.config['modules'].split())
modules_to_disable = self.state._current_modules - config_modules
changed_modules = self.state._current_modules ^ config_modules
for m in modules_to_disable:
self._disable_module(m)
self.state._current_modules.remove(m)
for m in config_modules:
self._enable_module(m)
self.state._current_modules.add(m)
# Modules were either enabled or disabled so a restart is needed.
if changed_modules:
self._systemd_unit_command('restart', self.HTTPD_SERVICE_NAME)
self._assess_readiness()
def on_apache_ready(self, event):
self.framework.model.unit.status = ActiveStatus()
def _assess_readiness(self):
if self._is_systemd_unit_active(self.HTTPD_SERVICE_NAME):
self.state.ready = True
self.on.apache_ready.emit()
else:
self.state.ready = False
def _systemd_unit_command(self, command, name):
"""Run a systemctl command from a subset of commands on a systemd unit."""
if command in self.SYSTEMCTL_COMMANDS:
logger.info(f'Running systemctl {command} {name}')
rc = subprocess.call(['systemctl', command, name])
if rc:
raise SystemctlCommandException(f'got unexpected return code {rc} while executing {command} on unit {name}')
else:
raise NotImplementedError(f'usage of systemctl command "{command}" is not supported by the charm.')
def _is_systemd_unit_active(self, name):
"""Run systemctl is-active on a unit.
is-active returns a non-zero exit code to represent an inactive service.
"""
rc = subprocess.call(['systemctl', 'is-active', name])
return False if rc else True
def on_vhost_config_relation_changed(self, event):
if not self.state.ready:
event.defer()
return
# no subordinate unit observed yet, let's wait until it appears.
if not event.unit:
return
vhosts_serialized = event.relation.data[event.app].get('vhosts')
# No vhosts are provided yet - skip this event.
if not vhosts_serialized:
return
vhosts = yaml.safe_load(vhosts_serialized)
for vhost in vhosts:
self._enable_site(self.create_vhost(self.framework.model.config['server_name'], vhost["template"], vhost['port']))
self._systemd_unit_command('reload', self.HTTPD_SERVICE_NAME)
def _get_module_state(self, module_name):
return ModuleState(subprocess.call(['a2query', '-m', module_name]))
def _disable_module(self, module_name):
module_state = self._get_module_state(module_name)
if module_state == ModuleState.Found:
try:
logger.info(f'Disabling apache2 module {module_name}')
subprocess.check_call(['a2dismod', module_name])
except subprocess.CalledProcessError:
raise ApacheModuleDisableException(f'unable to disable apache2 module {module_name}.')
elif module_state in (ModuleState.OffByAdmin, ModuleState.OffByMaintainer):
logger.info(f'Apache2 module {module_name} is already disabled.')
elif module_state == ModuleState.NotFound:
raise ApacheModuleDisableException(f'module {module_name} was not found.')
else:
raise ApacheModuleDisableException(f'unexpected module {module_name} state.')
def _enable_module(self, module_name):
try:
logger.info(f'Enabling apache2 module {module_name}')
subprocess.check_call(['a2enmod', module_name])
except subprocess.CalledProcessError:
raise ApacheModuleEnableException(f'unable to enable apache2 module {module_name}.')
def _enable_site(self, site_name):
try:
logger.info(f'Enabling site {site_name}')
subprocess.check_call(['a2ensite', site_name])
except subprocess.CalledProcessError:
raise ApacheSiteEnableException(f'unable to enable apache2 site {site_name}.')
def _disable_site(self, site_name):
try:
logger.info(f'Disabling site {site_name}')
subprocess.check_call(['a2dissite', site_name])
except subprocess.CalledProcessError:
raise ApacheSiteDisableException(f'unable to disable apache2 site {site_name}.')
@classmethod
def create_vhost(cls, server_name, template, port, protocol=None):
"""
Create and enable a vhost in apache.
server_name -- the server name to use for a vhost.
template -- the template string to use.
port -- port on which to listen (int)
protocol -- used to name the vhost file intelligently. If not specified the port will be used instead (ex: http, https).
return -- vhost file name.
"""
if protocol is None:
protocol = str(port)
template = base64.b64decode(template).decode('utf-8')
vhost_name = f'{server_name}_{protocol}'
vhost_file = cls.APACHE_CONFIG_DIR / 'sites-available' / f'{vhost_name}.conf'
logger.info(f'Writing vhost config to {vhost_file}')
vhost_file.write_text(template)
return vhost_name
def apt_install(self, packages, options=None):
"""Install one or more packages.
packages -- package(s) to install.
options -- a list of apt options to use.
"""
if options is None:
options = ['--option=Dpkg::Options::=--force-confold']
command = ['apt-get', '--assume-yes']
command.extend(options)
command.append('install')
if isinstance(packages, collections.abc.Sequence):
command.extend(packages)
else:
raise ValueError(f'Invalid type was used for the "packages" argument: {type(packages)} instead of str')
logger.info("Installing {} with options: {}".format(packages, options))
subprocess.check_call(command)
if __name__ == '__main__':
main(Charm)