Merge lp:~jtv/maas/bug-1373261 into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 3352
Proposed branch: lp:~jtv/maas/bug-1373261
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 503 lines (+476/-0)
3 files modified
src/provisioningserver/__main__.py (+2/-0)
src/provisioningserver/configure_maas_url.py (+123/-0)
src/provisioningserver/tests/test_configure_maas_url.py (+351/-0)
To merge this branch: bzr merge lp:~jtv/maas/bug-1373261
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+241123@code.launchpad.net

Commit message

Python replacement for maas-cluster-controller postinst code to rewrite maas_cluster.conf and pserv.yaml. To be invoked as a maas-provision sub-command.

This is lots and lots more work than the old shell code, but it has two advantages: it's covered by the test suite, and it supports IPv6 addresses in URLs. These have a special syntax, and caused our shell code to corrupt pserv.yaml.

Description of the change

The problem with the shell code was that it mistook any colon (":") in the generator URL for the beginning of a port specifier. Thus, once you had an IPv6-based URL configured, reconfiguring for a different host would produce garbage in pserv.yaml.

While I was at it, I opted to handle both config files that this shell code rewrites. The one that needed fixing was the hard one anyway, so replacing a bit more shell code was a cheap bonus.

An accompanying packaging change will replace the postinst shell code with an invocation of the new maas-provision sub-command.

