diff --git a/Gemfile b/Gemfile index b014c8fa1be7..9333438694a9 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gem 'test-kitchen', '~>1.23.3' -gem 'kitchen-salt', '~>0.4.1' +gem 'kitchen-salt', :git => 'https://github.com/s0undt3ch/kitchen-salt.git', :branch => 'features/nox' gem 'kitchen-sync' gem 'git' @@ -13,7 +13,8 @@ end group :windows do gem 'winrm', '~>2.0' - gem 'winrm-fs', '~>1.3.1' +# gem 'winrm-fs', '~>1.3.1' + gem 'winrm-fs', :git => 'https://github.com/s0undt3ch/winrm-fs.git', :branch => 'hotfix/saltstack-ci' end group :ec2 do diff --git a/noxfile.py b/noxfile.py index 227bc3001959..2ec8f40f838a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -23,6 +23,10 @@ REPO_ROOT = os.path.abspath(os.path.dirname(__file__)) SITECUSTOMIZE_DIR = os.path.join(REPO_ROOT, 'tests', 'support', 'coverage') +# We can't just import salt because if this is running under a frozen nox, there +# will be no salt to import +IS_WINDOWS = sys.platform.lower().startswith('win') + # Python versions to run against _PYTHON_VERSIONS = ('2', '2.7', '3', '3.4', '3.5', '3.6') @@ -41,7 +45,9 @@ def _create_ci_directories(): def _install_requirements(session, *extra_requirements): # Install requirements - _requirements_files = [] + _requirements_files = [ + os.path.join(REPO_ROOT, 'requirements', 'pytest.txt') + ] if sys.platform.startswith('linux'): requirements_files = [ os.path.join(REPO_ROOT, 'requirements', 'tests.txt') @@ -49,7 +55,6 @@ def _install_requirements(session, *extra_requirements): elif sys.platform.startswith('win'): requirements_files = [ os.path.join(REPO_ROOT, 'pkg', 'windows', 'req.txt'), - os.path.join(REPO_ROOT, 'pkg', 'windows', 'req_testing.txt'), ] elif sys.platform.startswith('darwin'): requirements_files = [ @@ -61,6 +66,10 @@ def _install_requirements(session, *extra_requirements): if not requirements_files: break requirements_file = requirements_files.pop(0) + + if requirements_file not in _requirements_files: + _requirements_files.append(requirements_file) + session.log('Processing {}'.format(requirements_file)) with open(requirements_file) as rfh: # pylint: disable=resource-leakage for line in rfh: @@ -80,8 +89,14 @@ def _install_requirements(session, *extra_requirements): if extra_requirements: session.install(*extra_requirements) + if IS_WINDOWS: + # Windows hacks :/ + nox_windows_setup = os.path.join(REPO_ROOT, 'tests', 'support', 'nox-windows-setup.py') + session.run('python', nox_windows_setup) + def _run_with_coverage(session, *test_cmd): + session.install('coverage') session.run('coverage', 'erase') python_path_env_var = os.environ.get('PYTHONPATH') or None if python_path_env_var is None: diff --git a/salt/client/mixins.py b/salt/client/mixins.py index 480145eb8d22..b282951b8c6e 100644 --- a/salt/client/mixins.py +++ b/salt/client/mixins.py @@ -30,6 +30,9 @@ import salt.utils.versions import salt.transport.client import salt.log.setup +import salt.output +import salt.utils.text + from salt.ext import six # Import 3rd-party libs @@ -376,7 +379,10 @@ def low(self, fun, low, print_event=True, full_return=False): try: data['return'] = func(*args, **kwargs) except TypeError as exc: - data['return'] = '\nPassed invalid arguments: {0}\n\nUsage:\n{1}'.format(exc, func.__doc__) + data['return'] = salt.utils.text.cli_info('Error: {exc}\nUsage:\n{doc}'.format( + exc=exc, doc=func.__doc__), 'Passed invalid arguments') + except Exception as exc: + data['return'] = salt.utils.text.cli_info(six.text_type(exc), 'General error occurred') try: data['success'] = self.context.get('retcode', 0) == 0 except AttributeError: @@ -389,11 +395,8 @@ def low(self, fun, low, print_event=True, full_return=False): if isinstance(ex, salt.exceptions.NotImplemented): data['return'] = six.text_type(ex) else: - data['return'] = 'Exception occurred in {0} {1}: {2}'.format( - self.client, - fun, - traceback.format_exc(), - ) + data['return'] = 'Exception occurred in {client} {fun}: {tb}'.format( + client=self.client, fun=fun, tb=traceback.format_exc()) data['success'] = False if self.store_job: diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index 86a39404d9f6..75bfd94d5122 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -3069,6 +3069,7 @@ def run_chroot(root, group=None, shell=DEFAULT_SHELL, python_shell=True, + binds=None, env=None, clean_env=False, template=None, @@ -3096,19 +3097,17 @@ def run_chroot(root, :param str root: Path to the root of the jail to use. - stdin - A string of standard input can be specified for the command to be run using - the ``stdin`` parameter. This can be useful in cases where sensitive - information must be read from standard input.: + :param str stdin: A string of standard input can be specified for + the command to be run using the ``stdin`` parameter. This can + be useful in cases where sensitive information must be read + from standard input.: - runas - User to run script as. + :param str runas: User to run script as. - group - Group to run script as. + :param str group: Group to run script as. - shell - Shell to execute under. Defaults to the system default shell. + :param str shell: Shell to execute under. Defaults to the system + default shell. :param str cmd: The command to run. ex: ``ls -lart /home`` @@ -3132,6 +3131,9 @@ def run_chroot(root, arguments. Set to True to use shell features, such as pipes or redirection. + :param list binds: List of directories that will be exported inside + the chroot with the bind option. + :param dict env: Environment variables to be set prior to execution. .. note:: @@ -3150,11 +3152,11 @@ def run_chroot(root, engine will be used to render the downloaded file. Currently jinja, mako, and wempy are supported. - :param bool rstrip: - Strip all whitespace off the end of output before it is returned. + :param bool rstrip: Strip all whitespace off the end of output + before it is returned. - :param str umask: - The umask (in octal) to use when running the command. + :param str umask: The umask (in octal) to use when running the + command. :param str output_encoding: Control the encoding used to decode the command's output. @@ -3242,6 +3244,15 @@ def run_chroot(root, 'sysfs', fstype='sysfs') + binds = binds if binds else [] + for bind_exported in binds: + bind_exported_to = os.path.relpath(bind_exported, os.path.sep) + bind_exported_to = os.path.join(root, bind_exported_to) + __salt__['mount.mount']( + bind_exported_to, + bind_exported, + opts='default,bind') + # Execute chroot routine sh_ = '/bin/sh' if os.path.isfile(os.path.join(root, 'bin/bash')): @@ -3294,6 +3305,11 @@ def run_chroot(root, log.error('Processes running in chroot could not be killed, ' 'filesystem will remain mounted') + for bind_exported in binds: + bind_exported_to = os.path.relpath(bind_exported, os.path.sep) + bind_exported_to = os.path.join(root, bind_exported_to) + __salt__['mount.umount'](bind_exported_to) + __salt__['mount.umount'](os.path.join(root, 'sys')) __salt__['mount.umount'](os.path.join(root, 'proc')) __salt__['mount.umount'](os.path.join(root, 'dev')) diff --git a/salt/modules/win_file.py b/salt/modules/win_file.py index 601fa19fafe7..4501fefff6ab 100644 --- a/salt/modules/win_file.py +++ b/salt/modules/win_file.py @@ -1072,7 +1072,7 @@ def remove(path, force=False): raise SaltInvocationError('File path must be absolute: {0}'.format(path)) # Does the file/folder exists - if not os.path.exists(path): + if not os.path.exists(path) and not is_link(path): raise CommandExecutionError('Path not found: {0}'.format(path)) # Remove ReadOnly Attribute diff --git a/salt/runner.py b/salt/runner.py index 3e3657e8eaa6..ab645c9c0c21 100644 --- a/salt/runner.py +++ b/salt/runner.py @@ -16,6 +16,7 @@ import salt.utils.event import salt.utils.files import salt.utils.user +import salt.defaults.exitcodes from salt.client import mixins from salt.output import display_output from salt.utils.lazy import verify_fun @@ -180,117 +181,129 @@ def run(self): ''' Execute the runner sequence ''' - import salt.minion - ret = {} + # Print documentation only if self.opts.get('doc', False): self.print_docs() else: - low = {'fun': self.opts['fun']} - try: - # Allocate a jid - async_pub = self._gen_async_pub() - self.jid = async_pub['jid'] + return self._run_runner() - fun_args = salt.utils.args.parse_input( - self.opts['arg'], - no_parse=self.opts.get('no_parse', [])) + def _run_runner(self): + ''' + Actually execute specific runner + :return: + ''' + import salt.minion + ret = {} + low = {'fun': self.opts['fun']} + try: + # Allocate a jid + async_pub = self._gen_async_pub() + self.jid = async_pub['jid'] + + fun_args = salt.utils.args.parse_input( + self.opts['arg'], + no_parse=self.opts.get('no_parse', [])) + + verify_fun(self.functions, low['fun']) + args, kwargs = salt.minion.load_args_and_kwargs( + self.functions[low['fun']], + fun_args) + low['arg'] = args + low['kwarg'] = kwargs + + if self.opts.get('eauth'): + if 'token' in self.opts: + try: + with salt.utils.files.fopen(os.path.join(self.opts['cachedir'], '.root_key'), 'r') as fp_: + low['key'] = salt.utils.stringutils.to_unicode(fp_.readline()) + except IOError: + low['token'] = self.opts['token'] + + # If using eauth and a token hasn't already been loaded into + # low, prompt the user to enter auth credentials + if 'token' not in low and 'key' not in low and self.opts['eauth']: + # This is expensive. Don't do it unless we need to. + import salt.auth + resolver = salt.auth.Resolver(self.opts) + res = resolver.cli(self.opts['eauth']) + if self.opts['mktoken'] and res: + tok = resolver.token_cli( + self.opts['eauth'], + res + ) + if tok: + low['token'] = tok.get('token', '') + if not res: + log.error('Authentication failed') + return ret + low.update(res) + low['eauth'] = self.opts['eauth'] + else: + user = salt.utils.user.get_specific_user() - verify_fun(self.functions, low['fun']) - args, kwargs = salt.minion.load_args_and_kwargs( - self.functions[low['fun']], - fun_args) - low['arg'] = args - low['kwarg'] = kwargs + if low['fun'] in ['state.orchestrate', 'state.orch', 'state.sls']: + low['kwarg']['orchestration_jid'] = async_pub['jid'] + # Run the runner! + if self.opts.get('async', False): if self.opts.get('eauth'): - if 'token' in self.opts: - try: - with salt.utils.files.fopen(os.path.join(self.opts['cachedir'], '.root_key'), 'r') as fp_: - low['key'] = salt.utils.stringutils.to_unicode(fp_.readline()) - except IOError: - low['token'] = self.opts['token'] - - # If using eauth and a token hasn't already been loaded into - # low, prompt the user to enter auth credentials - if 'token' not in low and 'key' not in low and self.opts['eauth']: - # This is expensive. Don't do it unless we need to. - import salt.auth - resolver = salt.auth.Resolver(self.opts) - res = resolver.cli(self.opts['eauth']) - if self.opts['mktoken'] and res: - tok = resolver.token_cli( - self.opts['eauth'], - res - ) - if tok: - low['token'] = tok.get('token', '') - if not res: - log.error('Authentication failed') - return ret - low.update(res) - low['eauth'] = self.opts['eauth'] - else: - user = salt.utils.user.get_specific_user() - - if low['fun'] in ['state.orchestrate', 'state.orch', 'state.sls']: - low['kwarg']['orchestration_jid'] = async_pub['jid'] - - # Run the runner! - if self.opts.get('async', False): - if self.opts.get('eauth'): - async_pub = self.cmd_async(low) - else: - async_pub = self.asynchronous(self.opts['fun'], - low, - user=user, - pub=async_pub) - # by default: info will be not enougth to be printed out ! - log.warning( - 'Running in asynchronous mode. Results of this execution may ' - 'be collected by attaching to the master event bus or ' - 'by examing the master job cache, if configured. ' - 'This execution is running under tag %s', async_pub['tag'] - ) - return async_pub['jid'] # return the jid - - # otherwise run it in the main process - if self.opts.get('eauth'): - ret = self.cmd_sync(low) - if isinstance(ret, dict) and set(ret) == {'data', 'outputter'}: - outputter = ret['outputter'] - ret = ret['data'] - else: - outputter = None - display_output(ret, outputter, self.opts) + async_pub = self.cmd_async(low) else: - ret = self._proc_function(self.opts['fun'], - low, - user, - async_pub['tag'], - async_pub['jid'], - daemonize=False) - except salt.exceptions.SaltException as exc: - evt = salt.utils.event.get_event('master', opts=self.opts) - evt.fire_event({'success': False, - 'return': '{0}'.format(exc), - 'retcode': 254, - 'fun': self.opts['fun'], - 'fun_args': fun_args, - 'jid': self.jid}, - tag='salt/run/{0}/ret'.format(self.jid)) - # Attempt to grab documentation - if 'fun' in low: - ret = self.get_docs('{0}*'.format(low['fun'])) + async_pub = self.asynchronous(self.opts['fun'], + low, + user=user, + pub=async_pub) + + # by default: info will be not enough to be printed out ! + log.warning( + 'Running in asynchronous mode. Results of this execution may ' + 'be collected by attaching to the master event bus or ' + 'by examing the master job cache, if configured. ' + 'This execution is running under tag %s', async_pub['tag'] + ) + return async_pub['jid'] # return the jid + + # otherwise run it in the main process + if self.opts.get('eauth'): + ret = self.cmd_sync(low) + if isinstance(ret, dict) and set(ret) == {'data', 'outputter'}: + outputter = ret['outputter'] + ret = ret['data'] else: - ret = None - - # If we didn't get docs returned then - # return the `not availble` message. - if not ret: - ret = '{0}'.format(exc) - if not self.opts.get('quiet', False): - display_output(ret, 'nested', self.opts) + outputter = None + display_output(ret, outputter, self.opts) + else: + ret = self._proc_function(self.opts['fun'], + low, + user, + async_pub['tag'], + async_pub['jid'], + daemonize=False) + except salt.exceptions.SaltException as exc: + evt = salt.utils.event.get_event('master', opts=self.opts) + evt.fire_event({'success': False, + 'return': '{0}'.format(exc), + 'retcode': 254, + 'fun': self.opts['fun'], + 'fun_args': fun_args, + 'jid': self.jid}, + tag='salt/run/{0}/ret'.format(self.jid)) + # Attempt to grab documentation + if 'fun' in low: + ret = self.get_docs('{0}*'.format(low['fun'])) else: - log.debug('Runner return: %s', ret) + ret = None + + # If we didn't get docs returned then + # return the `not availble` message. + if not ret: + ret = '{0}'.format(exc) + if not self.opts.get('quiet', False): + display_output(ret, 'nested', self.opts) + else: + ret = { + 'retcode': salt.defaults.exitcodes.EX_SOFTWARE, + } + log.debug('Runner return: %s', ret) - return ret + return ret diff --git a/salt/states/archive.py b/salt/states/archive.py index 8df492bb6446..360c75525894 100644 --- a/salt/states/archive.py +++ b/salt/states/archive.py @@ -412,6 +412,10 @@ def extracted(name, Set this to ``True`` if archive should be extracted if source_hash has changed. This would extract regardless of the ``if_missing`` parameter. + Note that this is only checked if the ``source`` value has not changed. + If it has (e.g. to increment a version number in the path) then the + archive will not be extracted even if the hash has changed. + .. versionadded:: 2016.3.0 skip_verify : False diff --git a/salt/states/boto_cloudfront.py b/salt/states/boto_cloudfront.py index aae4dc2fe8d7..fced4b607a00 100644 --- a/salt/states/boto_cloudfront.py +++ b/salt/states/boto_cloudfront.py @@ -53,7 +53,6 @@ import json # Import Salt conveniences -import salt.utils.boto3mod from salt.ext import six from salt.ext.six.moves import range @@ -521,7 +520,7 @@ def distribution_present(name, region=None, key=None, keyid=None, profile=None, copyOne = copy.deepcopy(currentDC) copyTwo = copy.deepcopy(currentDC) copyTwo.update(kwargs['DistributionConfig']) - correct = salt.utils.boto3.json_objs_equal(copyOne, copyTwo) + correct = __utils__['boto3.json_objs_equal'](copyOne, copyTwo) tags_correct = (currentTags == Tags) comments = [] old = {} @@ -674,7 +673,7 @@ def oai_bucket_policy_present(name, Bucket, OAI, Policy, # Warning: unavoidable hardcoded magic values HO! fake_Policy['Statement'][stanza].update({'Principal': {'AWS': 'arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity {}'.format(oai_id)}}) - if salt.utils.boto3.json_objs_equal(curr_policy, fake_Policy): + if __utils__['boto3.json_objs_equal'](curr_policy, fake_Policy): msg = 'Policy of S3 bucket `{}` is in the correct state.'.format(Bucket) log.info(msg) ret['comment'] = msg diff --git a/salt/utils/text.py b/salt/utils/text.py new file mode 100644 index 000000000000..66a80f3d5103 --- /dev/null +++ b/salt/utils/text.py @@ -0,0 +1,23 @@ +# coding=utf-8 +''' +All text work utilities (formatting messages, layout etc). +''' +from __future__ import absolute_import, unicode_literals, print_function +import textwrap + + +def cli_info(data, title='Info'): + ''' + Prints an info on CLI with the title. + Useful for infos, general errors etc. + + :param data: + :param title: + :return: + ''' + + wrapper = textwrap.TextWrapper() + wrapper.initial_indent = ' ' * 4 + wrapper.subsequent_indent = wrapper.initial_indent + + return '{title}:\n\n{text}'.format(title=title, text=wrapper.fill(data)) diff --git a/salt/utils/win_reg.py b/salt/utils/win_reg.py index cac46a05e509..57e6fba26d59 100644 --- a/salt/utils/win_reg.py +++ b/salt/utils/win_reg.py @@ -423,7 +423,7 @@ def list_values(hive, key=None, use_32bit_registry=False, include_default=True): if vtype == win32con.REG_MULTI_SZ: value['vdata'] = [_to_mbcs(i) for i in vdata] elif vtype in [win32con.REG_SZ, win32con.REG_EXPAND_SZ]: - value['vdata'] = _to_mbcs(vdata) + value['vdata'] = _to_mbcs(vdata).rstrip('\0') else: value['vdata'] = vdata values.append(value) @@ -528,7 +528,7 @@ def read_value(hive, key, vname=None, use_32bit_registry=False): if vtype == win32con.REG_MULTI_SZ: ret['vdata'] = [_to_mbcs(i) for i in vdata] elif vtype in [win32con.REG_SZ, win32con.REG_EXPAND_SZ]: - ret['vdata'] = _to_mbcs(vdata) + ret['vdata'] = _to_mbcs(vdata).rstrip('\0') else: ret['vdata'] = vdata else: diff --git a/tests/support/nox-windows-setup.py b/tests/support/nox-windows-setup.py new file mode 100644 index 000000000000..9409884ef3ea --- /dev/null +++ b/tests/support/nox-windows-setup.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +''' + tests.support.nox-windows-setup + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This script is meant to run under the nox virtualenv to take care of required + windows procedures +''' +# pylint: disable=resource-leakage + +from __future__ import absolute_import, print_function, unicode_literals +import os +import re +import sys +import site +import shutil + +try: + import site + SITE_PACKAGES = site.getsitepackages() + PYTHON_EXECUTABLE_DIRECTORY = os.path.dirname(sys.executable) + PYTHON_SCRIPTS_DIR = os.path.join(PYTHON_EXECUTABLE_DIRECTORY, 'Scripts') +except AttributeError: + # The site module does not have the getsitepackages function when running within a virtualenv + # But the site-packages directory WILL be on sys.path + SITE_PACKAGES = None + for entry in sys.path: + if 'site-packages' in entry: + SITE_PACKAGES = entry + break + # Under a virtualenv, the python "binary" is under Scripts already. + # Well, not the binary, but the Python DLLs + PYTHON_EXECUTABLE_DIRECTORY = PYTHON_SCRIPTS_DIR = os.path.dirname(sys.executable) + +# Requests is a Salt dependency, it's safe to import, but... +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +IS_64_BITS = sys.maxsize > 2**32 +SALT_REPO_URL = 'https://repo.saltstack.com/windows/dependencies/{}'.format(IS_64_BITS and 64 or 32) +DLLS = ("libeay32.dll", "ssleay32.dll", "OpenSSL_License.txt", "msvcr120.dll", "libsodium.dll") + +for dll in DLLS: + outfile = os.path.join(PYTHON_EXECUTABLE_DIRECTORY, dll) + if os.path.exists(outfile): + continue + src_url = '{}/{}'.format(SALT_REPO_URL, dll) + if HAS_REQUESTS: + print('Downloading {} to {}'.format(src_url, outfile)) + request = requests.get(src_url, allow_redirects=True) + with open(outfile, 'wb') as wfh: + wfh.write(request.content) + else: + print('ATTENTION: The python requests package is not installed, can\'t download {}'.format(src_url)) + +PYWIN32_SYSTEM32_DIR = os.path.join(SITE_PACKAGES, 'pywin32_system32') +if os.path.exists(PYWIN32_SYSTEM32_DIR): + for fname in os.listdir(PYWIN32_SYSTEM32_DIR): + if not fname.endswith('.dll'): + continue + spath = os.path.join(PYWIN32_SYSTEM32_DIR, fname) + dpath = spath.replace('pywin32_system32', 'win32') + print('Moving {} to {}'.format(spath, dpath)) + shutil.move(spath, dpath) + + print('Deleting {}'.format(PYWIN32_SYSTEM32_DIR)) + shutil.rmtree(PYWIN32_SYSTEM32_DIR, ignore_errors=True) + + +if os.path.exists(PYTHON_SCRIPTS_DIR): + print('Searching for pywin32 scripts to delete') + for fname in os.listdir(PYTHON_SCRIPTS_DIR): + if not fname.startswith('pywin32_'): + continue + fpath = os.path.join(PYTHON_SCRIPTS_DIR, fname) + print('Deleting {}'.format(fpath)) + os.unlink(fpath) + + +PYTHONWIN_DIR = os.path.join(SITE_PACKAGES, 'pythonwin') +if os.path.exists(PYTHONWIN_DIR): + print('Deleting {}'.format(PYTHONWIN_DIR)) + shutil.rmtree(PYTHONWIN_DIR, ignore_errors=True) + +PYCRPTO_NT_FILE = os.path.join(SITE_PACKAGES, 'Crypto', 'Random', 'OSRNG', 'nt.py') +if os.path.exists(PYCRPTO_NT_FILE): + with open(PYCRPTO_NT_FILE, 'r') as rfh: + contents = rfh.read() + new_contents = re.sub( + r'^import winrandom$', + 'from Crypto.Random.OSRNG import winrandom', + contents, + count=1, + flags=re.MULTILINE + ) + if contents != new_contents: + print('Patching {}'.format(PYCRPTO_NT_FILE)) + with open(PYCRPTO_NT_FILE, 'w') as wfh: + wfh.write(new_contents) diff --git a/tests/unit/modules/test_win_file.py b/tests/unit/modules/test_win_file.py index 6413dd37ae4e..31815cd4f832 100644 --- a/tests/unit/modules/test_win_file.py +++ b/tests/unit/modules/test_win_file.py @@ -12,6 +12,7 @@ # Import Salt Libs import salt.modules.win_file as win_file +import salt.modules.temp as temp from salt.exceptions import CommandExecutionError import salt.utils.platform import salt.utils.win_dacl @@ -46,3 +47,20 @@ def test_issue_43328_check_perms_no_ret(self): with patch('os.path.exists', return_value=False): self.assertRaises( CommandExecutionError, win_file.check_perms, self.FAKE_PATH) + + @skipIf(not salt.utils.platform.is_windows(), 'Skip on Non-Windows systems') + def test_issue_52002_check_file_remove_symlink(self): + ''' + Make sure that directories including symlinks or symlinks can be removed + ''' + base = temp.dir(prefix='base') + target = os.path.join(base, 'child 1', 'target/') + symlink = os.path.join(base, 'child 2', 'link') + self.assertFalse(win_file.directory_exists(target)) + self.assertFalse(win_file.directory_exists(symlink)) + self.assertTrue(win_file.makedirs_(target)) + self.assertTrue(win_file.directory_exists(symlink)) + self.assertTrue(win_file.symlink(target, symlink)) + self.assertTrue(win_file.is_link(symlink)) + self.assertTrue(win_file.remove(base)) + self.assertFalse(win_file.directory_exists(base))