Merge lp:~blake-rouse/maas/maas-automate into lp:~maas-committers/maas/trunk
- maas-automate
- Merge into trunk
Proposed by
Gavin Panella
Status: | Rejected |
---|---|
Rejected by: | MAAS Lander |
Proposed branch: | lp:~blake-rouse/maas/maas-automate |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
1244 lines (+1172/-0) (has conflicts) 10 files modified
Makefile (+11/-0) buildout.cfg (+10/-0) maas-automate.yaml (+179/-0) src/maasautomate/config.py (+76/-0) src/maasautomate/console.py (+70/-0) src/maasautomate/creator.py (+123/-0) src/maasautomate/creators/lxc_clone.py (+175/-0) src/maasautomate/main.py (+74/-0) src/maasautomate/monitor.py (+98/-0) src/maasautomate/run.py (+356/-0) Text conflict in Makefile |
To merge this branch: | bzr merge lp:~blake-rouse/maas/maas-automate |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MAAS Maintainers | Pending | ||
Review via email: mp+281232@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote : | # |
Unmerged revisions
- 4300. By Blake Rouse
-
Configure subnets and add the boilerplate for importing boot images.
- 4299. By Blake Rouse
-
Add all the logic to configure boot images, clusters, and interfaces.
- 4298. By Blake Rouse
-
Fix code the run inside the container.
- 4297. By Blake Rouse
-
Start of maas-automate.
- 4296. By Blake Rouse
-
Maas automate.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'Makefile' |
2 | --- Makefile 2015-12-21 21:19:44 +0000 |
3 | +++ Makefile 2015-12-22 17:03:15 +0000 |
4 | @@ -81,6 +81,13 @@ |
5 | echo '/src/**/*.pyc' >> $@ |
6 | echo '/etc/**/*.pyc' >> $@ |
7 | |
8 | +<<<<<<< TREE |
9 | +======= |
10 | +bin/python: |
11 | + $(virtualenv) --python=$(python) --system-site-packages $(CURDIR) |
12 | + #bin/pip install hypothesis==0.7.2 # buildout can't install this. |
13 | + |
14 | +>>>>>>> MERGE-SOURCE |
15 | configure-buildout: |
16 | utilities/configure-buildout |
17 | |
18 | @@ -140,6 +147,10 @@ |
19 | $(buildout) install config-test |
20 | @touch --no-create $@ |
21 | |
22 | +bin/maas-automate: bin/buildout buildout.cfg versions.cfg setup.py |
23 | + $(buildout) install automate |
24 | + @touch --no-create $@ |
25 | + |
26 | bin/flake8: bin/buildout buildout.cfg versions.cfg setup.py |
27 | $(buildout) install flake8 |
28 | @touch --no-create $@ |
29 | |
30 | === modified file 'buildout.cfg' |
31 | --- buildout.cfg 2015-12-08 14:51:58 +0000 |
32 | +++ buildout.cfg 2015-12-22 17:03:15 +0000 |
33 | @@ -259,6 +259,16 @@ |
34 | environ.setdefault("DEV_DB_NAME", "test_maas_e2e") |
35 | environ.setdefault("MAAS_PREVENT_MIGRATIONS", "1") |
36 | |
37 | +[automate] |
38 | +recipe = zc.recipe.egg |
39 | +eggs = |
40 | +entry-points = |
41 | + maas-automate=maasautomate.main:main |
42 | +extra-paths = |
43 | + ${common:extra-paths} |
44 | +scripts = |
45 | + maas-automate |
46 | + |
47 | [flake8] |
48 | recipe = zc.recipe.egg |
49 | eggs = |
50 | |
51 | === added file 'maas-automate.yaml' |
52 | --- maas-automate.yaml 1970-01-01 00:00:00 +0000 |
53 | +++ maas-automate.yaml 2015-12-22 17:03:15 +0000 |
54 | @@ -0,0 +1,179 @@ |
55 | +# Configuration for the MAAS Automation tool. |
56 | + |
57 | +# Creator to use for building of MAAS. |
58 | +# |
59 | +# Available Creators: |
60 | +# creator: |
61 | +# type: lxc-clone |
62 | +# name: maas # Name of the container to clone. |
63 | +# username: ubuntu # Username of the user to place |
64 | +# # data. (Default: ubuntu) |
65 | +# path: /srv/data # Path to build the package and install |
66 | +# # from. (Default: /home/{username}/maas-automate) |
67 | +# |
68 | +creator: |
69 | + type: lxc-clone |
70 | + name: maas |
71 | + dist_upgrade: false |
72 | + |
73 | +# Install curtin from a branch instead of using the distributions |
74 | +# curtin package. |
75 | +#curtin: |
76 | +# branch: lp:curtin |
77 | + |
78 | +# Configuration that is applied to the MAAS deployment before any nodes are |
79 | +# enlisted or probed into MAAS. All boot images will also be imported before |
80 | +# any enlistment or probing is done. |
81 | +config: |
82 | + boot_sources: |
83 | + - url: http://maas.ubuntu.com/images/ephemeral-v2/releases/ |
84 | + keyring_filename: /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg |
85 | + selections: |
86 | + - os: ubuntu |
87 | + release: precise |
88 | + arches: |
89 | + - amd64 |
90 | + - i386 |
91 | + - os: ubuntu |
92 | + release: trusty |
93 | + arches: |
94 | + - amd64 |
95 | + - i386 |
96 | + - os: ubuntu |
97 | + release: vivid |
98 | + arches: |
99 | + - amd64 |
100 | + - i386 |
101 | + ubuntu: |
102 | + main_archive: http://archive.ubuntu.com/ubuntu |
103 | + ports_archive: http://ports.ubuntu.com/ubuntu-ports |
104 | + boot: |
105 | + kernel_opts: "" |
106 | + network: |
107 | + http_proxy: "" |
108 | + upstream_dns: 192.168.1.1 |
109 | + ntp_server: ntp.ubuntu.com |
110 | + clusters: |
111 | + master: |
112 | + name: maas |
113 | + interfaces: |
114 | + eth0: |
115 | + management: 2 |
116 | + ip: 192.168.122.2 |
117 | + router_ip: 192.168.122.1 |
118 | + subnet_mask: 255.255.255.0 |
119 | + ip_range_low: 192.168.122.150 |
120 | + ip_range_high: 192.168.122.254 |
121 | + static_ip_range_low: 192.168.122.10 |
122 | + static_ip_range_high: 192.168.122.149 |
123 | + subnets: |
124 | + - cidr: 192.168.123.0/24 |
125 | + vlan: 1 |
126 | + |
127 | +# Probe for the following chassis and enlist all the machines in those |
128 | +# chasis into the MAAS. |
129 | +probe: |
130 | + - type: virsh |
131 | + address: qemu+ssh://blake@192.168.122.1/system |
132 | + password: p@ssw0rd |
133 | + prefix_filter: maas |
134 | + machines: 4 |
135 | + timeout: 60 |
136 | + |
137 | +# Modify the configuration of the selected machines below. The machines are |
138 | +# identified by a MAS address of one of its interfaces. This list doesn't need |
139 | +# to contain all the machines, just the machines that needs modifications |
140 | +# performed. |
141 | +machines: |
142 | + - mac_address: 52:54:00:f1:ec:be |
143 | + architecture: amd64/generic |
144 | + storage: |
145 | + layout: flat |
146 | + networking: |
147 | + interfaces: |
148 | + - mac_address: 52:54:00:f1:ec:be |
149 | + links: |
150 | + - type: auto |
151 | + subnet: 192.168.122.0/24 |
152 | + - type: static |
153 | + subnet: 192.168.123.0/24 |
154 | + ip: 192.168.123.50 |
155 | + - mac_address: 52:54:00:80:cc:02 |
156 | + architecture: i386/generic |
157 | + storage: |
158 | + layout: lvm |
159 | + networking: |
160 | + interfaces: |
161 | + - mac_address: 52:54:00:80:cc:02 |
162 | + links: |
163 | + - type: auto |
164 | + subnet: 192.168.122.0/24 |
165 | + - mac_address: 52:54:00:f2:0a:bd |
166 | + links: |
167 | + - type: auto |
168 | + subnet: 192.168.122.0/24 |
169 | + - mac_address: 2:54:00:0f:58:77 |
170 | + links: |
171 | + - type: auto |
172 | + subnet: 192.168.122.0/24 |
173 | + |
174 | +# Deploy the following configurations. Multiple will be performed at once until |
175 | +# all deployment options are choosen. Even if one fails all the others will |
176 | +# still be performed, until all have been performed. |
177 | +deploy: |
178 | + - os: ubuntu |
179 | + release: precise |
180 | + hwe_kernel: hwe-p |
181 | + arch: amd64 |
182 | + - os: ubuntu |
183 | + release: precise |
184 | + hwe_kernel: hwe-t |
185 | + arch: amd64 |
186 | + - os: ubuntu |
187 | + release: precise |
188 | + hwe_kernel: hwe-v |
189 | + arch: amd64 |
190 | + - os: ubuntu |
191 | + release: trusty |
192 | + hwe_kernel: hwe-t |
193 | + arch: amd64 |
194 | + - os: ubuntu |
195 | + release: trusty |
196 | + hwe_kernel: hwe-v |
197 | + arch: amd64 |
198 | + - os: ubuntu |
199 | + release: vivid |
200 | + arch: amd64 |
201 | + - os: ubuntu |
202 | + release: wily |
203 | + arch: amd64 |
204 | + - os: ubuntu |
205 | + release: trusty |
206 | + arch: i386 |
207 | + - os: ubuntu |
208 | + release: vivid |
209 | + arch: i386 |
210 | + - os: ubuntu |
211 | + release: wily |
212 | + arch: i386 |
213 | + |
214 | +# Use the MAAS installation with Juju. The list of deployments will be tried |
215 | +# in order with Juju. If one of the deployments fails all the others will |
216 | +# still be performed, untill all have been performed. |
217 | +juju: |
218 | + - ppa: juju/stable |
219 | + options: |
220 | + disable_network_management: true |
221 | + deploy: |
222 | + - charm: trusty/mysql |
223 | + - charm: trusty/wordpress |
224 | + relate: trusty/mysql |
225 | + expose: true |
226 | + - ppa: juju/devel |
227 | + options: |
228 | + use_address_allocation: true |
229 | + deploy: |
230 | + - charm: trusty/mysql |
231 | + - charm: trusty/wordpress |
232 | + relate: trusty/mysql |
233 | + expose: true |
234 | |
235 | === added directory 'src/maasautomate' |
236 | === added file 'src/maasautomate/__init__.py' |
237 | === added file 'src/maasautomate/config.py' |
238 | --- src/maasautomate/config.py 1970-01-01 00:00:00 +0000 |
239 | +++ src/maasautomate/config.py 2015-12-22 17:03:15 +0000 |
240 | @@ -0,0 +1,76 @@ |
241 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
242 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
243 | + |
244 | +"""Configuration for MAAS automate.""" |
245 | + |
246 | +from __future__ import ( |
247 | + absolute_import, |
248 | + print_function, |
249 | + unicode_literals, |
250 | + ) |
251 | + |
252 | +str = None |
253 | + |
254 | +__metaclass__ = type |
255 | +__all__ = [] |
256 | + |
257 | +import base64 |
258 | +import os |
259 | + |
260 | +import yaml |
261 | + |
262 | + |
263 | +DEFAULT_CONFIG_PATH = "~/.maas/automate.yaml" |
264 | + |
265 | + |
266 | +class MissingConfigError(Exception): |
267 | + """Raise when the configuration file is missing.""" |
268 | + |
269 | + |
270 | +class InvalidConfigError(Exception): |
271 | + """Raise when the configuration file is invalid.""" |
272 | + |
273 | + |
274 | +def find_config(): |
275 | + """Return the path to the configuration file.""" |
276 | + path = os.environ.get("MAAS_AUTOMATE_CONFIG", DEFAULT_CONFIG_PATH) |
277 | + path = os.path.expanduser(path) |
278 | + if not os.path.exists(path): |
279 | + raise MissingConfigError( |
280 | + "Unable to find configuration file: %s" % path) |
281 | + else: |
282 | + return path |
283 | + |
284 | + |
285 | +def load_config(): |
286 | + """Load the configuration file.""" |
287 | + path = find_config() |
288 | + with open(path, "r") as fp: |
289 | + return yaml.load(fp.read()) |
290 | + |
291 | + |
292 | +def load_config_from_base64(data): |
293 | + """Load the configuration from base64.""" |
294 | + config = base64.b64decode(data) |
295 | + return yaml.load(config) |
296 | + |
297 | + |
298 | +def get_config_value( |
299 | + config, key, default=None, required=False, section="global"): |
300 | + """Return the config value from `config` for `key`. |
301 | + |
302 | + `config` does not have to be the entire loaded config, it can be |
303 | + just a piece of the config as well. |
304 | + """ |
305 | + assert isinstance(config, dict) |
306 | + if required and key not in config: |
307 | + raise InvalidConfigError( |
308 | + "%s is required in the %s section." % (key, section)) |
309 | + else: |
310 | + return config.get(key, default) |
311 | + |
312 | + |
313 | +def convert_config_to_base64(config): |
314 | + """Return the config in base64 YAML.""" |
315 | + config = yaml.dump(config) |
316 | + return base64.b64encode(config) |
317 | |
318 | === added file 'src/maasautomate/console.py' |
319 | --- src/maasautomate/console.py 1970-01-01 00:00:00 +0000 |
320 | +++ src/maasautomate/console.py 2015-12-22 17:03:15 +0000 |
321 | @@ -0,0 +1,70 @@ |
322 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
323 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
324 | + |
325 | +"""Output information to the console.""" |
326 | + |
327 | +from __future__ import ( |
328 | + absolute_import, |
329 | + print_function, |
330 | + unicode_literals, |
331 | + ) |
332 | + |
333 | +str = None |
334 | + |
335 | +__metaclass__ = type |
336 | +__all__ = [] |
337 | + |
338 | +from functools import partial |
339 | +from subprocess import ( |
340 | + PIPE, |
341 | + Popen, |
342 | + STDOUT, |
343 | +) |
344 | +import sys |
345 | + |
346 | +from provisioningserver.utils.shell import ExternalProcessError |
347 | + |
348 | + |
349 | +def run_command(command, *args, **kwargs): |
350 | + """Run `command` while outputting to the console.""" |
351 | + output = kwargs.pop("output", True) |
352 | + prefix = kwargs.pop("prefix", None) |
353 | + process = Popen(command, *args, stdout=PIPE, stderr=STDOUT, **kwargs) |
354 | + ret = "" |
355 | + while True: |
356 | + line = process.stdout.readline() |
357 | + if line != "": |
358 | + ret += line |
359 | + if output: |
360 | + if prefix: |
361 | + sys.stdout.write("%s: %s" % (prefix, line)) |
362 | + else: |
363 | + sys.stdout.write(line) |
364 | + sys.stdout.flush() |
365 | + else: |
366 | + returncode = process.wait() |
367 | + if returncode != 0: |
368 | + raise ExternalProcessError( |
369 | + process.returncode, command, output=ret) |
370 | + else: |
371 | + break |
372 | + return ret |
373 | + |
374 | + |
375 | +def print_prefix(prefix, msg): |
376 | + """Print the message with prefix.""" |
377 | + print("%s: %s" % (prefix, msg)) |
378 | + |
379 | + |
380 | +start = partial(print_prefix, "S") |
381 | +install = partial(print_prefix, "I") |
382 | +config = partial(print_prefix, "C") |
383 | +enlist = partial(print_prefix, "E") |
384 | +deploy = partial(print_prefix, "D") |
385 | + |
386 | +cmd = run_command |
387 | +start_cmd = partial(run_command, prefix="S") |
388 | +install_cmd = partial(run_command, prefix="I") |
389 | +config_cmd = partial(run_command, prefix="C") |
390 | +enlist_cmd = partial(run_command, prefix="E") |
391 | +deploy_cmd = partial(run_command, prefix="D") |
392 | |
393 | === added file 'src/maasautomate/creator.py' |
394 | --- src/maasautomate/creator.py 1970-01-01 00:00:00 +0000 |
395 | +++ src/maasautomate/creator.py 2015-12-22 17:03:15 +0000 |
396 | @@ -0,0 +1,123 @@ |
397 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
398 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
399 | + |
400 | +""" |
401 | +Creator core for MAAS automate. |
402 | + |
403 | +Each specific module is loaded from maasautomate.creators. This just provides |
404 | +the logic to load the specific creator based on the configuration file. |
405 | +""" |
406 | + |
407 | +from __future__ import ( |
408 | + absolute_import, |
409 | + print_function, |
410 | + unicode_literals, |
411 | + ) |
412 | + |
413 | +str = None |
414 | + |
415 | +__metaclass__ = type |
416 | +__all__ = [] |
417 | + |
418 | +from importlib import import_module |
419 | +import os |
420 | + |
421 | +from maasautomate import console |
422 | +from maasautomate.config import ( |
423 | + convert_config_to_base64, |
424 | + get_config_value, |
425 | +) |
426 | + |
427 | + |
428 | +class InvalidCreatorError(Exception): |
429 | + """Raised when the creator does not exist or is not exporting the correct |
430 | + attributes.""" |
431 | + |
432 | + |
433 | +class CreatorFailError(Exception): |
434 | + """Raised when the creator failed.""" |
435 | + |
436 | + |
437 | +class Creator: |
438 | + """Creator that provides the logic for automating MAAS. |
439 | + |
440 | + This wraps the backend driver so different backends can be supported. |
441 | + """ |
442 | + |
443 | + def __init__(self, creator_name, run_data, config, driver): |
444 | + self.creator_name = creator_name |
445 | + self.run_data = run_data |
446 | + self.config = config |
447 | + self.driver = driver |
448 | + |
449 | + def start(self): |
450 | + """Start the creator.""" |
451 | + # The driver provides all this logic. |
452 | + console.start( |
453 | + "Starting the machine with creator '%s'." % self.creator_name) |
454 | + self.driver.start() |
455 | + |
456 | + def cleanup(self): |
457 | + """Cleanup the creator.""" |
458 | + # The driver provides all the logic. |
459 | + self.driver.cleanup() |
460 | + |
461 | + def run_internal(self): |
462 | + """Run automate inside of the machine.""" |
463 | + # Place the tarball into the machine. |
464 | + console.start("Injecting the branch in the machine.") |
465 | + tarball_path = self.driver.inject_tarball() |
466 | + self.run_data.workdir = os.path.dirname(tarball_path) |
467 | + tarball_name = os.path.basename(tarball_path) |
468 | + |
469 | + # Extract the tarball and set the working directory to the contents. |
470 | + console.start("Extracting the branch onto the machine.") |
471 | + self.driver.execute_command( |
472 | + console.start_cmd, ["tar", "zxf", tarball_name]) |
473 | + self.run_data.workdir = os.path.join( |
474 | + self.run_data.workdir, tarball_name.replace(".tar.gz", "")) |
475 | + |
476 | + # Start the runner inside the machine. |
477 | + run_internal_data = convert_config_to_base64(self.config) |
478 | + console.start("Starting MAAS automate inside the machine.") |
479 | + self.driver.execute_command(console.cmd, [ |
480 | + "make", "bin/maas-automate" |
481 | + ], output=False) |
482 | + try: |
483 | + self.driver.execute_command(console.cmd, [ |
484 | + "bin/maas-automate", "--run-internal", run_internal_data |
485 | + ]) |
486 | + except console.ExternalProcessError: |
487 | + # Catch this error as the internal run will print the real error |
488 | + # internal on the machine. |
489 | + raise CreatorFailError() |
490 | + |
491 | + |
492 | +def get_creator_driver_import_path(creator_type): |
493 | + """Return the import path for the creator type.""" |
494 | + creator_type = creator_type.replace('-', '_') |
495 | + return "maasautomate.creators.%s" % creator_type |
496 | + |
497 | + |
498 | +def import_creator_driver(creator_type, run_data, config): |
499 | + """Import the creator driver.""" |
500 | + module_path = get_creator_driver_import_path(creator_type) |
501 | + try: |
502 | + module = import_module(module_path) |
503 | + except ImportError: |
504 | + raise InvalidCreatorError("Creator %s does not exist." % creator_type) |
505 | + else: |
506 | + if not hasattr(module, "load_creator"): |
507 | + raise InvalidCreatorError( |
508 | + "Creator %s is missing the required load_creator function." % ( |
509 | + creator_type)) |
510 | + return module.load_creator(run_data, config) |
511 | + |
512 | + |
513 | +def load_creator(run_data, config): |
514 | + """Load the creator from the config.""" |
515 | + creator_config = get_config_value(config, "creator", required=True) |
516 | + creator_type = get_config_value( |
517 | + creator_config, "type", required=True, section="creator") |
518 | + driver = import_creator_driver(creator_type, run_data, creator_config) |
519 | + return Creator(creator_type, run_data, config, driver) |
520 | |
521 | === added directory 'src/maasautomate/creators' |
522 | === added file 'src/maasautomate/creators/__init__.py' |
523 | === added file 'src/maasautomate/creators/lxc_clone.py' |
524 | --- src/maasautomate/creators/lxc_clone.py 1970-01-01 00:00:00 +0000 |
525 | +++ src/maasautomate/creators/lxc_clone.py 2015-12-22 17:03:15 +0000 |
526 | @@ -0,0 +1,175 @@ |
527 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
528 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
529 | + |
530 | +""" |
531 | +lxc-clone creator for MAAS automate. |
532 | + |
533 | +Creator that clones an already existing LXC to perform the automation. This |
534 | +allows the developer to setup the LXC before starting the automation. |
535 | +""" |
536 | + |
537 | +from __future__ import ( |
538 | + absolute_import, |
539 | + print_function, |
540 | + unicode_literals, |
541 | + ) |
542 | + |
543 | +str = None |
544 | + |
545 | +__metaclass__ = type |
546 | +__all__ = [ |
547 | + "load_creator" |
548 | +] |
549 | + |
550 | +import os |
551 | +from shutil import copyfile |
552 | + |
553 | +from maasautomate.config import get_config_value |
554 | +from provisioningserver.utils.fs import ensure_dir |
555 | +from provisioningserver.utils.shell import ( |
556 | + call_and_check, |
557 | + ExternalProcessError, |
558 | +) |
559 | + |
560 | + |
561 | +class LXCCloneCreator: |
562 | + |
563 | + def __init__(self, run_data, config): |
564 | + self.run_data = run_data |
565 | + self.config = config |
566 | + self.name = get_config_value( |
567 | + config, "name", required=True, section="creator") |
568 | + self.username = get_config_value( |
569 | + config, "username", default="ubuntu") |
570 | + |
571 | + def _get_containers(self): |
572 | + """Return list of existing containers.""" |
573 | + return [ |
574 | + line.strip() |
575 | + for line in call_and_check(["lxc-ls"]).splitlines() |
576 | + ] |
577 | + |
578 | + def _run_container_exists(self): |
579 | + """Return True if the run container exists.""" |
580 | + containers = self._get_containers() |
581 | + return self.run_data.run_name in containers |
582 | + |
583 | + def _clone_container(self): |
584 | + """Clone the container.""" |
585 | + call_and_check([ |
586 | + "lxc-clone", |
587 | + self.name, |
588 | + self.run_data.run_name, |
589 | + ]) |
590 | + |
591 | + def _start_container(self): |
592 | + """Start the container.""" |
593 | + call_and_check([ |
594 | + "lxc-start", |
595 | + "-d", |
596 | + "-q", |
597 | + "-n", |
598 | + self.run_data.run_name, |
599 | + ]) |
600 | + |
601 | + def _get_container_rootfs(self): |
602 | + """Return the path to the containers rootfs.""" |
603 | + output = call_and_check([ |
604 | + "lxc-info", |
605 | + "-n", |
606 | + self.run_data.run_name, |
607 | + "-c", |
608 | + "lxc.rootfs", |
609 | + ]) |
610 | + return output.split("=")[1].strip() |
611 | + |
612 | + def start(self): |
613 | + """Create and start the container.""" |
614 | + self._clone_container() |
615 | + self._start_container() |
616 | + |
617 | + def cleanup(self): |
618 | + """Clean up the created container.""" |
619 | + try: |
620 | + call_and_check([ |
621 | + "lxc-stop", |
622 | + "-k", |
623 | + "-n", |
624 | + self.run_data.run_name, |
625 | + ]) |
626 | + except ExternalProcessError: |
627 | + # Ignore the error, might already be stopped. |
628 | + pass |
629 | + if self._run_container_exists(): |
630 | + call_and_check([ |
631 | + "lxc-destroy", |
632 | + "-n", |
633 | + self.run_data.run_name, |
634 | + ]) |
635 | + |
636 | + def inject_tarball(self): |
637 | + """Inject the tarball directly into the container.""" |
638 | + path = get_config_value( |
639 | + self.config, "path", default=None, required=False) |
640 | + if path is None: |
641 | + path = "/home/%s/maas-automate" % self.username |
642 | + |
643 | + # Create the required directory. |
644 | + injection_path = os.path.join( |
645 | + self._get_container_rootfs(), path.lstrip("/")) |
646 | + ensure_dir(injection_path) |
647 | + |
648 | + # Inject the file into the container. |
649 | + tarball_base = os.path.basename(self.run_data.tarball) |
650 | + injection_filepath = os.path.join(injection_path, tarball_base) |
651 | + copyfile(self.run_data.tarball, injection_filepath) |
652 | + |
653 | + # Set the permissions correctly. |
654 | + inside_path = os.path.join(path, tarball_base) |
655 | + call_and_check([ |
656 | + "lxc-attach", |
657 | + "-n", |
658 | + self.run_data.run_name, |
659 | + "--clear-env", |
660 | + "--", |
661 | + "chown", |
662 | + "%s:%s" % (self.username, self.username), |
663 | + path |
664 | + ]) |
665 | + call_and_check([ |
666 | + "lxc-attach", |
667 | + "-n", |
668 | + self.run_data.run_name, |
669 | + "--clear-env", |
670 | + "--", |
671 | + "chown", |
672 | + "%s:%s" % (self.username, self.username), |
673 | + inside_path |
674 | + ]) |
675 | + |
676 | + # Return the path from inside of the container. |
677 | + return inside_path |
678 | + |
679 | + def execute_command(self, runner, command, *args, **kwargs): |
680 | + """Execute the command inside the container.""" |
681 | + bash_cmd = "cd %s && %s" % ( |
682 | + self.run_data.workdir, |
683 | + " ".join(command), |
684 | + ) |
685 | + return runner([ |
686 | + "lxc-attach", |
687 | + "-n", |
688 | + self.run_data.run_name, |
689 | + "--clear-env", |
690 | + "--", |
691 | + "sudo", |
692 | + "-u", self.username, |
693 | + "/bin/bash", |
694 | + "-c", |
695 | + bash_cmd, |
696 | + ], *args, **kwargs) |
697 | + |
698 | + |
699 | +def load_creator(run_data, config): |
700 | + """Load the creator with `config`.""" |
701 | + return LXCCloneCreator(run_data, config) |
702 | |
703 | === added file 'src/maasautomate/main.py' |
704 | --- src/maasautomate/main.py 1970-01-01 00:00:00 +0000 |
705 | +++ src/maasautomate/main.py 2015-12-22 17:03:15 +0000 |
706 | @@ -0,0 +1,74 @@ |
707 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
708 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
709 | + |
710 | +""" |
711 | +MAAS Automate |
712 | + |
713 | +Tool to automate the installation of MAAS into an LXC container or a |
714 | +virtual machine. The newly installed MAAS with be configure, machines |
715 | +enlisted, commissioned, and deployments will be performed. |
716 | +""" |
717 | + |
718 | +from __future__ import ( |
719 | + absolute_import, |
720 | + print_function, |
721 | + unicode_literals, |
722 | + ) |
723 | + |
724 | +str = None |
725 | + |
726 | +__metaclass__ = type |
727 | +__all__ = [] |
728 | + |
729 | +import argparse |
730 | +import sys |
731 | + |
732 | +from maasautomate.config import ( |
733 | + load_config, |
734 | + load_config_from_base64, |
735 | +) |
736 | +from maasautomate.creator import ( |
737 | + CreatorFailError, |
738 | + load_creator, |
739 | +) |
740 | +from maasautomate.run import ( |
741 | + build_run_data, |
742 | + MAASRunner, |
743 | +) |
744 | + |
745 | + |
746 | +def main(): |
747 | + """Main entry point for the application.""" |
748 | + parser = argparse.ArgumentParser( |
749 | + description="Automate the installation and testing of MAAS.") |
750 | + parser.add_argument( |
751 | + "--uncommitted", action="store_true", help=( |
752 | + "Run automate with uncomitted code in the branch.")) |
753 | + parser.add_argument( |
754 | + "--run-internal", help=( |
755 | + "Run automate inside the deployed test machine. " |
756 | + "(Only called internally by maas-automate.)")) |
757 | + args = parser.parse_args() |
758 | + if args.run_internal: |
759 | + # Internal running, means we are running inside the machine. |
760 | + # run_internal contains the base64 encode YAML config. |
761 | + config = load_config_from_base64(args.run_internal) |
762 | + runner = MAASRunner(config) |
763 | + runner.run() |
764 | + else: |
765 | + # Not inside the machine so we need to spawn the machine with the |
766 | + # creator and spawn the internal run. |
767 | + run_data = build_run_data(uncommitted=args.uncommitted) |
768 | + config = load_config() |
769 | + creator = load_creator(run_data, config) |
770 | + |
771 | + exit_code = 0 |
772 | + try: |
773 | + creator.start() |
774 | + creator.run_internal() |
775 | + except CreatorFailError: |
776 | + exit_code = 1 |
777 | + finally: |
778 | + creator.cleanup() |
779 | + run_data.cleanup() |
780 | + sys.exit(exit_code) |
781 | |
782 | === added file 'src/maasautomate/monitor.py' |
783 | --- src/maasautomate/monitor.py 1970-01-01 00:00:00 +0000 |
784 | +++ src/maasautomate/monitor.py 2015-12-22 17:03:15 +0000 |
785 | @@ -0,0 +1,98 @@ |
786 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
787 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
788 | + |
789 | +"""Monitors MAAS to make sure it stays running.""" |
790 | + |
791 | +from __future__ import ( |
792 | + absolute_import, |
793 | + print_function, |
794 | + unicode_literals, |
795 | + ) |
796 | + |
797 | +str = None |
798 | + |
799 | +__metaclass__ = type |
800 | +__all__ = [] |
801 | + |
802 | +from abc import ( |
803 | + ABCMeta, |
804 | + abstractmethod, |
805 | + abstractproperty, |
806 | +) |
807 | +import threading |
808 | +import time |
809 | +import urllib2 |
810 | + |
811 | + |
812 | +class MonitorTimeoutError(Exception): |
813 | + """Raise when the monitor times out waiting.""" |
814 | + |
815 | + |
816 | +class MonitorBase: |
817 | + |
818 | + __metaclass__ = ABCMeta |
819 | + |
820 | + def __init__(self): |
821 | + self.running = False |
822 | + |
823 | + def start(self): |
824 | + """Start the monitor.""" |
825 | + self._event = threading.Event() |
826 | + self._thread = threading.Thread(target=self._run) |
827 | + self._thread.start() |
828 | + |
829 | + def stop(self): |
830 | + """Stop the monitor.""" |
831 | + self._event.set() |
832 | + self._thread.join() |
833 | + |
834 | + def wait_for_running(self, timeout=60 * 3): |
835 | + """Wait for it to be running.""" |
836 | + for _ in range(timeout): |
837 | + if self.running: |
838 | + return |
839 | + time.sleep(1) |
840 | + raise MonitorTimeoutError( |
841 | + "Waiting for '%s' to start running timed out after " |
842 | + "%s seconds." % (self.name, timeout)) |
843 | + |
844 | + def _run(self): |
845 | + """Run the monitor and update the status.""" |
846 | + while True: |
847 | + if self._event.is_set(): |
848 | + return |
849 | + |
850 | + # Update the running state. |
851 | + self.running = self._monitor() |
852 | + |
853 | + # Wait 5 seconds to check if running, but see if thread has been |
854 | + # stopped every second. |
855 | + for _ in range(5): |
856 | + if self._event.is_set(): |
857 | + return |
858 | + time.sleep(1) |
859 | + |
860 | + @abstractproperty |
861 | + def name(self): |
862 | + """Name of the service being monitored.""" |
863 | + |
864 | + @abstractmethod |
865 | + def _monitor(self): |
866 | + """Monitor the endpoint.""" |
867 | + |
868 | + |
869 | +class MonitorRegion(MonitorBase): |
870 | + """Monitors the MAAS region to make sure that it is running and stays |
871 | + running.""" |
872 | + |
873 | + name = "regiond" |
874 | + |
875 | + def _monitor(self): |
876 | + """Monitor the RPC endpoint on the region to make sure that |
877 | + it is working.""" |
878 | + try: |
879 | + stream = urllib2.urlopen("http://localhost:5240/MAAS/rpc/") |
880 | + stream.close() |
881 | + return True |
882 | + except urllib2.HTTPError: |
883 | + return False |
884 | |
885 | === added file 'src/maasautomate/run.py' |
886 | --- src/maasautomate/run.py 1970-01-01 00:00:00 +0000 |
887 | +++ src/maasautomate/run.py 2015-12-22 17:03:15 +0000 |
888 | @@ -0,0 +1,356 @@ |
889 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
890 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
891 | + |
892 | +"""Provide the run data.""" |
893 | + |
894 | +from __future__ import ( |
895 | + absolute_import, |
896 | + print_function, |
897 | + unicode_literals, |
898 | + ) |
899 | + |
900 | +str = None |
901 | + |
902 | +__metaclass__ = type |
903 | +__all__ = [ |
904 | + "build_run_data", |
905 | +] |
906 | + |
907 | +from collections import namedtuple |
908 | +from datetime import datetime |
909 | +import json |
910 | +import os |
911 | +import tempfile |
912 | +import time |
913 | + |
914 | +from maasautomate import console |
915 | +from maasautomate.config import get_config_value |
916 | +from maasautomate.monitor import MonitorRegion |
917 | +from maasserver.utils.version import get_maas_branch |
918 | +from provisioningserver.utils.shell import call_and_check |
919 | + |
920 | + |
921 | +class RunDataError(Exception): |
922 | + """Raise when data gathering stage fails.""" |
923 | + |
924 | + |
925 | +class RunError(Exception): |
926 | + """Raise when an error performing the run.""" |
927 | + |
928 | + |
929 | +RunDataBase = namedtuple("RunDataBase", [ |
930 | + "datetime", |
931 | + "branch", |
932 | + "tmpdir", |
933 | + "tarball", |
934 | + ]) |
935 | + |
936 | + |
937 | +class RunData(RunDataBase): |
938 | + """Current run data.""" |
939 | + |
940 | + def __init__(self, *args, **kwargs): |
941 | + super(RunData, self).__init__(*args, **kwargs) |
942 | + self.workdir = None |
943 | + |
944 | + @property |
945 | + def run_name(self): |
946 | + """Name of the run.""" |
947 | + time_str = self.datetime.strftime("%Y-%m-%d-%H-%M-%S") |
948 | + return "maas+%s-%s" % (self.branch.revno(), time_str) |
949 | + |
950 | + def cleanup(self): |
951 | + """Cleanup any run data.""" |
952 | + os.unlink(self.tarball) |
953 | + os.rmdir(self.tmpdir) |
954 | + |
955 | + |
956 | +def export_branch(tmpdir, revno, uncommitted=False): |
957 | + """Export the current branch.""" |
958 | + output_name = "maas+%s" % revno |
959 | + output_path = "%s/%s.tar" % (tmpdir.rstrip('/'), output_name) |
960 | + export_cmd = ["bzr", "export"] |
961 | + if uncommitted: |
962 | + export_cmd.append("--uncommitted") |
963 | + export_cmd.extend(["--root=%s" % output_name, output_path]) |
964 | + call_and_check(export_cmd) |
965 | + call_and_check([ |
966 | + "tar", |
967 | + "-rf", |
968 | + output_path, |
969 | + "--transform", |
970 | + "s%%^%%%s/%%" % output_name, |
971 | + ".bzr", |
972 | + ]) |
973 | + call_and_check(["gzip", output_path]) |
974 | + return output_path + ".gz" |
975 | + |
976 | + |
977 | +def build_run_data(uncommitted=False): |
978 | + """Build the current run data.""" |
979 | + branch = get_maas_branch() |
980 | + if branch is None: |
981 | + raise RunDataError( |
982 | + "Unable to get MAAS bazaar version, you can only run " |
983 | + "maas-automate from source branch.") |
984 | + console.start("Compressing current branch for injection.") |
985 | + tmpdir = tempfile.mkdtemp(prefix="maas-automate-") |
986 | + tarball = export_branch(tmpdir, branch.revno(), uncommitted=uncommitted) |
987 | + return RunData( |
988 | + datetime=datetime.now(), branch=branch, tmpdir=tmpdir, |
989 | + tarball=tarball) |
990 | + |
991 | + |
992 | +class MAASRunner: |
993 | + """Runs the MAAS auomation inside of the machine to test MAAS.""" |
994 | + |
995 | + def __init__(self, config): |
996 | + self.config = config |
997 | + |
998 | + def run(self): |
999 | + """Perform the automation.""" |
1000 | + self.apt_update() |
1001 | + self.install_curtin() |
1002 | + self.install_maas() |
1003 | + self.configure() |
1004 | + |
1005 | + def _clean_apikey(self, apikey): |
1006 | + """Cleanup the API key output.""" |
1007 | + for line in apikey.splitlines(): |
1008 | + line = line.strip() |
1009 | + if line != "" and not line.startswith("sudo:"): |
1010 | + return line |
1011 | + |
1012 | + def apt_update(self): |
1013 | + """Install MAAS and curtin on the machine.""" |
1014 | + # Update the packages. |
1015 | + creator_config = get_config_value( |
1016 | + self.config, "creator", required=True) |
1017 | + dist_upgrade = get_config_value( |
1018 | + creator_config, "dist_upgrade", default=True) |
1019 | + if dist_upgrade: |
1020 | + console.install("Updating packages on machine.") |
1021 | + console.install_cmd(["sudo", "apt-get", "update"]) |
1022 | + console.install_cmd([ |
1023 | + "sudo", |
1024 | + "DEBIAN_FRONTEND=noninteractive", |
1025 | + "apt-get", |
1026 | + "dist-upgrade", |
1027 | + "-y", |
1028 | + ]) |
1029 | + else: |
1030 | + console.install("Skipped updating packages on machine.") |
1031 | + |
1032 | + def install_curtin(self): |
1033 | + """Install curtin on the machine if requested.""" |
1034 | + curtin_config = get_config_value(self.config, "curtin") |
1035 | + if curtin_config is not None: |
1036 | + branch = get_config_value(curtin_config, "branch") |
1037 | + if branch is not None: |
1038 | + console.install("Installing curtin from branch: %s" % branch) |
1039 | + console.install_cmd([ |
1040 | + "/bin/bash", "-c", |
1041 | + "cd .. && bzr branch %s curtin && cd curtin && " |
1042 | + "tools/build-deb -us -uc && " |
1043 | + "sudo dpkg -i *.deb" % branch, |
1044 | + ]) |
1045 | + return |
1046 | + console.install( |
1047 | + "Curtin was not installed from a branch it will be " |
1048 | + "installed from the release archive.") |
1049 | + |
1050 | + def install_maas(self): |
1051 | + """Install MAAS on the machine.""" |
1052 | + # Installing dependencies. |
1053 | + console.install("Installing dependencies for MAAS.") |
1054 | + console.install_cmd(["make", "install-dependencies"]) |
1055 | + |
1056 | + # Make the package. |
1057 | + console.install("Building the MAAS packages.") |
1058 | + console.install_cmd(["make", "package"]) |
1059 | + |
1060 | + # Install MAAS. |
1061 | + console.install("Installing MAAS from packaging.") |
1062 | + console.install_cmd([ |
1063 | + "/bin/bash", "-c", |
1064 | + "cd ../build-area && " |
1065 | + "sudo dpkg -i *.deb || " |
1066 | + "sudo apt-get install -f || " |
1067 | + "sudo apt-get install -f"]) |
1068 | + console.install("Installation of MAAS was successful.") |
1069 | + |
1070 | + def configure(self): |
1071 | + """Configure the new MAAS installation.""" |
1072 | + console.config("Creating administrator account.") |
1073 | + console.config_cmd([ |
1074 | + "sudo", "maas-region-admin", "createadmin", |
1075 | + "--username", "admin", "--password", "test", |
1076 | + "--email", "admin@test.com", |
1077 | + ]) |
1078 | + |
1079 | + self.regiond_monitor = MonitorRegion() |
1080 | + self.regiond_monitor.start() |
1081 | + try: |
1082 | + console.config("Checking for MAAS regiond to be running.") |
1083 | + self.regiond_monitor.wait_for_running() |
1084 | + console.config( |
1085 | + "MAAS regiond is working and handing RPC connections.") |
1086 | + |
1087 | + console.config("Logging into the MAAS API.") |
1088 | + apikey = console.config_cmd([ |
1089 | + "sudo", "maas-region-admin", "apikey", |
1090 | + "--username", "admin", |
1091 | + ], output=False) |
1092 | + apikey = self._clean_apikey(apikey) |
1093 | + console.config_cmd([ |
1094 | + "maas", "login", "admin", |
1095 | + "http://localhost:5240/MAAS", apikey, |
1096 | + ], output=False) |
1097 | + |
1098 | + config = get_config_value(self.config, "config") |
1099 | + if config is not None: |
1100 | + boot_sources = get_config_value(config, "boot_sources") |
1101 | + if boot_sources is not None: |
1102 | + self.configure_boot_sources(boot_sources) |
1103 | + |
1104 | + self.configure_global_settings(config) |
1105 | + |
1106 | + clusters_config = get_config_value(config, "clusters") |
1107 | + if clusters_config is not None: |
1108 | + self.configure_clusters(clusters_config) |
1109 | + |
1110 | + self.configure_networking(config) |
1111 | + self.import_boot_images() |
1112 | + |
1113 | + finally: |
1114 | + self.regiond_monitor.stop() |
1115 | + |
1116 | + def _run_maascli(self, runner, cmds, output=True, return_json=False): |
1117 | + """Run maascli with the runner and MAAS command.""" |
1118 | + output = runner(["maas", "admin"] + cmds, output=output) |
1119 | + if return_json: |
1120 | + return json.loads(output) |
1121 | + else: |
1122 | + return output |
1123 | + |
1124 | + def _run_maascli_config(self, commands, output=True, return_json=False): |
1125 | + """Run maascli as a configuration step.""" |
1126 | + return self._run_maascli( |
1127 | + console.config_cmd, commands, |
1128 | + output=output, return_json=return_json) |
1129 | + |
1130 | + def configure_boot_sources(self, config): |
1131 | + """Configure the boot sources.""" |
1132 | + console.config("Clearing default boot sources.") |
1133 | + for boot_source in self._run_maascli_config( |
1134 | + ["boot-sources", "read"], output=False, return_json=True): |
1135 | + self._run_maascli_config( |
1136 | + ["boot-source", "delete", "%s" % boot_source["id"]], |
1137 | + output=False) |
1138 | + |
1139 | + console.config("Adding new boot sources.") |
1140 | + for new_source in config: |
1141 | + url = new_source["url"] |
1142 | + keyring_filename = new_source["keyring_filename"] |
1143 | + source_id = "%s" % self._run_maascli_config([ |
1144 | + "boot-sources", "create", |
1145 | + "url=%s" % url, "keyring_filename=%s" % keyring_filename], |
1146 | + output=False, return_json=True)["id"] |
1147 | + |
1148 | + # Wait 3 seconds for the boot source cache to be primed. |
1149 | + time.sleep(3) |
1150 | + |
1151 | + for selection in new_source.get("selections", []): |
1152 | + cmd = [ |
1153 | + "boot-source-selections", "create", source_id, |
1154 | + "os=%s" % selection["os"], |
1155 | + "release=%s" % selection["release"], |
1156 | + ] |
1157 | + for arch in selection["arches"]: |
1158 | + cmd.append("arches=%s" % arch) |
1159 | + cmd.append("subarches=*") |
1160 | + cmd.append("labels=*") |
1161 | + self._run_maascli_config(cmd, output=False) |
1162 | + |
1163 | + def _set_global_settings(self, key, value): |
1164 | + self._run_maascli_config([ |
1165 | + "maas", "set-config", "name=%s" % key, "value=%s" % value], |
1166 | + output=False) |
1167 | + |
1168 | + def _set_config_value(self, config, key): |
1169 | + value = config.get(key, None) |
1170 | + if value is not None: |
1171 | + self._set_global_settings(key, value) |
1172 | + |
1173 | + def configure_global_settings(self, config): |
1174 | + """Configure the global settings.""" |
1175 | + console.config("Setting global configuration values.") |
1176 | + ubuntu_config = get_config_value(config, "ubuntu") |
1177 | + if ubuntu_config is not None: |
1178 | + self._set_config_value(ubuntu_config, "main_archive") |
1179 | + self._set_config_value(ubuntu_config, "ports_archive") |
1180 | + boot_config = get_config_value(config, "boot") |
1181 | + if boot_config is not None: |
1182 | + self._set_config_value(boot_config, "kernel_opts") |
1183 | + network_config = get_config_value(config, "network") |
1184 | + if network_config is not None: |
1185 | + self._set_config_value(network_config, "http_proxy") |
1186 | + self._set_config_value(network_config, "upstream_dns") |
1187 | + self._set_config_value(network_config, "ntp_server") |
1188 | + |
1189 | + def _add_config_to_command(self, command, config): |
1190 | + for key, value in config.items(): |
1191 | + command.append("%s=%s" % (key, value)) |
1192 | + |
1193 | + def configure_clusters(self, config): |
1194 | + """Configure the cluster settings.""" |
1195 | + clusters = self._run_maascli_config([ |
1196 | + "node-groups", "list"], output=False, return_json=True) |
1197 | + for cluster_name, config in config.items(): |
1198 | + console.config( |
1199 | + "Configuring cluster controller '%s'." % cluster_name) |
1200 | + if cluster_name == "master": |
1201 | + # Grap the first cluster uuid. |
1202 | + cluster_uuid = clusters[0]["uuid"] |
1203 | + else: |
1204 | + cluster_uuid = None |
1205 | + for cluster in clusters: |
1206 | + if cluster["uuid"] == cluster_name: |
1207 | + cluster_uuid = cluster["uuid"] |
1208 | + break |
1209 | + if cluster_uuid: |
1210 | + raise RunError("Unable to find cluster %s." % cluster_name) |
1211 | + |
1212 | + # Update the name or cluster_name if requested. |
1213 | + if "name" in config or "cluster_name" in config: |
1214 | + cmd = [ |
1215 | + "node-group", "update", cluster_uuid |
1216 | + ] |
1217 | + if "name" in config: |
1218 | + cmd.append("name=%s" % config["name"]) |
1219 | + if "cluster_name" in config: |
1220 | + cmd.append("cluster_name=%s" % config["cluster_name"]) |
1221 | + |
1222 | + # Configure the interfaces for the cluster. |
1223 | + for eth_name, eth_config in config.get("interfaces", {}).items(): |
1224 | + cmd = [ |
1225 | + "node-group-interface", "update", cluster_uuid, eth_name, |
1226 | + ] |
1227 | + self._add_config_to_command(cmd, eth_config) |
1228 | + self._run_maascli_config(cmd, output=False, return_json=False) |
1229 | + |
1230 | + def configure_networking(self, config): |
1231 | + """Configure the networking settings.""" |
1232 | + subnets = get_config_value(config, "subnets", default=[]) |
1233 | + if len(subnets) > 0: |
1234 | + console.config("Configuring subnets.") |
1235 | + for subnet in subnets: |
1236 | + cmd = [ |
1237 | + "subnets", "create", |
1238 | + ] |
1239 | + self._add_config_to_command(cmd, subnet) |
1240 | + self._run_maascli_config(cmd, output=False, return_json=False) |
1241 | + |
1242 | + def import_boot_images(self): |
1243 | + """Import the boot images and wait for it to finish.""" |
1244 | + pass |
Transitioned to Git.
lp:maas has now moved from Bzr to Git.
Please propose your branches with Launchpad using Git.
git clone https:/ /git.launchpad. net/maas