Gavin Panella has proposed merging lp:~allenap/maas/tftp-path-in-pserv into lp:maas.
Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~allenap/maas/tftp-path-in-pserv/+merge/116318 The diff is big, but there's a lot of movement and context. It's all as a result of removing TFTPROOT from celeryconfig: - Sets tftp/root in pserv.yaml to /var/lib/tftpboot. - Makes TFTPROOT and MAAS_PROVISIONING_SETTINGS both mandatory parameters in scripts/maas-import-pxe-files. I'm hopeful that we can absorb this script into Python at some point, so this slight ugliness is transient. - Define PROVISIONING_SETTINGS in the Django configs. This is so the Django app can load provisioning configuration info. - Split out the configuration stuff from provisioningserver.plugin into its own .config module (and corresponding test module). - Create a new fixture, ConfigFixture. - Create a new subclass of ActionScript, MainScript, which takes a config file parameter. This is so all commands will uniformally be able to consume a configuration file. - Lots of other sed-like changes. -- https://code.launchpad.net/~allenap/maas/tftp-path-in-pserv/+merge/116318 Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/tftp-path-in-pserv into lp:maas.
=== modified file 'etc/celeryconfig.py' --- etc/celeryconfig.py 2012-07-17 09:51:38 +0000 +++ etc/celeryconfig.py 2012-07-23 16:06:48 +0000 @@ -26,9 +26,6 @@ # None to use the templates installed with the running version of MAAS. PXE_TEMPLATES_DIR = None -# TFTP server's root directory. -TFTPROOT = "/var/lib/tftpboot" - # Location of MAAS' bind configuration files. DNS_CONFIG_DIR = '/var/cache/bind/maas' === modified file 'etc/pserv.yaml' --- etc/pserv.yaml 2012-07-05 20:19:59 +0000 +++ etc/pserv.yaml 2012-07-23 16:06:48 +0000 @@ -60,7 +60,7 @@ ## TFTP configuration. # tftp: - # root: <current directory> + root: /var/lib/tftpboot # port: 5244 ## The URL to be contacted to generate PXE configurations. # generator: http://localhost:5243/api/1.0/pxeconfig === modified file 'scripts/maas-import-pxe-files' --- scripts/maas-import-pxe-files 2012-07-13 22:42:54 +0000 +++ scripts/maas-import-pxe-files 2012-07-23 16:06:48 +0000 @@ -34,8 +34,13 @@ # Supported architectures. ARCHES=${ARCHES:-amd64 i386} -# TFTP root directory. (Don't let the "root" vs. "boot" confuse you.) -TFTPROOT=${TFTPROOT:-/var/lib/tftpboot} +# TFTP root directory. Mandatory. +# TODO: Remove this; it's here to support the obsolete +# generate_enlistment_pxe command. +TFTPROOT=${TFTPROOT?} + +# Path to the provisioning configuration. Mandatory. +MAAS_PROVISIONING_SETTINGS=${MAAS_PROVISIONING_SETTINGS?} # Command line to download a resource at a given URL into the current # directory. A wget command line will work here, but curl will do as well. @@ -70,7 +75,7 @@ then # TODO: Pass sub-architecture once we support those. maas-provision install-pxe-bootloader \ - --arch=$arch --loader='pxelinux.0' --tftproot=$TFTPROOT + --arch=$arch --loader='pxelinux.0' fi } @@ -93,7 +98,7 @@ maas-provision install-pxe-image \ --arch=$arch --release=$release --purpose="install" \ - --image="install" --tftproot=$TFTPROOT + --image="install" } @@ -118,6 +123,7 @@ done # TODO: Pass sub-architecture once we support those. + # TODO: Remove this; it's obsolete. maas generate_enlistment_pxe \ --arch=$arch --release=$CURRENT_RELEASE \ --tftproot=$TFTPROOT === modified file 'src/maas/development.py' --- src/maas/development.py 2012-07-11 09:06:57 +0000 +++ src/maas/development.py 2012-07-23 16:06:48 +0000 @@ -98,6 +98,8 @@ COMMISSIONING_SCRIPT = os.path.join( DEV_ROOT_DIRECTORY, 'etc/maas/commissioning-user-data') +PROVISIONING_SETTINGS = abspath("etc/pserv.yaml") + # Set up celery to use the demo settings. os.environ['CELERY_CONFIG_MODULE'] = 'democeleryconfig' === modified file 'src/maas/settings.py' --- src/maas/settings.py 2012-07-06 10:12:25 +0000 +++ src/maas/settings.py 2012-07-23 16:06:48 +0000 @@ -307,5 +307,11 @@ "/usr/share/maas/preseeds", ) +# Settings used for provisioning. +# TODO: un-cargo-cult this from provisioningserver.utils.MainScript. +PROVISIONING_SETTINGS = os.environ.get( + "MAAS_PROVISIONING_SETTINGS", "/etc/maas/pserv.yaml") + + # Allow the user to override settings in maas_local_settings. import_local_settings() === modified file 'src/maasserver/api.py' --- src/maasserver/api.py 2012-07-12 12:33:57 +0000 +++ src/maasserver/api.py 2012-07-23 16:06:48 +0000 @@ -132,6 +132,7 @@ from piston.models import Token from piston.resource import Resource from piston.utils import rc +import provisioningserver.config from provisioningserver.pxe.pxeconfig import ( PXEConfig, PXEConfigFail, @@ -1037,10 +1038,14 @@ :param kernelimage: The path to the kernel in the TFTP server :param append: Kernel parameters to append. """ + provisioning_config = ( + provisioningserver.config.Config.load_from_cache( + settings.PROVISIONING_SETTINGS)) arch = get_mandatory_param(request.GET, 'arch') subarch = request.GET.get('subarch', None) mac = request.GET.get('mac', None) - config = PXEConfig(arch, subarch, mac) + tftproot = provisioning_config["tftp"]["root"] + config = PXEConfig(arch, subarch, mac, tftproot) # Rendering parameters. menutitle = get_mandatory_param(request.GET, 'menutitle') kernelimage = get_mandatory_param(request.GET, 'kernelimage') === modified file 'src/maasserver/management/commands/generate_enlistment_pxe.py' --- src/maasserver/management/commands/generate_enlistment_pxe.py 2012-06-26 07:27:06 +0000 +++ src/maasserver/management/commands/generate_enlistment_pxe.py 2012-07-23 16:06:48 +0000 @@ -29,6 +29,7 @@ class Command(BaseCommand): """Print out enlistment PXE config.""" + # TODO: Remove this; it's obsolete. option_list = BaseCommand.option_list + ( make_option( === modified file 'src/provisioningserver/__main__.py' --- src/provisioningserver/__main__.py 2012-07-13 16:32:05 +0000 +++ src/provisioningserver/__main__.py 2012-07-23 16:06:48 +0000 @@ -15,10 +15,10 @@ import provisioningserver.dhcp.writer import provisioningserver.pxe.install_bootloader import provisioningserver.pxe.install_image -from provisioningserver.utils import ActionScript - - -main = ActionScript(__doc__) +from provisioningserver.utils import MainScript + + +main = MainScript(__doc__) main.register( "generate-dhcp-config", provisioningserver.dhcp.writer) === added file 'src/provisioningserver/config.py' --- src/provisioningserver/config.py 1970-01-01 00:00:00 +0000 +++ src/provisioningserver/config.py 2012-07-23 16:06:48 +0000 @@ -0,0 +1,129 @@ +# Copyright 2012 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""MAAS Provisioning Configuration.""" + +from __future__ import ( + absolute_import, + print_function, + unicode_literals, + ) + +__metaclass__ = type +__all__ = [ + "Config", + ] + +from getpass import getuser +from os.path import abspath +from threading import RLock + +from formencode import Schema +from formencode.validators import ( + Int, + RequireIfPresent, + String, + URL, + ) +import yaml + + +class ConfigOops(Schema): + """Configuration validator for OOPS options.""" + + if_key_missing = None + + directory = String(if_missing=b"") + reporter = String(if_missing=b"") + + chained_validators = ( + RequireIfPresent("reporter", present="directory"), + ) + + +class ConfigBroker(Schema): + """Configuration validator for message broker options.""" + + if_key_missing = None + + host = String(if_missing=b"localhost") + port = Int(min=1, max=65535, if_missing=5673) + username = String(if_missing=getuser()) + password = String(if_missing=b"test") + vhost = String(if_missing="/") + + +class ConfigCobbler(Schema): + """Configuration validator for connecting to Cobbler.""" + + if_key_missing = None + + url = URL( + add_http=True, require_tld=False, + if_missing=b"http://localhost/cobbler_api", + ) + username = String(if_missing=getuser()) + password = String(if_missing=b"test") + + +class ConfigTFTP(Schema): + """Configuration validator for the TFTP service.""" + + if_key_missing = None + + root = String(if_missing="/var/lib/tftpboot") + port = Int(min=1, max=65535, if_missing=5244) + generator = URL( + add_http=True, require_tld=False, + if_missing=b"http://localhost:5243/api/1.0/pxeconfig", + ) + + +class Config(Schema): + """Configuration validator.""" + + if_key_missing = None + + interface = String(if_empty=b"", if_missing=b"127.0.0.1") + port = Int(min=1, max=65535, if_missing=5241) + username = String(not_empty=True, if_missing=getuser()) + password = String(not_empty=True) + logfile = String(if_empty=b"pserv.log", if_missing=b"pserv.log") + oops = ConfigOops + broker = ConfigBroker + cobbler = ConfigCobbler + tftp = ConfigTFTP + + @classmethod + def parse(cls, stream): + """Load a YAML configuration from `stream` and validate.""" + return cls.to_python(yaml.safe_load(stream)) + + @classmethod + def load(cls, filename): + """Load a YAML configuration from `filename` and validate.""" + with open(filename, "rb") as stream: + return cls.parse(stream) + + _cache = {} + _cache_lock = RLock() + + @classmethod + def load_from_cache(cls, filename): + """Load or return a previously loaded configuration. + + This is thread-safe, so is okay to use from Django, for example. + """ + filename = abspath(filename) + with cls._cache_lock: + if filename not in cls._cache: + with open(filename, "rb") as stream: + cls._cache[filename] = cls.parse(stream) + return cls._cache[filename] + + @classmethod + def field(target, *steps): + """Obtain a field by following `steps`.""" + for step in steps: + target = target.fields[step] + return target === modified file 'src/provisioningserver/plugin.py' --- src/provisioningserver/plugin.py 2012-07-06 19:53:41 +0000 +++ src/provisioningserver/plugin.py 2012-07-23 16:06:48 +0000 @@ -12,18 +12,9 @@ __metaclass__ = type __all__ = [] -from getpass import getuser - -from formencode import Schema -from formencode.validators import ( - Int, - RequireIfPresent, - String, - URL, - ) from provisioningserver.amqpclient import AMQFactory from provisioningserver.cobblerclient import CobblerSession -from provisioningserver.pxe.tftppath import locate_tftp_path +from provisioningserver.config import Config from provisioningserver.remote import ProvisioningAPI_XMLRPC from provisioningserver.services import ( LogService, @@ -65,7 +56,6 @@ Resource, ) from twisted.web.server import Site -import yaml from zope.interface import implementer @@ -107,84 +97,6 @@ raise NotImplementedError() -class ConfigOops(Schema): - """Configuration validator for OOPS options.""" - - if_key_missing = None - - directory = String(if_missing=b"") - reporter = String(if_missing=b"") - - chained_validators = ( - RequireIfPresent("reporter", present="directory"), - ) - - -class ConfigBroker(Schema): - """Configuration validator for message broker options.""" - - if_key_missing = None - - host = String(if_missing=b"localhost") - port = Int(min=1, max=65535, if_missing=5673) - username = String(if_missing=getuser()) - password = String(if_missing=b"test") - vhost = String(if_missing="/") - - -class ConfigCobbler(Schema): - """Configuration validator for connecting to Cobbler.""" - - if_key_missing = None - - url = URL( - add_http=True, require_tld=False, - if_missing=b"http://localhost/cobbler_api", - ) - username = String(if_missing=getuser()) - password = String(if_missing=b"test") - - -class ConfigTFTP(Schema): - """Configuration validator for the TFTP service.""" - - if_key_missing = None - - root = String(if_missing=locate_tftp_path()) - port = Int(min=1, max=65535, if_missing=5244) - generator = URL( - add_http=True, require_tld=False, - if_missing=b"http://localhost:5243/api/1.0/pxeconfig", - ) - - -class Config(Schema): - """Configuration validator.""" - - if_key_missing = None - - interface = String(if_empty=b"", if_missing=b"127.0.0.1") - port = Int(min=1, max=65535, if_missing=5241) - username = String(not_empty=True, if_missing=getuser()) - password = String(not_empty=True) - logfile = String(if_empty=b"pserv.log", if_missing=b"pserv.log") - oops = ConfigOops - broker = ConfigBroker - cobbler = ConfigCobbler - tftp = ConfigTFTP - - @classmethod - def parse(cls, stream): - """Load a YAML configuration from `stream` and validate.""" - return cls.to_python(yaml.safe_load(stream)) - - @classmethod - def load(cls, filename): - """Load a YAML configuration from `filename` and validate.""" - with open(filename, "rb") as stream: - return cls.parse(stream) - - class Options(usage.Options): """Command line options for the provisioning server.""" === modified file 'src/provisioningserver/pxe/install_bootloader.py' --- src/provisioningserver/pxe/install_bootloader.py 2012-07-13 16:01:59 +0000 +++ src/provisioningserver/pxe/install_bootloader.py 2012-07-23 16:06:48 +0000 @@ -19,7 +19,7 @@ import os.path from shutil import copyfile -from celeryconfig import TFTPROOT +from provisioningserver.config import Config from provisioningserver.pxe.tftppath import ( compose_bootloader_path, locate_tftp_path, @@ -97,10 +97,6 @@ parser.add_argument( '--loader', dest='loader', default=None, help="PXE pre-boot loader to install.") - parser.add_argument( - '--tftproot', dest='tftproot', default=TFTPROOT, help=( - "Store to this TFTP directory tree instead of the " - "default [%(default)s].")) def run(args): @@ -109,7 +105,9 @@ This won't overwrite an existing loader if its contents are unchanged. However the new loader you give it will be deleted regardless. """ - destination = make_destination(args.tftproot, args.arch, args.subarch) + config = Config.load(args.config_file) + tftproot = config["tftp"]["root"] + destination = make_destination(tftproot, args.arch, args.subarch) install_bootloader(args.loader, destination) if os.path.exists(args.loader): os.remove(args.loader) === modified file 'src/provisioningserver/pxe/install_image.py' --- src/provisioningserver/pxe/install_image.py 2012-07-13 16:32:05 +0000 +++ src/provisioningserver/pxe/install_image.py 2012-07-23 16:06:48 +0000 @@ -22,7 +22,7 @@ rmtree, ) -from celeryconfig import TFTPROOT +from provisioningserver.config import Config from provisioningserver.pxe.tftppath import ( compose_image_path, locate_tftp_path, @@ -130,10 +130,6 @@ parser.add_argument( '--image', dest='image', default=None, help="Netboot image directory, containing kernel & initrd.") - parser.add_argument( - '--tftproot', dest='tftproot', default=TFTPROOT, help=( - "Store to this TFTP directory tree instead of the " - "default [%(default)s].")) def run(args): @@ -144,8 +140,10 @@ containing identical files, the new image is deleted and the old one is left untouched. """ + config = Config.load(args.config_file) + tftproot = config["tftp"]["root"] destination = make_destination( - args.tftproot, args.arch, args.subarch, args.release, args.purpose) + tftproot, args.arch, args.subarch, args.release, args.purpose) if not are_identical_dirs(destination, args.image): # Image has changed. Move the new version into place. install_dir(args.image, destination) === modified file 'src/provisioningserver/pxe/tests/test_install_bootloader.py' --- src/provisioningserver/pxe/tests/test_install_bootloader.py 2012-07-13 16:01:59 +0000 +++ src/provisioningserver/pxe/tests/test_install_bootloader.py 2012-07-23 16:06:48 +0000 @@ -29,7 +29,8 @@ compose_bootloader_path, locate_tftp_path, ) -from provisioningserver.utils import ActionScript +from provisioningserver.testing.config import ConfigFixture +from provisioningserver.utils import MainScript from testtools.matchers import ( DirExists, FileContains, @@ -41,21 +42,26 @@ class TestInstallPXEBootloader(TestCase): def test_integration(self): + tftproot = self.make_dir() + config = {"tftp": {"root": tftproot}} + config_fixture = ConfigFixture(config) + self.useFixture(config_fixture) + loader = self.make_file() - tftproot = self.make_dir() arch = factory.make_name('arch') subarch = factory.make_name('subarch') action = factory.make_name("action") - script = ActionScript(action) + script = MainScript(action) script.register(action, provisioningserver.pxe.install_bootloader) script.execute( - (action, "--arch", arch, "--subarch", subarch, - "--loader", loader, "--tftproot", tftproot)) + ("--config-file", config_fixture.filename, action, "--arch", arch, + "--subarch", subarch, "--loader", loader)) self.assertThat( locate_tftp_path( - compose_bootloader_path(arch, subarch), tftproot=tftproot), + compose_bootloader_path(arch, subarch), + tftproot=tftproot), FileExists()) self.assertThat(loader, Not(FileExists())) === modified file 'src/provisioningserver/pxe/tests/test_install_image.py' --- src/provisioningserver/pxe/tests/test_install_image.py 2012-07-13 16:32:05 +0000 +++ src/provisioningserver/pxe/tests/test_install_image.py 2012-07-23 16:06:48 +0000 @@ -26,7 +26,8 @@ compose_image_path, locate_tftp_path, ) -from provisioningserver.utils import ActionScript +from provisioningserver.testing.config import ConfigFixture +from provisioningserver.utils import MainScript from testtools.matchers import ( DirExists, FileContains, @@ -48,20 +49,24 @@ class TestInstallPXEImage(TestCase): def test_integration(self): + tftproot = self.make_dir() + config = {"tftp": {"root": tftproot}} + config_fixture = ConfigFixture(config) + self.useFixture(config_fixture) + download_dir = self.make_dir() image_dir = os.path.join(download_dir, 'image') os.makedirs(image_dir) factory.make_file(image_dir, 'kernel') - tftproot = self.make_dir() arch, subarch, release, purpose = make_arch_subarch_release_purpose() action = factory.make_name("action") - script = ActionScript(action) + script = MainScript(action) script.register(action, provisioningserver.pxe.install_image) script.execute( - (action, "--arch", arch, "--subarch", subarch, "--release", - release, "--purpose", purpose, "--image", image_dir, - "--tftproot", tftproot)) + ("--config-file", config_fixture.filename, action, "--arch", arch, + "--subarch", subarch, "--release", release, "--purpose", purpose, + "--image", image_dir)) self.assertThat( os.path.join( === modified file 'src/provisioningserver/pxe/tests/test_pxeconfig.py' --- src/provisioningserver/pxe/tests/test_pxeconfig.py 2012-07-04 13:07:31 +0000 +++ src/provisioningserver/pxe/tests/test_pxeconfig.py 2012-07-23 16:06:48 +0000 @@ -26,6 +26,7 @@ compose_config_path, locate_tftp_path, ) +from provisioningserver.testing.config import ConfigFixture import tempita from testtools.matchers import ( Contains, @@ -37,47 +38,62 @@ class TestPXEConfig(TestCase): """Tests for PXEConfig.""" + def setUp(self): + super(TestPXEConfig, self).setUp() + self.tftproot = self.make_dir() + self.config = {"tftp": {"root": self.tftproot}} + self.useFixture(ConfigFixture(self.config)) + def configure_templates_dir(self, path=None): """Configure PXE_TEMPLATES_DIR to `path`.""" self.patch( provisioningserver.pxe.pxeconfig, 'PXE_TEMPLATES_DIR', path) def test_init_sets_up_paths(self): - pxeconfig = PXEConfig("armhf", "armadaxp") + pxeconfig = PXEConfig("armhf", "armadaxp", tftproot=self.tftproot) expected_template = os.path.join( pxeconfig.template_basedir, 'maas.template') - expected_target = os.path.dirname(locate_tftp_path( - compose_config_path('armhf', 'armadaxp', 'default'))) + expected_target = os.path.dirname( + locate_tftp_path( + compose_config_path('armhf', 'armadaxp', 'default'), + tftproot=self.tftproot)) self.assertEqual(expected_template, pxeconfig.template) self.assertEqual( expected_target, os.path.dirname(pxeconfig.target_file)) def test_init_with_no_subarch_makes_path_with_generic(self): - pxeconfig = PXEConfig("i386") - expected_target = os.path.dirname(locate_tftp_path( - compose_config_path('i386', 'generic', 'default'))) + pxeconfig = PXEConfig("i386", tftproot=self.tftproot) + expected_target = os.path.dirname( + locate_tftp_path( + compose_config_path('i386', 'generic', 'default'), + tftproot=self.tftproot)) self.assertEqual( expected_target, os.path.dirname(pxeconfig.target_file)) def test_init_with_no_mac_sets_default_filename(self): - pxeconfig = PXEConfig("armhf", "armadaxp") + pxeconfig = PXEConfig("armhf", "armadaxp", tftproot=self.tftproot) expected_filename = locate_tftp_path( - compose_config_path('armhf', 'armadaxp', 'default')) + compose_config_path('armhf', 'armadaxp', 'default'), + tftproot=self.tftproot) self.assertEqual(expected_filename, pxeconfig.target_file) def test_init_with_dodgy_mac(self): # !=5 colons is bad. bad_mac = "aa:bb:cc:dd:ee" exception = self.assertRaises( - PXEConfigFail, PXEConfig, "armhf", "armadaxp", bad_mac) + PXEConfigFail, PXEConfig, "armhf", "armadaxp", bad_mac, + tftproot=self.tftproot) self.assertEqual( exception.message, "Expecting exactly five ':' chars, found 4") def test_init_with_mac_sets_filename(self): - pxeconfig = PXEConfig("armhf", "armadaxp", mac="00:a1:b2:c3:e4:d5") + pxeconfig = PXEConfig( + "armhf", "armadaxp", mac="00:a1:b2:c3:e4:d5", + tftproot=self.tftproot) expected_filename = locate_tftp_path( - compose_config_path('armhf', 'armadaxp', '00-a1-b2-c3-e4-d5')) + compose_config_path('armhf', 'armadaxp', '00-a1-b2-c3-e4-d5'), + tftproot=self.tftproot) self.assertEqual(expected_filename, pxeconfig.target_file) def test_template_basedir_defaults_to_local_dir(self): @@ -86,7 +102,7 @@ self.assertEqual( os.path.join( os.path.dirname(os.path.dirname(__file__)), 'templates'), - PXEConfig(arch).template_basedir) + PXEConfig(arch, tftproot=self.tftproot).template_basedir) def test_template_basedir_prefers_configured_value(self): temp_dir = self.make_dir() @@ -94,11 +110,11 @@ arch = factory.make_name('arch') self.assertEqual( temp_dir, - PXEConfig(arch).template_basedir) + PXEConfig(arch, tftproot=self.tftproot).template_basedir) def test_get_template_retrieves_template(self): self.configure_templates_dir() - pxeconfig = PXEConfig("i386") + pxeconfig = PXEConfig("i386", tftproot=self.tftproot) template = pxeconfig.get_template() self.assertIsInstance(template, tempita.Template) self.assertThat(pxeconfig.template, FileContains(template.content)) @@ -108,10 +124,12 @@ template = self.make_file(name='maas.template', contents=contents) self.configure_templates_dir(os.path.dirname(template)) arch = factory.make_name('arch') - self.assertEqual(contents, PXEConfig(arch).get_template().content) + self.assertEqual( + contents, PXEConfig( + arch, tftproot=self.tftproot).get_template().content) def test_render_template(self): - pxeconfig = PXEConfig("i386") + pxeconfig = PXEConfig("i386", tftproot=self.tftproot) template = tempita.Template("template: {{kernelimage}}") rendered = pxeconfig.render_template(template, kernelimage="myimage") self.assertEqual("template: myimage", rendered) @@ -119,7 +137,7 @@ def test_render_template_raises_PXEConfigFail(self): # If not enough arguments are supplied to fill in template # variables then a PXEConfigFail is raised. - pxeconfig = PXEConfig("i386") + pxeconfig = PXEConfig("i386", tftproot=self.tftproot) template_name = factory.getRandomString() template = tempita.Template( "template: {{kernelimage}}", name=template_name) === modified file 'src/provisioningserver/pxe/tests/test_tftppath.py' --- src/provisioningserver/pxe/tests/test_tftppath.py 2012-07-06 19:53:41 +0000 +++ src/provisioningserver/pxe/tests/test_tftppath.py 2012-07-23 16:06:48 +0000 @@ -14,7 +14,6 @@ import os.path -from celeryconfig import TFTPROOT from maastesting.factory import factory from maastesting.testcase import TestCase from provisioningserver.pxe.tftppath import ( @@ -23,6 +22,7 @@ compose_image_path, locate_tftp_path, ) +from provisioningserver.testing.config import ConfigFixture from testtools.matchers import ( Not, StartsWith, @@ -31,6 +31,12 @@ class TestTFTPPath(TestCase): + def setUp(self): + super(TestTFTPPath, self).setUp() + self.tftproot = self.make_dir() + self.config = {"tftp": {"root": self.tftproot}} + self.useFixture(ConfigFixture(self.config)) + def test_compose_config_path_follows_maas_pxe_directory_layout(self): arch = factory.make_name('arch') subarch = factory.make_name('subarch') @@ -45,7 +51,7 @@ name = factory.make_name('config') self.assertThat( compose_config_path(arch, subarch, name), - Not(StartsWith(TFTPROOT))) + Not(StartsWith(self.tftproot))) def test_compose_image_path_follows_maas_pxe_directory_layout(self): arch = factory.make_name('arch') @@ -63,7 +69,7 @@ purpose = factory.make_name('purpose') self.assertThat( compose_image_path(arch, subarch, release, purpose), - Not(StartsWith(TFTPROOT))) + Not(StartsWith(self.tftproot))) def test_compose_bootloader_path_follows_maas_pxe_directory_layout(self): arch = factory.make_name('arch') @@ -77,13 +83,13 @@ subarch = factory.make_name('subarch') self.assertThat( compose_bootloader_path(arch, subarch), - Not(StartsWith(TFTPROOT))) + Not(StartsWith(self.tftproot))) def test_locate_tftp_path_prefixes_tftp_root_by_default(self): pxefile = factory.make_name('pxefile') self.assertEqual( - os.path.join(TFTPROOT, pxefile), - locate_tftp_path(pxefile)) + os.path.join(self.tftproot, pxefile), + locate_tftp_path(pxefile, tftproot=self.tftproot)) def test_locate_tftp_path_overrides_default_tftproot(self): tftproot = '/%s' % factory.make_name('tftproot') @@ -93,4 +99,5 @@ locate_tftp_path(pxefile, tftproot=tftproot)) def test_locate_tftp_path_returns_root_by_default(self): - self.assertEqual(TFTPROOT, locate_tftp_path()) + self.assertEqual( + self.tftproot, locate_tftp_path(tftproot=self.tftproot)) === modified file 'src/provisioningserver/pxe/tftppath.py' --- src/provisioningserver/pxe/tftppath.py 2012-07-09 16:03:46 +0000 +++ src/provisioningserver/pxe/tftppath.py 2012-07-23 16:06:48 +0000 @@ -19,8 +19,6 @@ import os.path -from celeryconfig import TFTPROOT - def compose_bootloader_path(arch, subarch): """Compose the TFTP path for a PXE pre-boot loader.""" @@ -73,8 +71,7 @@ :param tftproot: Optional TFTP root directory to override the configured default. """ - if tftproot is None: - tftproot = TFTPROOT + assert tftproot is not None, "tftproot must be defined." if tftp_path is None: return tftproot return os.path.join(tftproot, tftp_path.lstrip('/')) === added file 'src/provisioningserver/testing/config.py' --- src/provisioningserver/testing/config.py 1970-01-01 00:00:00 +0000 +++ src/provisioningserver/testing/config.py 2012-07-23 16:06:48 +0000 @@ -0,0 +1,52 @@ +# Copyright 2005-2012 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for the psmaas TAP.""" + +from __future__ import ( + absolute_import, + print_function, + unicode_literals, + ) + +__metaclass__ = type +__all__ = [ + "ConfigFixture", + ] + +from os import path + +from fixtures import ( + EnvironmentVariableFixture, + Fixture, + TempDir, + ) +from maastesting.factory import factory +import yaml + + +class ConfigFixture(Fixture): + + def __init__(self, config=None): + super(ConfigFixture, self).__init__() + # The smallest config snippet that will validate. + self.config = { + "password": factory.getRandomString(), + } + if config is not None: + self.config.update(config) + + def setUp(self): + super(ConfigFixture, self).setUp() + # Create a real configuration file, and populate it. + self.dir = self.useFixture(TempDir()).path + self.filename = path.join(self.dir, "config.yaml") + with open(self.filename, "wb") as stream: + yaml.safe_dump(self.config, stream=stream) + # Export this filename to the environment, so that subprocesses will + # pick up this configuration. Define the new environment as an + # instance variable so that users of this fixture can use this to + # extend custom subprocess environments. + self.environ = {"MAAS_PROVISIONING_SETTINGS": self.filename} + for name, value in self.environ.items(): + self.useFixture(EnvironmentVariableFixture(name, value)) === added file 'src/provisioningserver/tests/test_config.py' --- src/provisioningserver/tests/test_config.py 1970-01-01 00:00:00 +0000 +++ src/provisioningserver/tests/test_config.py 2012-07-23 16:06:48 +0000 @@ -0,0 +1,155 @@ +# Copyright 2005-2012 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for provisioning configuration.""" + +from __future__ import ( + absolute_import, + print_function, + unicode_literals, + ) + +__metaclass__ = type +__all__ = [] + +from functools import partial +from getpass import getuser +import os +from textwrap import dedent + +import formencode +from maastesting.factory import factory +from maastesting.testcase import TestCase +from provisioningserver.config import Config +from provisioningserver.testing.config import ConfigFixture +from testtools.matchers import ( + DirExists, + FileExists, + MatchesException, + Raises, + ) +import yaml + + +class TestConfigFixture(TestCase): + """Tests for `provisioningserver.testing.config.ConfigFixture`.""" + + def exercise_fixture(self, fixture): + # ConfigFixture arranges a minimal configuration on disk, and exports + # the configuration filename to the environment so that subprocesses + # can find it. + with fixture: + self.assertThat(fixture.dir, DirExists()) + self.assertThat(fixture.filename, FileExists()) + self.assertEqual( + {"MAAS_PROVISIONING_SETTINGS": fixture.filename}, + fixture.environ) + self.assertEqual( + fixture.filename, os.environ["MAAS_PROVISIONING_SETTINGS"]) + with open(fixture.filename, "rb") as stream: + self.assertEqual(fixture.config, yaml.safe_load(stream)) + + def test_use_minimal(self): + # With no arguments, ConfigFixture arranges a minimal configuration. + fixture = ConfigFixture() + self.exercise_fixture(fixture) + + def test_use_with_config(self): + # Given a configuration, ConfigFixture can arrange a minimal global + # configuration with the additional options merged in. + dummy_logfile = factory.make_name("logfile") + fixture = ConfigFixture({"logfile": dummy_logfile}) + self.assertEqual(dummy_logfile, fixture.config["logfile"]) + self.exercise_fixture(fixture) + + +class TestConfig(TestCase): + """Tests for `provisioningserver.config.Config`.""" + + def test_defaults(self): + mandatory = { + 'password': 'killing_joke', + } + expected = { + 'broker': { + 'host': 'localhost', + 'port': 5673, + 'username': getuser(), + 'password': 'test', + 'vhost': '/', + }, + 'cobbler': { + 'url': 'http://localhost/cobbler_api', + 'username': getuser(), + 'password': 'test', + }, + 'logfile': 'pserv.log', + 'oops': { + 'directory': '', + 'reporter': '', + }, + 'tftp': { + 'generator': 'http://localhost:5243/api/1.0/pxeconfig', + 'port': 5244, + 'root': "/var/lib/tftpboot", + }, + 'interface': '127.0.0.1', + 'port': 5241, + 'username': getuser(), + } + expected.update(mandatory) + observed = Config.to_python(mandatory) + self.assertEqual(expected, observed) + + def test_parse(self): + # Configuration can be parsed from a snippet of YAML. + observed = Config.parse( + b'logfile: "/some/where.log"\n' + b'password: "black_sabbath"\n' + ) + self.assertEqual("/some/where.log", observed["logfile"]) + + def test_load(self): + # Configuration can be loaded and parsed from a file. + config = dedent(""" + logfile: "/some/where.log" + password: "megadeth" + """) + filename = self.make_file(name="config.yaml", contents=config) + observed = Config.load(filename) + self.assertEqual("/some/where.log", observed["logfile"]) + + def test_load_example(self): + # The example configuration can be loaded and validated. + filename = os.path.join( + os.path.dirname(__file__), os.pardir, + os.pardir, os.pardir, "etc", "pserv.yaml") + Config.load(filename) + + def test_load_from_cache(self): + # A config loaded by Config.load_from_cache() is never reloaded. + filename = self.make_file( + name="config.yaml", contents='password: irrelevant') + config_before = Config.load_from_cache(filename) + os.unlink(filename) + config_after = Config.load_from_cache(filename) + self.assertIs(config_before, config_after) + + def test_oops_directory_without_reporter(self): + # It is an error to omit the OOPS reporter if directory is specified. + config = ( + 'oops:\n' + ' directory: /tmp/oops\n' + ) + expected = MatchesException( + formencode.Invalid, "oops: You must give a value for reporter") + self.assertThat( + partial(Config.parse, config), + Raises(expected)) + + def test_field(self): + self.assertIs(Config, Config.field()) + self.assertIs(Config.fields["tftp"], Config.field("tftp")) + self.assertIs( + Config.fields["tftp"].fields["root"], + Config.field("tftp", "root")) === modified file 'src/provisioningserver/tests/test_maas_import_pxe_files.py' --- src/provisioningserver/tests/test_maas_import_pxe_files.py 2012-07-13 22:42:28 +0000 +++ src/provisioningserver/tests/test_maas_import_pxe_files.py 2012-07-23 16:06:48 +0000 @@ -21,6 +21,7 @@ age_file, get_write_time, ) +from provisioningserver.testing.config import ConfigFixture from testtools.matchers import ( Contains, FileContains, @@ -67,6 +68,13 @@ class TestImportPXEFiles(TestCase): + def setUp(self): + super(TestImportPXEFiles, self).setUp() + self.tftproot = self.make_dir() + self.config = {"tftp": {"root": self.tftproot}} + self.config_fixture = ConfigFixture(self.config) + self.useFixture(self.config_fixture) + def make_downloads(self, release=None, arch=None): """Set up a directory with an image for "download" by the script. @@ -102,8 +110,11 @@ # Substitute curl for wget; it accepts file:// URLs. 'DOWNLOAD': 'curl -O --silent', 'PATH': os.pathsep.join(path), - 'TFTPROOT': tftproot, + # TODO: Remove TFTPROOT; it's here to support the obsolete + # generate_enlistment_pxe command. + 'TFTPROOT': self.tftproot, } + env.update(self.config_fixture.environ) if arch is not None: env['ARCHES'] = arch if release is not None: @@ -116,9 +127,8 @@ arch = factory.make_name('arch') release = 'precise' archive = self.make_downloads(arch=arch, release=release) - tftproot = self.make_dir() - self.call_script(archive, tftproot, arch=arch, release=release) - tftp_path = compose_tftp_path(tftproot, arch, 'pxelinux.0') + self.call_script(archive, self.tftproot, arch=arch, release=release) + tftp_path = compose_tftp_path(self.tftproot, arch, 'pxelinux.0') download_path = compose_download_dir(archive, arch, release) expected_contents = read_file(download_path, 'pxelinux.0') self.assertThat(tftp_path, FileContains(expected_contents)) @@ -129,21 +139,19 @@ archive = self.make_downloads(arch=arch, release=release) download_path = compose_download_dir(archive, arch, release) os.remove(os.path.join(download_path, 'pxelinux.0')) - tftproot = self.make_dir() - self.call_script(archive, tftproot, arch=arch, release=release) - tftp_path = compose_tftp_path(tftproot, arch, 'pxelinux.0') + self.call_script(archive, self.tftproot, arch=arch, release=release) + tftp_path = compose_tftp_path(self.tftproot, arch, 'pxelinux.0') self.assertThat(tftp_path, Not(FileExists())) def test_updates_pre_boot_loader(self): arch = factory.make_name('arch') release = 'precise' - tftproot = self.make_dir() - tftp_path = compose_tftp_path(tftproot, arch, 'pxelinux.0') + tftp_path = compose_tftp_path(self.tftproot, arch, 'pxelinux.0') os.makedirs(os.path.dirname(tftp_path)) with open(tftp_path, 'w') as existing_file: existing_file.write(factory.getRandomString()) archive = self.make_downloads(arch=arch, release=release) - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) download_path = compose_download_dir(archive, arch, release) expected_contents = read_file(download_path, 'pxelinux.0') self.assertThat(tftp_path, FileContains(expected_contents)) @@ -152,10 +160,9 @@ arch = factory.make_name('arch') release = 'precise' archive = self.make_downloads(arch=arch, release=release) - tftproot = self.make_dir() - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) tftp_path = compose_tftp_path( - tftproot, arch, release, 'install', 'linux') + self.tftproot, arch, release, 'install', 'linux') download_path = compose_download_dir(archive, arch, release) expected_contents = read_file(download_path, 'linux') self.assertThat(tftp_path, FileContains(expected_contents)) @@ -163,14 +170,13 @@ def test_updates_install_image(self): arch = factory.make_name('arch') release = 'precise' - tftproot = self.make_dir() tftp_path = compose_tftp_path( - tftproot, arch, release, 'install', 'linux') + self.tftproot, arch, release, 'install', 'linux') os.makedirs(os.path.dirname(tftp_path)) with open(tftp_path, 'w') as existing_file: existing_file.write(factory.getRandomString()) archive = self.make_downloads(arch=arch, release=release) - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) download_path = compose_download_dir(archive, arch, release) expected_contents = read_file(download_path, 'linux') self.assertThat(tftp_path, FileContains(expected_contents)) @@ -179,22 +185,21 @@ arch = factory.make_name('arch') release = 'precise' archive = self.make_downloads(arch=arch, release=release) - tftproot = self.make_dir() - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) tftp_path = compose_tftp_path( - tftproot, arch, release, 'install', 'linux') + self.tftproot, arch, release, 'install', 'linux') backdate(tftp_path) original_timestamp = get_write_time(tftp_path) - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) self.assertEqual(original_timestamp, get_write_time(tftp_path)) def test_generates_default_pxe_config(self): arch = factory.make_name('arch') release = 'precise' - tftproot = self.make_dir() archive = self.make_downloads(arch=arch, release=release) - self.call_script(archive, tftproot, arch=arch, release=release) + self.call_script(archive, self.tftproot, arch=arch, release=release) self.assertThat( os.path.join( - tftproot, 'maas', arch, 'generic', 'pxelinux.cfg', 'default'), + self.tftproot, 'maas', arch, 'generic', + 'pxelinux.cfg', 'default'), FileContains(matcher=Contains("MENU TITLE"))) === modified file 'src/provisioningserver/tests/test_plugin.py' --- src/provisioningserver/tests/test_plugin.py 2012-07-06 19:53:41 +0000 +++ src/provisioningserver/tests/test_plugin.py 2012-07-23 16:06:48 +0000 @@ -14,24 +14,19 @@ from base64 import b64encode from functools import partial -from getpass import getuser import httplib import os from StringIO import StringIO -from textwrap import dedent import xmlrpclib -import formencode from maastesting.factory import factory from maastesting.testcase import TestCase from provisioningserver.plugin import ( - Config, Options, ProvisioningRealm, ProvisioningServiceMaker, SingleUsernamePasswordChecker, ) -from provisioningserver.pxe.tftppath import locate_tftp_path from provisioningserver.testing.fakecobbler import make_fake_cobbler_session from provisioningserver.tftp import TFTPBackend from testtools.deferredruntest import ( @@ -59,82 +54,6 @@ import yaml -class TestConfig(TestCase): - """Tests for `provisioningserver.plugin.Config`.""" - - def test_defaults(self): - mandatory = { - 'password': 'killing_joke', - } - expected = { - 'broker': { - 'host': 'localhost', - 'port': 5673, - 'username': getuser(), - 'password': 'test', - 'vhost': '/', - }, - 'cobbler': { - 'url': 'http://localhost/cobbler_api', - 'username': getuser(), - 'password': 'test', - }, - 'logfile': 'pserv.log', - 'oops': { - 'directory': '', - 'reporter': '', - }, - 'tftp': { - 'generator': 'http://localhost:5243/api/1.0/pxeconfig', - 'port': 5244, - 'root': locate_tftp_path(), - }, - 'interface': '127.0.0.1', - 'port': 5241, - 'username': getuser(), - } - expected.update(mandatory) - observed = Config.to_python(mandatory) - self.assertEqual(expected, observed) - - def test_parse(self): - # Configuration can be parsed from a snippet of YAML. - observed = Config.parse( - b'logfile: "/some/where.log"\n' - b'password: "black_sabbath"\n' - ) - self.assertEqual("/some/where.log", observed["logfile"]) - - def test_load(self): - # Configuration can be loaded and parsed from a file. - config = dedent(""" - logfile: "/some/where.log" - password: "megadeth" - """) - filename = self.make_file(name="config.yaml", contents=config) - observed = Config.load(filename) - self.assertEqual("/some/where.log", observed["logfile"]) - - def test_load_example(self): - # The example configuration can be loaded and validated. - filename = os.path.join( - os.path.dirname(__file__), os.pardir, - os.pardir, os.pardir, "etc", "pserv.yaml") - Config.load(filename) - - def test_oops_directory_without_reporter(self): - # It is an error to omit the OOPS reporter if directory is specified. - config = ( - 'oops:\n' - ' directory: /tmp/oops\n' - ) - expected = MatchesException( - formencode.Invalid, "oops: You must give a value for reporter") - self.assertThat( - partial(Config.parse, config), - Raises(expected)) - - class TestOptions(TestCase): """Tests for `provisioningserver.plugin.Options`.""" === modified file 'src/provisioningserver/tests/test_utils.py' --- src/provisioningserver/tests/test_utils.py 2012-07-18 10:06:55 +0000 +++ src/provisioningserver/tests/test_utils.py 2012-07-23 16:06:48 +0000 @@ -16,10 +16,10 @@ ArgumentParser, Namespace, ) -from io import BytesIO import os import random from random import randint +import StringIO from subprocess import CalledProcessError import sys import types @@ -31,6 +31,7 @@ atomic_write, increment_age, incremental_write, + MainScript, Safe, ShellTemplate, ) @@ -132,17 +133,21 @@ class TestActionScript(TestCase): """Test `ActionScript`.""" + factory = ActionScript + def setUp(self): super(TestActionScript, self).setUp() # ActionScript.setup() is not safe to run in the test suite. self.patch(ActionScript, "setup", lambda self: None) - # ArgumentParser sometimes likes to print to stdout/err. - self.patch(sys, "stdout", BytesIO()) - self.patch(sys, "stderr", BytesIO()) + # ArgumentParser sometimes likes to print to stdout/err. Use + # StringIO.StringIO to be relaxed about str/unicode (argparse uses + # str). When moving to Python 3 this will need to be tightened up. + self.patch(sys, "stdout", StringIO.StringIO()) + self.patch(sys, "stderr", StringIO.StringIO()) def test_init(self): description = factory.getRandomString() - script = ActionScript(description) + script = self.factory(description) self.assertIsInstance(script.parser, ArgumentParser) self.assertEqual(description, script.parser.description) @@ -152,7 +157,7 @@ self.assertIsInstance(parser, ArgumentParser)) handler.run = lambda args: ( self.assertIsInstance(args, int)) - script = ActionScript("Description") + script = self.factory("Description") script.register("slay", handler) self.assertIn("slay", script.subparsers.choices) action_parser = script.subparsers.choices["slay"] @@ -163,7 +168,7 @@ # add_arguments() callable. handler = types.ModuleType(b"handler") handler.run = lambda args: None - script = ActionScript("Description") + script = self.factory("Description") error = self.assertRaises( AttributeError, script.register, "decapitate", handler) self.assertIn("'add_arguments'", "%s" % error) @@ -173,7 +178,7 @@ # callable. handler = types.ModuleType(b"handler") handler.add_arguments = lambda parser: None - script = ActionScript("Description") + script = self.factory("Description") error = self.assertRaises( AttributeError, script.register, "decapitate", handler) self.assertIn("'run'", "%s" % error) @@ -183,7 +188,7 @@ handler = types.ModuleType(b"handler") handler.add_arguments = lambda parser: None handler.run = handler_calls.append - script = ActionScript("Description") + script = self.factory("Description") script.register("amputate", handler) error = self.assertRaises(SystemExit, script, ["amputate"]) self.assertEqual(0, error.code) @@ -191,7 +196,7 @@ self.assertIsInstance(handler_calls[0], Namespace) def test_call_invalid_choice(self): - script = ActionScript("Description") + script = self.factory("Description") self.assertRaises(SystemExit, script, ["disembowel"]) self.assertIn(b"invalid choice", sys.stderr.getvalue()) @@ -200,7 +205,7 @@ handler = types.ModuleType(b"handler") handler.add_arguments = lambda parser: None handler.run = lambda args: 0 / 0 - script = ActionScript("Description") + script = self.factory("Description") script.register("eviscerate", handler) self.assertRaises(ZeroDivisionError, script, ["eviscerate"]) @@ -216,7 +221,7 @@ handler = types.ModuleType(b"handler") handler.add_arguments = lambda parser: None handler.run = lambda args: raise_exception() - script = ActionScript("Description") + script = self.factory("Description") script.register("sever", handler) error = self.assertRaises(SystemExit, script, ["sever"]) self.assertEqual(exception.returncode, error.code) @@ -231,7 +236,31 @@ handler = types.ModuleType(b"handler") handler.add_arguments = lambda parser: None handler.run = lambda args: raise_exception() - script = ActionScript("Description") + script = self.factory("Description") script.register("smash", handler) error = self.assertRaises(SystemExit, script, ["smash"]) self.assertEqual(1, error.code) + + +class TestMainScript(TestActionScript): + + factory = MainScript + + def test_default_arguments(self): + # MainScript accepts a --config-file parameter. The value of this is + # passed through into the args namespace object as config_file. + handler_calls = [] + handler = types.ModuleType(b"handler") + handler.add_arguments = lambda parser: None + handler.run = handler_calls.append + script = self.factory("Description") + script.register("dislocate", handler) + dummy_config_file = factory.make_name("config-file") + # --config-file is specified before the action. + args = ["--config-file", dummy_config_file, "dislocate"] + error = self.assertRaises(SystemExit, script, args) + self.assertEqual(0, error.code) + namespace = handler_calls[0] + self.assertEqual( + {"config_file": dummy_config_file, "handler": handler}, + vars(namespace)) === modified file 'src/provisioningserver/utils.py' --- src/provisioningserver/utils.py 2012-07-18 10:06:55 +0000 +++ src/provisioningserver/utils.py 2012-07-23 16:06:48 +0000 @@ -14,15 +14,19 @@ "ActionScript", "atomic_write", "deferred", + "incremental_write", + "MainScript", "ShellTemplate", - "incremental_write", "xmlrpc_export", ] from argparse import ArgumentParser from functools import wraps import os -from os import fdopen +from os import ( + fdopen, + environ, + ) from pipes import quote import signal from subprocess import CalledProcessError @@ -239,3 +243,21 @@ raise SystemExit(1) else: raise SystemExit(0) + + +class MainScript(ActionScript): + """An `ActionScript` that always accepts a `--config-file` option. + + The `--config-file` option defaults to the value of + `MAAS_PROVISIONING_SETTINGS` in the process's environment, otherwise + `/etc/maas/pserv.yaml`. + """ + + def __init__(self, description): + super(MainScript, self).__init__(description) + self.parser.add_argument( + "-c", "--config-file", metavar="FILENAME", + help="Configuration file to load [%(default)s].", + default=environ.get( + "MAAS_PROVISIONING_SETTINGS", + "/etc/maas/pserv.yaml")) === modified file 'templates/test_module.py' --- templates/test_module.py 2012-07-20 02:35:14 +0000 +++ templates/test_module.py 2012-07-23 16:06:48 +0000 @@ -13,7 +13,7 @@ __metaclass__ = type __all__ = [] -from maasserver.testing.testcase import TestCase +from maastesting.testcase import TestCase class TestSomething(TestCase):
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : [email protected] Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp

