Esempio n. 1
0
class TestBuild(unittest.TestCase):
    def setUp(self):
        self.dirname = path(pkg_resources.resource_filename(__name__, ""))
        self.build_dir = TempDir()
        os.environ["CHARM_HIDE_METRICS"] = 'true'
        os.environ["CHARM_LAYERS_DIR"] = self.dirname / "layers"
        os.environ["CHARM_INTERFACES_DIR"] = self.dirname / "interfaces"
        os.environ["CHARM_CACHE_DIR"] = self.build_dir / "_cache"
        os.environ.pop("JUJU_REPOSITORY", None)
        os.environ.pop("LAYER_PATH", None)
        os.environ.pop("INTERFACE_PATH", None)
        self.p_post = mock.patch('requests.post')
        self.p_post.start()

    def tearDown(self):
        self.build_dir.rmtree_p()
        self.p_post.stop()
        build.fetchers.LayerFetcher.restore_layer_indexes()

    def test_default_no_hide_metrics(self):
        # In the absence of environment variables or command-line options,
        # Builder.hide_metrics is false.
        os.environ.pop("CHARM_HIDE_METRICS", None)
        builder = build.Builder()
        self.assertFalse(builder.hide_metrics)

    def test_environment_hide_metrics(self):
        # Setting the environment variable CHARM_HIDE_METRICS to a non-empty
        # value causes Builder.hide_metrics to be true.
        os.environ["CHARM_HIDE_METRICS"] = 'true'
        builder = build.Builder()
        self.assertTrue(builder.hide_metrics)

    def test_invalid_layer(self):
        # Test that invalid metadata.yaml files get a BuildError exception.
        builder = build.Builder()
        builder.log_level = "DEBUG"
        builder.build_dir = self.build_dir
        builder.cache_dir = builder.build_dir / "_cache"
        builder.series = "trusty"
        builder.name = "invalid-charm"
        builder.charm = "layers/invalid-layer"
        builder.no_local_layers = False
        metadata = path("tests/layers/invalid-layer/metadata.yaml")
        try:
            with self.dirname:
                builder()
            self.fail('Expected Builder to throw an exception on invalid YAML')
        except BuildError as e:
            self.assertEqual(
                "Failed to process {0}. "
                "Ensure the YAML is valid".format(metadata.abspath()), str(e))

    @mock.patch("argparse.ArgumentParser.parse_args")
    @mock.patch("charmtools.build.builder.proof")
    @mock.patch("charmtools.build.builder.Builder")
    def test_failed_proof(self, mBuilder, mproof, mparse_args):
        # Test that charm-proof failures get a BuildError exception.
        mproof.proof.return_value = ([], 200)
        mBuilder().charm_file = False
        try:
            build.builder.main()
            self.fail('Expected Builder to throw an exception on proof error')
        except SystemExit as e:
            self.assertEqual(e.code, 200)

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    def test_tester_layer(self, pv):
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/tester"
        bu.hide_metrics = True
        bu.report = False
        bu.charm_file = True
        remove_layer_file = self.dirname / 'layers/tester/to_remove'
        remove_layer_file.touch()
        charm_file = self.dirname / 'foo.charm'
        self.addCleanup(remove_layer_file.remove_p)
        self.addCleanup(charm_file.remove_p)
        with self.dirname:
            with mock.patch.object(build.builder, 'log') as log:
                with mock.patch.object(build.builder, 'repofinder') as rf:
                    rf.get_recommended_repo.return_value = None
                    bu()
                    log.warn.assert_called_with(
                        'Please add a `repo` key to your layer.yaml, '
                        'with a url from which your layer can be cloned.')
                    log.warn.reset_mock()
                    rf.get_recommended_repo.return_value = 'myrepo'
                    bu()
                    log.warn.assert_called_with(
                        'Please add a `repo` key to your layer.yaml, '
                        'e.g. repo: myrepo')
        base = bu.target_dir
        self.assertTrue(base.exists())
        self.assertTrue(charm_file.exists())
        with zipfile.ZipFile(charm_file, 'r') as zip:
            assert 'metadata.yaml' in zip.namelist()

        # Confirm that copyright file of lower layers gets renamed
        # and copyright file of top layer doesn't get renamed
        tester_copyright = (base / "copyright").text()
        mysql_copyright_path = base / "copyright.layer-mysql"
        self.assertIn("Copyright of tester", tester_copyright)
        self.assertTrue(mysql_copyright_path.isfile())

        # Verify ignore rules applied
        self.assertFalse((base / ".bzr").exists())
        self.assertEqual((base / "ignore").text(), "mysql\n")
        self.assertEqual((base / "exclude").text(), "test-base\n")
        self.assertEqual((base / "override-ignore").text(), "tester\n")
        self.assertEqual((base / "override-exclude").text(), "tester\n")
        self.assertFalse((base / "tests/00-setup").exists())
        self.assertFalse((base / "tests/15-configs").exists())
        self.assertTrue((base / "tests/20-deploy").exists())
        actions = yaml.safe_load((base / "actions.yaml").text())
        resources = yaml.safe_load((base / "resources.yaml").text())
        self.assertNotIn("test-base", actions)
        self.assertIn("mysql", actions)
        self.assertIn("tester", actions)
        self.assertIn("test-base", resources)
        self.assertNotIn("mysql", resources)
        self.assertIn("tester", resources)

        # Metadata should have combined provides fields
        metadata = base / "metadata.yaml"
        self.assertTrue(metadata.exists())
        metadata_data = yaml.safe_load(metadata.open())
        self.assertIn("shared-db", metadata_data['provides'])
        self.assertIn("storage", metadata_data['provides'])
        # The maintainer, maintainers values should only be from the top layer.
        self.assertIn("maintainer", metadata_data)
        self.assertEqual(metadata_data['maintainer'],
                         b"T\xc3\xa9sty T\xc3\xa9st\xc3\xa9r "
                         b"<t\xc3\xa9st\xc3\[email protected]>".decode('utf8'))
        self.assertNotIn("maintainers", metadata_data)
        # The tags list must be de-duplicated.
        self.assertEqual(metadata_data['tags'], ["databases"])
        self.assertEqual(metadata_data['series'], ['xenial', 'trusty'])

        # Config should have keys but not the ones in deletes
        config = base / "config.yaml"
        self.assertTrue(config.exists())
        config_data = yaml.safe_load(config.open())['options']
        self.assertIn("bind-address", config_data)
        self.assertNotIn("vip", config_data)
        self.assertIn("key", config_data)
        self.assertEqual(config_data["key"]["default"], None)
        # Issue #99 where strings lose their quotes in a charm build.
        self.assertIn("numeric-string", config_data)
        default_value = config_data['numeric-string']['default']
        self.assertEqual(default_value, "0123456789", "value must be a string")
        # Issue 218, ensure proper order of layer application
        self.assertEqual(config_data['backup_retention_count']['default'], 7,
                         'Config from layers was merged in wrong order')

        cyaml = base / "layer.yaml"
        self.assertTrue(cyaml.exists())
        cyaml_data = yaml.safe_load(cyaml.open())
        self.assertEquals(cyaml_data['includes'], ['layers/test-base',
                                                   'layers/mysql'])
        self.assertEquals(cyaml_data['is'], 'foo')
        self.assertEquals(cyaml_data['options']['mysql']['qux'], 'one')

        self.assertTrue((base / "hooks/config-changed").exists())

        # Files from the top layer as overrides
        start = base / "hooks/start"
        self.assertTrue(start.exists())
        self.assertIn("Overridden", start.text())

        # Standard hooks generated from template
        stop = base / "hooks/stop"
        self.assertTrue(stop.exists())
        self.assertIn("Hook: ", stop.text())

        self.assertTrue((base / "README.md").exists())
        self.assertEqual("dynamic tactics", (base / "README.md").text())

        self.assertTrue((base / "old_tactic").exists())
        self.assertEqual("processed", (base / "old_tactic").text())

        sigs = base / ".build.manifest"
        self.assertTrue(sigs.exists())
        data = json.load(sigs.open())
        self.assertEquals(data['signatures']["README.md"], [
            u'foo',
            "static",
            u'cfac20374288c097975e9f25a0d7c81783acdbc81'
            '24302ff4a731a4aea10de99'])

        self.assertEquals(data["signatures"]['metadata.yaml'], [
            u'foo',
            "dynamic",
            u'12c1f6fc865da0660f6dc044cca03b0244e883d9a99fdbdfab6ef6fc2fed63b7'
            ])

        storage_attached = base / "hooks/data-storage-attached"
        storage_detaching = base / "hooks/data-storage-detaching"
        self.assertTrue(storage_attached.exists())
        self.assertTrue(storage_detaching.exists())
        self.assertIn("Hook: data", storage_attached.text())
        self.assertIn("Hook: data", storage_detaching.text())

        # confirm that files removed from a base layer get cleaned up
        self.assertTrue((base / 'to_remove').exists())
        remove_layer_file.remove()
        with self.dirname:
            bu()
        self.assertFalse((base / 'to_remove').exists())

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @responses.activate
    def test_remote_interface(self, pv):
        # XXX: this test does pull the git repo in the response
        responses.add(responses.GET,
                      "https://juju.github.io/layer-index/"
                      "interfaces/pgsql.json",
                      body='''{
                      "id": "pgsql",
                      "name": "pgsql4",
                      "repo":
                      "https://github.com/bcsaller/juju-relation-pgsql.git",
                      "summary": "Postgres interface"
                      }''',
                      content_type="application/json")
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/c-reactive"
        bu.hide_metrics = True
        bu.report = False
        with self.dirname:
            bu()
        base = bu.target_dir
        self.assertTrue(base.exists())

        # basics
        self.assertTrue((base / "a").exists())
        self.assertTrue((base / "README.md").exists())
        # show that we pulled the interface from github
        init = base / "hooks/relations/pgsql/__init__.py"
        self.assertTrue(init.exists())
        main = base / "hooks/reactive/main.py"
        self.assertTrue(main.exists())

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    @responses.activate
    def test_remote_layer(self, mcall, ph, pi, pv):
        # XXX: this test does pull the git repo in the response
        responses.add(responses.GET,
                      "https://juju.github.io/layer-index/"
                      "layers/basic.json",
                      body='''{
                      "id": "basic",
                      "name": "basic",
                      "repo":
                      "https://git.launchpad.net/~bcsaller/charms/+source/basic",
                      "summary": "Base layer for all charms"
                      }''',
                      content_type="application/json")
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/use-layers"
        bu.hide_metrics = True
        bu.report = False
        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]

        with self.dirname:
            bu()
        base = bu.target_dir
        self.assertTrue(base.exists())

        # basics
        self.assertTrue((base / "README.md").exists())

        # show that we pulled charmhelpers from the basic layer as well
        mcall.assert_called_with(("pip3", "install",
                                  "--user", "--ignore-installed",
                                  mock.ANY), env=mock.ANY)

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_pypi_installer(self, mcall, ph, pi, pv):
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()
        mcall.assert_called_with(("pip3", "install",
                                  "--user", "--ignore-installed",
                                  mock.ANY), env=mock.ANY)

    @mock.patch(
        "charmtools.build.tactics.VersionTactic._try_to_get_current_sha",
        return_value="fake sha")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_version_tactic_without_existing_version_file(self, mcall, ph, pi,
                                                          get_sha):
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # ensure no an existing version file
        version_file = bu.charm / 'version'
        version_file.remove_p()

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()

        self.assertEqual((bu.target_dir / 'version').text(), 'fake sha')

    @mock.patch("charmtools.build.tactics.VersionTactic.CMDS", (
        ('does_not_exist_cmd', ''),
    ))
    @mock.patch("charmtools.build.tactics.InstallerTactic.trigger",
                classmethod(lambda *a: False))
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    def test_version_tactic_missing_cmd(self, ph, pi):
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # ensure no an existing version file
        version_file = bu.charm / 'version'
        version_file.remove_p()

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()

        assert not (bu.target_dir / 'version').exists()

    @mock.patch("charmtools.build.tactics.VersionTactic.read",
                return_value="sha1")
    @mock.patch(
        "charmtools.build.tactics.VersionTactic._try_to_get_current_sha",
        return_value="sha2")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_version_tactic_with_existing_version_file(self, mcall, ph, pi,
                                                       get_sha, read):
        bu = build.Builder()
        bu.ignore_lock_file = True
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            with mock.patch.object(build.tactics, 'log') as log:
                bu()
                log.warn.assert_has_calls(
                    [mock.call('version sha1 is out of update, '
                               'new sha sha2 will be used!')],
                    any_order=True)

        self.assertEqual((bu.target_dir / 'version').text(), 'sha2')

    @mock.patch("charmtools.utils.sign")
    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("path.Path.rmtree_p")
    @mock.patch("tempfile.mkdtemp")
    @mock.patch("charmtools.utils.Process")
    def test_wheelhouse(self, Process, mkdtemp, rmtree_p, ph, pi, pv, sign):
        build.tactics.WheelhouseTactic.per_layer = False
        mkdtemp.return_value = '/tmp'
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "whlayer"
        bu.charm = "layers/whlayer"
        bu.hide_metrics = True
        bu.report = False
        bu.wheelhouse_overrides = self.dirname / 'wh-over.txt'

        def _store_wheelhouses(args):
            filename = args[-1].split()[-1]
            if filename.endswith('.txt'):
                Process._wheelhouses.append(path(filename).lines(retain=False))
            return mock.Mock(return_value=mock.Mock(exit_code=0))
        Process._wheelhouses = []
        Process.side_effect = _store_wheelhouses

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            with mock.patch("path.Path.mkdir_p"):
                with mock.patch("path.Path.files"):
                    bu()
                    self.assertEqual(len(Process._wheelhouses), 1)
                    # note that setuptools uses both hyphen and underscore, but
                    # that should be normalized so that they match
                    self.assertEqual(Process._wheelhouses[0], [
                        '# layers/whbase',
                        '# base-comment',
                        '# foo==1.0  # overridden by whlayer',
                        '# bar==1.0  # overridden by whlayer',
                        '# qux==1.0  # overridden by whlayer',
                        '# setuptools-scm<=1.17.0  # overridden by '
                        '--wheelhouse-overrides',
                        '',
                        '# whlayer',
                        '# git+https://github.com/me/baz#egg=baz  # comment',
                        'foo==2.0',
                        'git+https://github.com/me/bar#egg=bar',
                        '# qux==2.0  # overridden by --wheelhouse-overrides',
                        '',
                        '# --wheelhouse-overrides',
                        'git+https://github.com/me/qux#egg=qux',
                        'setuptools_scm>=3.0<=3.4.1',
                        '',
                    ])

        sign.return_value = 'signature'
        wh = build.tactics.WheelhouseTactic(path('wheelhouse.txt'),
                                            mock.Mock(directory=path('wh')),
                                            mock.Mock(url='charm'),
                                            mock.Mock())
        # package name gets normalized properly when checking _layer_refs
        wh._layer_refs['setuptools-scm'] = 'layer:foo'
        wh.tracked = {path('wh/setuptools_scm-1.17.0.tar.gz')}
        self.assertEqual(wh.sign(), {
            'wheelhouse.txt': ('charm',
                               'dynamic',
                               'signature'),
            'setuptools_scm-1.17.0.tar.gz': ('layer:foo',
                                             'dynamic',
                                             'signature'),
        })

    @mock.patch.object(build.tactics, 'path')
    def test_wheelhouse_missing_package_name(self, path):
        wh = build.tactics.WheelhouseTactic(mock.Mock(name='entity'),
                                            mock.Mock(name='target'),
                                            mock.Mock(name='layer', url='foo'),
                                            mock.Mock(name='next_config'))
        path().text.return_value = 'https://example.com/my-package'
        with self.assertRaises(BuildError):
            wh.read()
        path().text.return_value = 'https://example.com/my-package#egg=foo'
        wh.read()
        self.assertIn('foo', wh._layer_refs.keys())

    @mock.patch.object(build.tactics, 'log')
    @mock.patch.object(build.tactics.YAMLTactic, 'read',
                       lambda s: setattr(s, '_read', True))
    def test_layer_options(self, log):
        entity = mock.MagicMock(name='entity')
        target = mock.MagicMock(name='target')
        config = mock.MagicMock(name='config')

        base_layer = mock.MagicMock(name='base_layer')
        base_layer.directory.name = 'layer-base'
        base_layer.name = 'base'
        base = build.tactics.LayerYAML(entity, target, base_layer, config)
        base.data = {
            'defines': {
                'foo': {
                    'type': 'string',
                    'default': 'FOO',
                    'description': "Don't set me, bro",
                },
                'bar': {
                    'enum': ['yes', 'no'],
                    'description': 'Go to the bar?',
                },
            }
        }
        assert base.lint()
        self.assertEqual(base.data['options']['base']['foo'], 'FOO')

        top_layer = mock.MagicMock(name='top_layer')
        top_layer.directory.name = 'layer-top'
        top_layer.name = 'top'
        top = build.tactics.LayerYAML(entity, target, top_layer, config)
        top.data = {
            'options': {
            },
            'defines': {
                'qux': {
                    'type': 'boolean',
                    'default': False,
                    'description': "Don't set me, bro",
                },
            }
        }
        assert top.lint()
        top.data['options'].update({
            'base': {
                'bar': 'bah',
            }
        })
        assert not top.lint()
        top.combine(base)
        assert not top.lint()
        log.error.assert_called_with('Invalid value for option %s: %s',
                                     'base.bar',
                                     "'bah' is not one of ['yes', 'no']")
        log.error.reset_mock()
        top.data['options']['base']['bar'] = 'yes'
        assert top.lint()
        self.assertEqual(top.data['options'], {
            'base': {
                'foo': 'FOO',
                'bar': 'yes',
            },
            'top': {
                'qux': False,
            },
        })

    @mock.patch('charmtools.build.tactics.getargspec')
    @mock.patch('charmtools.utils.walk')
    def test_custom_tactics(self, mwalk, mgetargspec):
        def _layer(tactics):
            return mock.Mock(config=build.builder.BuildConfig({'tactics':
                                                               tactics}),
                             directory=path('.'),
                             url=tactics[0])

        builder = build.builder.Builder()
        builder.ignore_lock_file = True
        builder.build_dir = self.build_dir
        builder.cache_dir = builder.build_dir / "_cache"
        builder.charm = 'foo'
        layers = {'layers': [
            _layer(['first']),
            _layer(['second']),
            _layer(['third']),
        ]}

        builder.plan_layers(layers, {})
        calls = [call[1]['current_config'].tactics
                 for call in mwalk.call_args_list]
        self.assertEquals(calls, [
            ['first'],
            ['second', 'first'],
            ['third', 'second', 'first'],
        ])

        mgetargspec.return_value = mock.Mock(args=[1, 2, 3, 4])
        current_config = mock.Mock(tactics=[
            mock.Mock(name='1', **{'trigger.return_value': False}),
            mock.Mock(name='2', **{'trigger.return_value': False}),
            mock.Mock(name='3', **{'trigger.return_value': True}),
        ])
        build.tactics.Tactic.get(mock.Mock(),
                                 mock.Mock(),
                                 mock.Mock(),
                                 mock.Mock(),
                                 current_config,
                                 mock.Mock())
        self.assertEquals([t.trigger.called for t in current_config.tactics],
                          [True, True, True])
        self.assertEquals([t.called for t in current_config.tactics],
                          [False, False, True])