Jeroen

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Nice :)

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/provisioningserver/__main__.py'
--- src/provisioningserver/__main__.py 2014-10-09 19:48:37 +0000
+++ src/provisioningserver/__main__.py 2014-11-07 18:13:30 +0000
@@ -17,6 +17,7 @@
17from provisioningserver import security17from provisioningserver import security
18import provisioningserver.boot.install_bootloader18import provisioningserver.boot.install_bootloader
19import provisioningserver.boot.install_grub19import provisioningserver.boot.install_grub
20import provisioningserver.configure_maas_url
20import provisioningserver.customize_config21import provisioningserver.customize_config
21import provisioningserver.dhcp.writer22import provisioningserver.dhcp.writer
22import provisioningserver.upgrade_cluster23import provisioningserver.upgrade_cluster
@@ -29,6 +30,7 @@
29script_commands = {30script_commands = {
30 'atomic-write': AtomicWriteScript,31 'atomic-write': AtomicWriteScript,
31 'check-for-shared-secret': security.CheckForSharedSecretScript,32 'check-for-shared-secret': security.CheckForSharedSecretScript,
33 'configure-maas-url': provisioningserver.configure_maas_url,
32 'customize-config': provisioningserver.customize_config,34 'customize-config': provisioningserver.customize_config,
33 'generate-dhcp-config': provisioningserver.dhcp.writer,35 'generate-dhcp-config': provisioningserver.dhcp.writer,
34 'install-shared-secret': security.InstallSharedSecretScript,36 'install-shared-secret': security.InstallSharedSecretScript,
3537
=== added file 'src/provisioningserver/configure_maas_url.py'
--- src/provisioningserver/configure_maas_url.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/configure_maas_url.py 2014-11-07 18:13:30 +0000
@@ -0,0 +1,123 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Management command: update `MAAS_URL`.
5
6The MAAS cluster controller packaging calls this in order to set a new
7"MAAS URL" (the URL where nodes and cluster controllers can reach the
8region controller) in the cluster controller's configuration files.
9"""
10
11from __future__ import (
12 absolute_import,
13 print_function,
14 unicode_literals,
15 )
16
17str = None
18
19__metaclass__ = type
20__all__ = [
21 'add_arguments',
22 'run',
23 ]
24
25from functools import partial
26import re
27from urlparse import urlparse
28
29from provisioningserver.utils.fs import (
30 atomic_write,
31 read_text_file,
32 )
33from provisioningserver.utils.url import compose_URL
34
35
36MAAS_CLUSTER_CONF = '/etc/maas/maas_cluster.conf'
37
38PSERV_YAML = '/etc/maas/pserv.yaml'
39
40
41def rewrite_config_file(path, line_filter, mode=0600):
42 """Rewrite config file at `path` on a line-by-line basis.
43
44 Reads the file at `path`, runs its lines through `line_filter`, and
45 writes the result back to `path`.
46
47 Newlines may not be exactly as they were. A trailing newline is ensured.
48
49 :param path: Path to the config file to be rewritten.
50 :param line_filter: A callable which accepts a line of input text (without
51 trailing newline), and returns the corresponding line of output text
52 (also without trailing newline).
53 :param mode: File access permissions for the newly written file.
54 """
55 input_lines = read_text_file(path).splitlines()
56 output_lines = [line_filter(line) for line in input_lines]
57 result = '%s\n' % '\n'.join(output_lines)
58 atomic_write(result, path, mode=mode)
59
60
61def update_maas_cluster_conf(url):
62 """Update `MAAS_URL` in `/etc/maas/maas_cluster.conf`.
63
64 This file contains a shell-style assignment of the `MAAS_URL`
65 variable. Its assigned value will be changed to `url`.
66 """
67 substitute_line = lambda line: (
68 'MAAS_URL="%s"' % url
69 if re.match('\s*MAAS_URL=', line)
70 else line)
71 rewrite_config_file(MAAS_CLUSTER_CONF, substitute_line, mode=0640)
72
73
74def extract_host(url):
75 """Return just the host part of `url`."""
76 return urlparse(url).hostname
77
78
79def substitute_pserv_yaml_line(new_host, line):
80 match = re.match('(\s*generator:)\s+(\S*)(.*)$', line)
81 if match is None:
82 # Not the generator line. Keep as-is.
83 return line
84 [head, input_url, tail] = match.groups()
85 return "%s %s%s" % (head, compose_URL(input_url, new_host), tail)
86
87
88def update_pserv_yaml(host):
89 """Update `generator` in `/etc/maas/pserv.yaml`.
90
91 This file contains a YAML line defining a `generator` URL. The line must
92 look something like::
93
94 generator: http://10.9.8.7/MAAS/api/1.0/pxeconfig/
95
96 The host part of the URL (in this example, `10.9.8.7`) will be replaced
97 with the new `host`. If `host` is an IPv6 address, this function will
98 ensure that it is surrounded by square brackets.
99 """
100 substitute_line = partial(substitute_pserv_yaml_line, host)
101 rewrite_config_file(PSERV_YAML, substitute_line, mode=0644)
102
103
104def add_arguments(parser):
105 """Add this command's options to the `ArgumentParser`.
106
107 Specified by the `ActionScript` interface.
108 """
109 parser.add_argument(
110 'maas_url', metavar='URL',
111 help=(
112 "URL where nodes and cluster controllers can reach the MAAS "
113 "region controller."))
114
115
116def run(args):
117 """Update MAAS_URL setting in configuration files.
118
119 For use by the MAAS packaging scripts. Updates configuration files
120 to reflect a new MAAS_URL setting.
121 """
122 update_maas_cluster_conf(args.maas_url)
123 update_pserv_yaml(extract_host(args.maas_url))
0124
=== added file 'src/provisioningserver/tests/test_configure_maas_url.py'
--- src/provisioningserver/tests/test_configure_maas_url.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/tests/test_configure_maas_url.py 2014-11-07 18:13:30 +0000
@@ -0,0 +1,351 @@
1# Copyright 2014 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `MAAS_URL` configuration update code."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = []
16
17from argparse import ArgumentParser
18from random import randint
19from textwrap import dedent
20
21from maastesting.factory import factory
22from maastesting.matchers import (
23 MockAnyCall,
24 MockCalledOnceWith,
25 )
26from maastesting.testcase import MAASTestCase
27from mock import (
28 ANY,
29 Mock,
30 )
31from provisioningserver import configure_maas_url
32from provisioningserver.configure_maas_url import substitute_pserv_yaml_line
33from testtools.matchers import (
34 FileContains,
35 StartsWith,
36 )
37
38
39class TestRewriteConfigFile(MAASTestCase):
40
41 def test__rewrites_file(self):
42 path = self.make_file(contents='foo\n')
43 configure_maas_url.rewrite_config_file(path, lambda line: 'bar')
44 self.assertThat(path, FileContains('bar\n'))
45
46 def test__sets_access_permissions(self):
47 writer = self.patch(configure_maas_url, 'atomic_write')
48 mode = 0215
49 path = self.make_file()
50 configure_maas_url.rewrite_config_file(
51 path, lambda line: line, mode=mode)
52 self.assertThat(writer, MockCalledOnceWith(ANY, path, mode=mode))
53
54 def test__preserves_trailing_newline(self):
55 path = self.make_file(contents='x\n')
56 configure_maas_url.rewrite_config_file(path, lambda line: line)
57 self.assertThat(path, FileContains('x\n'))
58
59 def test__ensures_trailing_newline(self):
60 path = self.make_file(contents='x')
61 configure_maas_url.rewrite_config_file(path, lambda line: line)
62 self.assertThat(path, FileContains('x\n'))
63
64
65class TestUpdateMAASClusterConf(MAASTestCase):
66
67 def patch_file(self, content):
68 """Inject a fake `/etc/maas/maas_cluster.conf`."""
69 path = self.make_file(name='maas_cluster.conf', contents=content)
70 self.patch(configure_maas_url, 'MAAS_CLUSTER_CONF', path)
71 return path
72
73 def test__updates_realistic_file(self):
74 config_file = self.patch_file(dedent("""\
75 # Leading comments.
76 MAAS_URL="http://10.9.8.7/MAAS"
77 CLUSTER_UUID="5d02950e-6318-8195-ac3e-e6ccb12673c5"
78 """))
79 configure_maas_url.update_maas_cluster_conf('http://1.2.3.4/MAAS')
80 self.assertThat(
81 config_file,
82 FileContains(dedent("""\
83 # Leading comments.
84 MAAS_URL="http://1.2.3.4/MAAS"
85 CLUSTER_UUID="5d02950e-6318-8195-ac3e-e6ccb12673c5"
86 """)))
87
88 def test__updates_quoted_value(self):
89 old_url = factory.make_url()
90 new_url = factory.make_url()
91 config_file = self.patch_file('MAAS_URL="%s"\n' % old_url)
92 configure_maas_url.update_maas_cluster_conf(new_url)
93 self.assertThat(
94 config_file,
95 FileContains('MAAS_URL="%s"\n' % new_url))
96
97 def test__updates_unquoted_value(self):
98 old_url = factory.make_url()
99 new_url = factory.make_url()
100 config_file = self.patch_file('MAAS_URL=%s\n' % old_url)
101 configure_maas_url.update_maas_cluster_conf(new_url)
102 self.assertThat(
103 config_file,
104 FileContains('MAAS_URL="%s"\n' % new_url))
105
106 def test__leaves_other_lines_unchanged(self):
107 old_content = '#MAAS_URL="%s"\n' % factory.make_url()
108 config_file = self.patch_file(old_content)
109 configure_maas_url.update_maas_cluster_conf(factory.make_url())
110 self.assertThat(config_file, FileContains(old_content))
111
112
113class TestExtractHost(MAASTestCase):
114
115 def test__extracts_hostname(self):
116 host = factory.make_name('host').lower()
117 port = factory.pick_port()
118 self.assertEqual(
119 host,
120 configure_maas_url.extract_host('http://%s/path' % host))
121 self.assertEqual(
122 host,
123 configure_maas_url.extract_host('http://%s:%d' % (host, port)))
124
125 def test__extracts_IPv4_address(self):
126 host = factory.make_ipv4_address()
127 port = factory.pick_port()
128 self.assertEqual(
129 host,
130 configure_maas_url.extract_host('http://%s' % host))
131 self.assertEqual(
132 host,
133 configure_maas_url.extract_host('http://%s:%d' % (host, port)))
134
135 def test__extracts_IPv6_address(self):
136 host = factory.make_ipv6_address()
137 port = factory.pick_port()
138 self.assertEqual(
139 host,
140 configure_maas_url.extract_host('http://[%s]' % host))
141 self.assertEqual(
142 host,
143 configure_maas_url.extract_host('http://[%s]:%d' % (host, port)))
144
145 def test__extracts_IPv6_address_with_zone_index(self):
146 host = (
147 factory.make_ipv6_address() +
148 '%25' +
149 factory.make_name('zone').lower())
150 port = factory.pick_port()
151 self.assertEqual(
152 host,
153 configure_maas_url.extract_host('http://[%s]' % host))
154 self.assertEqual(
155 host,
156 configure_maas_url.extract_host('http://[%s]:%d' % (host, port)))
157
158
159class TestSubstitutePservYamlLine(MAASTestCase):
160
161 def make_generator_line(self, url):
162 return " generator: %s" % url
163
164 def test__replaces_hostname_generator_URL(self):
165 old_host = factory.make_name('old-host')
166 new_host = factory.make_name('new-host')
167 input_line = self.make_generator_line('http://%s' % old_host)
168 self.assertEqual(
169 self.make_generator_line('http://%s' % new_host),
170 substitute_pserv_yaml_line(new_host, input_line))
171
172 def test__replaces_IPv4_generator_URL(self):
173 old_host = factory.make_ipv4_address()
174 new_host = factory.make_name('new-host')
175 input_line = self.make_generator_line('http://%s' % old_host)
176 self.assertEqual(
177 self.make_generator_line('http://%s' % new_host),
178 substitute_pserv_yaml_line(new_host, input_line))
179
180 def test__replaces_IPv6_generator_URL(self):
181 old_host = factory.make_ipv6_address()
182 new_host = factory.make_name('new-host')
183 input_line = self.make_generator_line('http://[%s]' % old_host)
184 self.assertEqual(
185 self.make_generator_line('http://%s' % new_host),
186 substitute_pserv_yaml_line(new_host, input_line))
187
188 def test__replaces_IPv6_generator_URL_with_zone_index(self):
189 old_host = (
190 factory.make_ipv6_address() +
191 '%25' +
192 factory.make_name('zone')
193 )
194 new_host = factory.make_name('new-host')
195 input_line = self.make_generator_line('http://[%s]' % old_host)
196 self.assertEqual(
197 self.make_generator_line('http://%s' % new_host),
198 substitute_pserv_yaml_line(new_host, input_line))
199
200 def test__inserts_IPv6_with_brackets(self):
201 old_host = factory.make_name('old-host')
202 new_host = '[%s]' % factory.make_ipv6_address()
203 input_line = self.make_generator_line('http://%s' % old_host)
204 self.assertEqual(
205 self.make_generator_line('http://%s' % new_host),
206 substitute_pserv_yaml_line(new_host, input_line))
207
208 def test__inserts_IPv6_without_brackets(self):
209 old_host = factory.make_name('old-host')
210 new_host = factory.make_ipv6_address()
211 input_line = self.make_generator_line('http://%s' % old_host)
212 self.assertEqual(
213 self.make_generator_line('http://[%s]' % new_host),
214 substitute_pserv_yaml_line(new_host, input_line))
215
216 def test__preserves_port_after_simple_host(self):
217 port = factory.pick_port()
218 old_host = factory.make_name('old-host')
219 new_host = factory.make_name('new-host')
220 input_line = self.make_generator_line(
221 'http://%s:%d' % (old_host, port))
222 self.assertEqual(
223 self.make_generator_line('http://%s:%d' % (new_host, port)),
224 substitute_pserv_yaml_line(new_host, input_line))
225
226 def test__preserves_port_with_IPv6(self):
227 port = factory.pick_port()
228 old_host = factory.make_ipv6_address()
229 new_host = factory.make_name('new-host')
230 input_line = self.make_generator_line(
231 'http://[%s]:%d' % (old_host, port))
232 self.assertEqual(
233 self.make_generator_line('http://%s:%d' % (new_host, port)),
234 substitute_pserv_yaml_line(new_host, input_line))
235
236 def test__preserves_port_with_IPv6_and_zone_index(self):
237 port = factory.pick_port()
238 old_host = (
239 factory.make_ipv6_address() +
240 '%25' +
241 factory.make_name('zone')
242 )
243 new_host = factory.make_name('new-host')
244 input_line = self.make_generator_line(
245 'http://[%s]:%d' % (old_host, port))
246 self.assertEqual(
247 self.make_generator_line('http://%s:%d' % (new_host, port)),
248 substitute_pserv_yaml_line(new_host, input_line))
249
250 def test__preserves_other_line(self):
251 line = '#' + self.make_generator_line(factory.make_url())
252 self.assertEqual(
253 line,
254 substitute_pserv_yaml_line(factory.make_name('host'), line))
255
256 def test__preserves_indentation(self):
257 spaces = ' ' * randint(0, 10)
258 input_line = spaces + 'generator: %s' % factory.make_url()
259 output_line = substitute_pserv_yaml_line(
260 factory.make_name('host'), input_line)
261 self.assertThat(output_line, StartsWith(spaces + 'generator:'))
262
263 def test__preserves_trailing_comments(self):
264 comment = " # Trailing comment."
265 old_host = factory.make_name('old-host')
266 new_host = factory.make_name('new-host')
267 input_line = self.make_generator_line('http://%s' % old_host) + comment
268 self.assertEqual(
269 self.make_generator_line('http://%s' % new_host) + comment,
270 substitute_pserv_yaml_line(new_host, input_line))
271
272
273class TestUpdatePservYaml(MAASTestCase):
274
275 def patch_file(self, content):
276 """Inject a fake `/etc/maas/pserv.yaml`."""
277 path = self.make_file(name='pserv.yaml', contents=content)
278 self.patch(configure_maas_url, 'PSERV_YAML', path)
279 return path
280
281 def test__updates_realistic_file(self):
282 old_host = factory.make_name('old-host')
283 new_host = factory.make_name('new-host')
284 config_file = self.patch_file(dedent("""\
285 ## TFTP configuration.
286 tftp:
287 ## The URL to be contacted to generate PXE configurations.
288 generator: http://%s/MAAS/api/1.0/pxeconfig/
289 """) % old_host)
290 configure_maas_url.update_pserv_yaml(new_host)
291 self.assertThat(
292 config_file,
293 FileContains(dedent("""\
294 ## TFTP configuration.
295 tftp:
296 ## The URL to be contacted to generate PXE configurations.
297 generator: http://%s/MAAS/api/1.0/pxeconfig/
298 """) % new_host))
299
300
301class TestAddArguments(MAASTestCase):
302
303 def test__accepts_maas_url(self):
304 url = factory.make_url()
305 parser = ArgumentParser()
306 configure_maas_url.add_arguments(parser)
307 args = parser.parse_args([url])
308 self.assertEqual(url, args.maas_url)
309
310
311class TestRun(MAASTestCase):
312
313 def make_args(self, maas_url):
314 args = Mock()
315 args.maas_url = maas_url
316 return args
317
318 def patch_read_file(self):
319 return self.patch(configure_maas_url, 'read_text_file')
320
321 def patch_write_file(self):
322 return self.patch(configure_maas_url, 'atomic_write')
323
324 def test__updates_maas_cluster_conf(self):
325 reader = self.patch_read_file()
326 writer = self.patch_write_file()
327 url = factory.make_url()
328 configure_maas_url.run(self.make_args(url))
329 self.assertThat(reader, MockAnyCall('/etc/maas/maas_cluster.conf'))
330 self.assertThat(
331 writer,
332 MockAnyCall(ANY, '/etc/maas/maas_cluster.conf', mode=0640))
333
334 def test__updates_pserv_yaml(self):
335 reader = self.patch_read_file()
336 writer = self.patch_write_file()
337 url = factory.make_url()
338 configure_maas_url.run(self.make_args(url))
339 self.assertThat(reader, MockAnyCall('/etc/maas/pserv.yaml'))
340 self.assertThat(
341 writer,
342 MockAnyCall(ANY, '/etc/maas/pserv.yaml', mode=0644))
343
344 def test__passes_host_to_update_pserv_yaml(self):
345 self.patch_read_file()
346 self.patch_write_file()
347 update_pserv_yaml = self.patch(configure_maas_url, 'update_pserv_yaml')
348 host = factory.make_name('host').lower()
349 url = factory.make_url(netloc=host)
350 configure_maas_url.run(self.make_args(url))
351 self.assertThat(update_pserv_yaml, MockCalledOnceWith(host))