Merge lp:~blake-rouse/maas/maas-automate into lp:~maas-committers/maas/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
Reviewer Review Type Date Requested Status
MAAS Maintainers Pending
Review via email: mp+281232@code.launchpad.net
To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

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

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