Esempio n. 2
0
class TestBuild(unittest.TestCase):
    def setUp(self):
        self.dirname = path(pkg_resources.resource_filename(__name__, ""))
        self.build_dir = TempDir()
        os.environ["CHARM_HIDE_METRICS"] = 'true'
        os.environ["CHARM_LAYERS_DIR"] = self.dirname / "layers"
        os.environ["CHARM_INTERFACES_DIR"] = self.dirname / "interfaces"
        os.environ["CHARM_CACHE_DIR"] = self.build_dir / "_cache"
        os.environ.pop("JUJU_REPOSITORY", None)
        os.environ.pop("LAYER_PATH", None)
        os.environ.pop("INTERFACE_PATH", None)
        ifd = build.fetchers.LayerFetcher.LAYER_INDEX
        self.p_post = mock.patch('requests.post')
        self.p_post.start()
        # preserve the layer index between tests
        self.p_layer_index = mock.patch(
            'charmtools.build.fetchers.'
            'LayerFetcher.LAYER_INDEX', ifd)
        self.p_layer_index.start()

    def tearDown(self):
        self.build_dir.rmtree_p()
        self.p_post.stop()
        self.p_layer_index.stop()

    def test_invalid_layer(self):
        # Test that invalid metadata.yaml files get a BuildError exception.
        builder = build.Builder()
        builder.log_level = "DEBUG"
        builder.build_dir = self.build_dir
        builder.cache_dir = builder.build_dir / "_cache"
        builder.series = "trusty"
        builder.name = "invalid-charm"
        builder.charm = "layers/invalid-layer"
        builder.no_local_layers = False
        metadata = path("tests/layers/invalid-layer/metadata.yaml")
        try:
            with self.dirname:
                builder()
            self.fail('Expected Builder to throw an exception on invalid YAML')
        except BuildError as e:
            self.assertEqual(
                "Failed to process {0}. "
                "Ensure the YAML is valid".format(metadata.abspath()), str(e))

    @mock.patch("argparse.ArgumentParser.parse_args")
    @mock.patch("charmtools.build.builder.proof")
    @mock.patch("charmtools.build.builder.Builder")
    def test_failed_proof(self, mBuilder, mproof, mparse_args):
        # Test that charm-proof failures get a BuildError exception.
        mproof.proof.return_value = ([], 200)
        try:
            build.builder.main()
            self.fail('Expected Builder to throw an exception on proof error')
        except SystemExit as e:
            self.assertEqual(e.code, 200)

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    def test_tester_layer(self, pv):
        bu = build.Builder()
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/tester"
        bu.hide_metrics = True
        bu.report = False
        remove_layer_file = self.dirname / 'layers/tester/to_remove'
        remove_layer_file.touch()
        self.addCleanup(remove_layer_file.remove_p)
        with self.dirname:
            with mock.patch.object(build.builder, 'log') as log:
                with mock.patch.object(build.builder, 'repofinder') as rf:
                    rf.get_recommended_repo.return_value = None
                    bu()
                    log.warn.assert_called_with(
                        'Please add a `repo` key to your layer.yaml, '
                        'with a url from which your layer can be cloned.')
                    log.warn.reset_mock()
                    rf.get_recommended_repo.return_value = 'myrepo'
                    bu()
                    log.warn.assert_called_with(
                        'Please add a `repo` key to your layer.yaml, '
                        'e.g. repo: myrepo')
        base = bu.target_dir
        self.assertTrue(base.exists())

        # Confirm that copyright file of lower layers gets renamed
        # and copyright file of top layer doesn't get renamed
        tester_copyright = (base / "copyright").text()
        mysql_copyright_path = base / "copyright.layer-mysql"
        self.assertIn("Copyright of tester", tester_copyright)
        self.assertTrue(mysql_copyright_path.isfile())

        # Verify ignore rules applied
        self.assertFalse((base / ".bzr").exists())
        self.assertEqual((base / "ignore").text(), "mysql\n")
        self.assertEqual((base / "exclude").text(), "test-base\n")
        self.assertEqual((base / "override-ignore").text(), "tester\n")
        self.assertEqual((base / "override-exclude").text(), "tester\n")
        self.assertFalse((base / "tests/00-setup").exists())
        self.assertFalse((base / "tests/15-configs").exists())
        self.assertTrue((base / "tests/20-deploy").exists())
        actions = yaml.load((base / "actions.yaml").text())
        resources = yaml.load((base / "resources.yaml").text())
        self.assertNotIn("test-base", actions)
        self.assertIn("mysql", actions)
        self.assertIn("tester", actions)
        self.assertIn("test-base", resources)
        self.assertNotIn("mysql", resources)
        self.assertIn("tester", resources)

        # Metadata should have combined provides fields
        metadata = base / "metadata.yaml"
        self.assertTrue(metadata.exists())
        metadata_data = yaml.load(metadata.open())
        self.assertIn("shared-db", metadata_data['provides'])
        self.assertIn("storage", metadata_data['provides'])
        # The maintainer, maintainers values should only be from the top layer.
        self.assertIn("maintainer", metadata_data)
        self.assertEqual(metadata_data['maintainer'], "Tester <*****@*****.**>")
        self.assertNotIn("maintainers", metadata_data)
        # The tags list must be de-duplicated.
        self.assertEqual(metadata_data['tags'], ["databases"])
        self.assertEqual(metadata_data['series'], ['xenial', 'trusty'])

        # Config should have keys but not the ones in deletes
        config = base / "config.yaml"
        self.assertTrue(config.exists())
        config_data = yaml.load(config.open())['options']
        self.assertIn("bind-address", config_data)
        self.assertNotIn("vip", config_data)
        self.assertIn("key", config_data)
        self.assertEqual(config_data["key"]["default"], None)
        # Issue #99 where strings lose their quotes in a charm build.
        self.assertIn("numeric-string", config_data)
        default_value = config_data['numeric-string']['default']
        self.assertEqual(default_value, "0123456789", "value must be a string")
        # Issue 218, ensure proper order of layer application
        self.assertEqual(config_data['backup_retention_count']['default'], 7,
                         'Config from layers was merged in wrong order')

        cyaml = base / "layer.yaml"
        self.assertTrue(cyaml.exists())
        cyaml_data = yaml.load(cyaml.open())
        self.assertEquals(cyaml_data['includes'],
                          ['layers/test-base', 'layers/mysql'])
        self.assertEquals(cyaml_data['is'], 'foo')
        self.assertEquals(cyaml_data['options']['mysql']['qux'], 'one')

        self.assertTrue((base / "hooks/config-changed").exists())

        # Files from the top layer as overrides
        start = base / "hooks/start"
        self.assertTrue(start.exists())
        self.assertIn("Overridden", start.text())

        # Standard hooks generated from template
        stop = base / "hooks/stop"
        self.assertTrue(stop.exists())
        self.assertIn("Hook: ", stop.text())

        self.assertTrue((base / "README.md").exists())
        self.assertEqual("dynamic tactics", (base / "README.md").text())

        self.assertTrue((base / "old_tactic").exists())
        self.assertEqual("processed", (base / "old_tactic").text())

        sigs = base / ".build.manifest"
        self.assertTrue(sigs.exists())
        data = json.load(sigs.open())
        self.assertEquals(data['signatures']["README.md"], [
            u'foo', "static", u'cfac20374288c097975e9f25a0d7c81783acdbc81'
            '24302ff4a731a4aea10de99'
        ])

        self.assertEquals(data["signatures"]['metadata.yaml'], [
            u'foo', "dynamic",
            u'03fc06a5e698e624231b826f4c47a60d3251cbc968fc1183ada444ca09b29ea6'
        ])

        storage_attached = base / "hooks/data-storage-attached"
        storage_detaching = base / "hooks/data-storage-detaching"
        self.assertTrue(storage_attached.exists())
        self.assertTrue(storage_detaching.exists())
        self.assertIn("Hook: data", storage_attached.text())
        self.assertIn("Hook: data", storage_detaching.text())

        # confirm that files removed from a base layer get cleaned up
        self.assertTrue((base / 'to_remove').exists())
        remove_layer_file.remove()
        with self.dirname:
            bu()
        self.assertFalse((base / 'to_remove').exists())

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @responses.activate
    def test_remote_interface(self, pv):
        # XXX: this test does pull the git repo in the response
        responses.add(responses.GET, "https://juju.github.io/layer-index/"
                      "interfaces/pgsql.json",
                      body='''{
                      "id": "pgsql",
                      "name": "pgsql4",
                      "repo":
                      "https://github.com/bcsaller/juju-relation-pgsql.git",
                      "summary": "Postgres interface"
                      }''',
                      content_type="application/json")
        bu = build.Builder()
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/c-reactive"
        bu.hide_metrics = True
        bu.report = False
        with self.dirname:
            bu()
        base = bu.target_dir
        self.assertTrue(base.exists())

        # basics
        self.assertTrue((base / "a").exists())
        self.assertTrue((base / "README.md").exists())
        # show that we pulled the interface from github
        init = base / "hooks/relations/pgsql/__init__.py"
        self.assertTrue(init.exists())
        main = base / "hooks/reactive/main.py"
        self.assertTrue(main.exists())

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    @responses.activate
    def test_remote_layer(self, mcall, ph, pi, pv):
        # XXX: this test does pull the git repo in the response
        responses.add(responses.GET, "https://juju.github.io/layer-index/"
                      "layers/basic.json",
                      body='''{
                      "id": "basic",
                      "name": "basic",
                      "repo":
                      "https://git.launchpad.net/~bcsaller/charms/+source/basic",
                      "summary": "Base layer for all charms"
                      }''',
                      content_type="application/json")
        bu = build.Builder()
        bu.log_level = "WARNING"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/use-layers"
        bu.hide_metrics = True
        bu.report = False
        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]

        with self.dirname:
            bu()
        base = bu.target_dir
        self.assertTrue(base.exists())

        # basics
        self.assertTrue((base / "README.md").exists())

        # show that we pulled charmhelpers from the basic layer as well
        mcall.assert_called_with(
            ("pip3", "install", "--user", "--ignore-installed", mock.ANY),
            env=mock.ANY)

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_pypi_installer(self, mcall, ph, pi, pv):
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()
        mcall.assert_called_with(
            ("pip3", "install", "--user", "--ignore-installed", mock.ANY),
            env=mock.ANY)

    @mock.patch(
        "charmtools.build.tactics.VersionTactic._try_to_get_current_sha",
        return_value="fake sha")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_version_tactic_without_existing_version_file(
            self, mcall, ph, pi, get_sha):
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # ensure no an existing version file
        version_file = bu.charm / 'version'
        version_file.remove_p()

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()

        self.assertEqual((bu.target_dir / 'version').text(), 'fake sha')

    @mock.patch("charmtools.build.tactics.VersionTactic.CMDS",
                (('does_not_exist_cmd', ''), ))
    @mock.patch("charmtools.build.tactics.InstallerTactic.trigger",
                classmethod(lambda *a: False))
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    def test_version_tactic_missing_cmd(self, ph, pi):
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # ensure no an existing version file
        version_file = bu.charm / 'version'
        version_file.remove_p()

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            bu()

        assert not (bu.target_dir / 'version').exists()

    @mock.patch("charmtools.build.tactics.VersionTactic.read",
                return_value="sha1")
    @mock.patch(
        "charmtools.build.tactics.VersionTactic._try_to_get_current_sha",
        return_value="sha2")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("charmtools.utils.Process")
    def test_version_tactic_with_existing_version_file(self, mcall, ph, pi,
                                                       get_sha, read):
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/chlayer"
        bu.hide_metrics = True
        bu.report = False

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            with mock.patch.object(build.tactics, 'log') as log:
                bu()
                log.warn.assert_has_calls([
                    mock.call('version sha1 is out of update, '
                              'new sha sha2 will be used!')
                ],
                                          any_order=True)

        self.assertEqual((bu.target_dir / 'version').text(), 'sha2')

    @mock.patch("charmtools.build.builder.Builder.plan_version")
    @mock.patch("charmtools.build.builder.Builder.plan_interfaces")
    @mock.patch("charmtools.build.builder.Builder.plan_hooks")
    @mock.patch("path.Path.rmtree_p")
    @mock.patch("tempfile.mkdtemp")
    @mock.patch("charmtools.utils.Process")
    def test_wheelhouse(self, Process, mkdtemp, rmtree_p, ph, pi, pv):
        mkdtemp.return_value = '/tmp'
        bu = build.Builder()
        bu.log_level = "WARN"
        bu.build_dir = self.build_dir
        bu.cache_dir = bu.build_dir / "_cache"
        bu.series = "trusty"
        bu.name = "foo"
        bu.charm = "layers/whlayer"
        bu.hide_metrics = True
        bu.report = False
        bu.wheelhouse_overrides = self.dirname / 'wh-over.txt'

        # remove the sign phase
        bu.PHASES = bu.PHASES[:-2]
        with self.dirname:
            with mock.patch("path.Path.mkdir_p"):
                with mock.patch("path.Path.files"):
                    bu()
                    Process.assert_any_call(
                        ('bash', '-c', '. /tmp/bin/activate ;'
                         ' pip3 download --no-binary :all: '
                         '-d /tmp -r ' +
                         self.dirname / 'layers/whlayer/wheelhouse.txt'))
                    Process.assert_any_call(
                        ('bash', '-c', '. /tmp/bin/activate ;'
                         ' pip3 download --no-binary :all: '
                         '-d /tmp -r ' + self.dirname / 'wh-over.txt'))

    @mock.patch.object(build.tactics, 'log')
    @mock.patch.object(build.tactics.YAMLTactic, 'read',
                       lambda s: setattr(s, '_read', True))
    def test_layer_options(self, log):
        entity = mock.MagicMock(name='entity')
        target = mock.MagicMock(name='target')
        config = mock.MagicMock(name='config')

        base_layer = mock.MagicMock(name='base_layer')
        base_layer.directory.name = 'layer-base'
        base_layer.name = 'base'
        base = build.tactics.LayerYAML(entity, target, base_layer, config)
        base.data = {
            'defines': {
                'foo': {
                    'type': 'string',
                    'default': 'FOO',
                    'description': "Don't set me, bro",
                },
                'bar': {
                    'enum': ['yes', 'no'],
                    'description': 'Go to the bar?',
                },
            }
        }
        assert base.lint()
        self.assertEqual(base.data['options']['base']['foo'], 'FOO')

        top_layer = mock.MagicMock(name='top_layer')
        top_layer.directory.name = 'layer-top'
        top_layer.name = 'top'
        top = build.tactics.LayerYAML(entity, target, top_layer, config)
        top.data = {
            'options': {},
            'defines': {
                'qux': {
                    'type': 'boolean',
                    'default': False,
                    'description': "Don't set me, bro",
                },
            }
        }
        assert top.lint()
        top.data['options'].update({'base': {
            'bar': 'bah',
        }})
        assert not top.lint()
        top.combine(base)
        assert not top.lint()
        log.error.assert_called_with('Invalid value for option %s: %s',
                                     'base.bar',
                                     "'bah' is not one of ['yes', 'no']")
        log.error.reset_mock()
        top.data['options']['base']['bar'] = 'yes'
        assert top.lint()
        self.assertEqual(top.data['options'], {
            'base': {
                'foo': 'FOO',
                'bar': 'yes',
            },
            'top': {
                'qux': False,
            },
        })

    @mock.patch('charmtools.build.tactics.getargspec')
    @mock.patch('charmtools.utils.walk')
    def test_custom_tactics(self, mwalk, mgetargspec):
        def _layer(tactics):
            return mock.Mock(config=build.builder.BuildConfig(
                {'tactics': tactics}),
                             directory=path('.'),
                             url=tactics[0])

        builder = build.builder.Builder()
        builder.build_dir = self.build_dir
        builder.cache_dir = builder.build_dir / "_cache"
        builder.charm = 'foo'
        layers = {
            'layers': [
                _layer(['first']),
                _layer(['second']),
                _layer(['third']),
            ]
        }

        builder.plan_layers(layers, {})
        calls = [
            call[1]['current_config'].tactics for call in mwalk.call_args_list
        ]
        self.assertEquals(calls, [
            ['first'],
            ['second', 'first'],
            ['third', 'second', 'first'],
        ])

        mgetargspec.return_value = mock.Mock(args=[1, 2, 3, 4])
        current_config = mock.Mock(tactics=[
            mock.Mock(name='1', **{'trigger.return_value': False}),
            mock.Mock(name='2', **{'trigger.return_value': False}),
            mock.Mock(name='3', **{'trigger.return_value': True}),
        ])
        build.tactics.Tactic.get(mock.Mock(), mock.Mock(), mock.Mock(),
                                 mock.Mock(), current_config, mock.Mock())
        self.assertEquals([t.trigger.called for t in current_config.tactics],
                          [True, True, True])
        self.assertEquals([t.called for t in current_config.tactics],
                          [False, False, True])