Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement hardlink() for both salt.modules.file and salt.states.file #55000

Merged
merged 13 commits into from
Dec 6, 2019
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions salt/modules/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -3353,11 +3353,29 @@ def link(src, path):
try:
os.link(src, path)
return True
except (OSError, IOError):
raise CommandExecutionError('Could not create \'{0}\''.format(path))
except (OSError, IOError) as E:
raise CommandExecutionError('Could not create \'{0}\': {1}'.format(path, E))
return False


def is_hardlink(path):
'''
Check if the path is a hard link by verifying that the number of links
is larger than 1

CLI Example:

.. code-block:: bash

salt '*' file.is_hardlink /path/to/link
'''

# Simply use lstat and count the st_nlink field to determine if this path
# is hardlinked to something.
res = lstat(os.path.expanduser(path))
return res and res['st_nlink'] > 1


def is_link(path):
'''
Check if the path is a symbolic link
Expand Down
257 changes: 257 additions & 0 deletions salt/states/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,68 @@ def _symlink_check(name, target, force, user, group, win_owner):
'should be. Did you mean to use force?'.format(name)), changes


def _hardlink_same(name, target):
'''
Check to see if the inodes match for the name and the target
'''
res = __salt__['file.stats'](name, None, follow_symlinks=False)
if 'inode' not in res:
return False
name_i = res['inode']

res = __salt__['file.stats'](target, None, follow_symlinks=False)
if 'inode' not in res:
return False
target_i = res['inode']

return name_i == target_i


def _hardlink_check(name, target, force):
'''
Check the hardlink function
'''
changes = {}
if not os.path.exists(target):
msg = 'Target {0} for hard link does not exist'.format(target)
return False, msg, changes

elif os.path.isdir(target):
msg = 'Unable to hard link from directory {0}'.format(target)
return False, msg, changes

if os.path.isdir(name):
msg = 'Unable to hard link to directory {0}'.format(name)
return False, msg, changes

elif not os.path.exists(name):
msg = 'Hard link {0} to {1} is set for creation'.format(name, target)
changes['new'] = name
return None, msg, changes

elif __salt__['file.is_hardlink'](name):
if _hardlink_same(name, target):
msg = 'The hard link {0} is presently targetting {1}'.format(name, target)
return True, msg, changes

msg = 'Link {0} target is set to be changed to {1}'.format(name, target)
changes['change'] = name
return None, msg, changes

if force:
msg = (
'The file or directory {0} is set for removal to '
'make way for a new hard link targeting {1}'.format(name, target)
)
return None, msg, changes

msg = (
'File or directory exists where the hard link {0} '
'should be. Did you mean to use force?'.format(name)
)
return False, msg, changes


def _test_owner(kwargs, user=None):
'''
Convert owner to user, since other config management tools use owner,
Expand Down Expand Up @@ -1332,6 +1394,201 @@ def _makedirs(name,
mode=dir_mode)


def hardlink(
name,
target,
force=False,
makedirs=False,
user=None,
group=None,
dir_mode=None,
**kwargs):
'''
Create a hard link
If the file already exists and is a hard link pointing to any location other
than the specified target, the hard link will be replaced. If the hard link
is a regular file or directory then the state will return False. If the
regular file is desired to be replaced with a hard link pass force: True

name
The location of the hard link to create
target
The location that the hard link points to
force
If the name of the hard link exists and force is set to False, the
state will fail. If force is set to True, the file or directory in the
way of the hard link file will be deleted to make room for the hard
link, unless backupname is set, when it will be renamed
makedirs
If the location of the hard link does not already have a parent directory
then the state will fail, setting makedirs to True will allow Salt to
create the parent directory
user
The user to own any directories made if makedirs is set to true. This
defaults to the user salt is running as on the minion
group
The group ownership set on any directories made if makedirs is set to
true. This defaults to the group salt is running as on the minion. On
Windows, this is ignored
dir_mode
If directories are to be created, passing this option specifies the
permissions for those directories.
'''
name = os.path.expanduser(name)

# Make sure that leading zeros stripped by YAML loader are added back
dir_mode = salt.utils.files.normalize_mode(dir_mode)

user = _test_owner(kwargs, user=user)
ret = {'name': name,
'changes': {},
'result': True,
'comment': ''}
if not name:
return _error(ret, 'Must provide name to file.hardlink')

if user is None:
user = __opts__['user']

if salt.utils.is_windows():
if group is not None:
log.warning(
'The group argument for {0} has been ignored as this '
'is a Windows system.'.format(name)
)
group = user

if group is None:
group = __salt__['file.gid_to_group'](
__salt__['user.info'](user).get('gid', 0)
)

preflight_errors = []
uid = __salt__['file.user_to_uid'](user)
gid = __salt__['file.group_to_gid'](group)

if uid == '':
preflight_errors.append('User {0} does not exist'.format(user))

if gid == '':
preflight_errors.append('Group {0} does not exist'.format(group))

if not os.path.isabs(name):
preflight_errors.append(
'Specified file {0} is not an absolute path'.format(name)
)

if not os.path.isabs(target):
preflight_errors.append(
'Specified target {0} is not an absolute path'.format(target)
)

if preflight_errors:
msg = '. '.join(preflight_errors)
if len(preflight_errors) > 1:
msg += '.'
return _error(ret, msg)

if __opts__['test']:
presult, pcomment, pchanges = _hardlink_check(name, target, force)
ret['result'] = presult
ret['comment'] = pcomment
ret['changes'] = pchanges
return ret

for direction, item in zip(['to', 'from'], [name, target]):
if os.path.isdir(item):
msg = 'Unable to hard link {0} directory {1}'.format(direction, item)
return _error(ret, msg)

if not os.path.exists(target):
msg = 'Target {0} for hard link does not exist'.format(target)
return _error(ret, msg)

# Check that the directory to write the hard link to exists
if not os.path.isdir(os.path.dirname(name)):
if makedirs:
__salt__['file.makedirs'](
name,
user=user,
group=group,
mode=dir_mode)

else:
return _error(
ret,
'Directory {0} for hard link is not present'.format(
os.path.dirname(name)
)
)

# If file is not a hard link and we're actually overwriting it, then verify
# that this was forced.
if os.path.isfile(name) and not __salt__['file.is_hardlink'](name):

# Remove whatever is in the way. This should then hit the else case
# of the file.is_hardlink check below
if force:
os.remove(name)
ret['changes']['forced'] = 'File for hard link was forcibly replaced'

# Otherwise throw an error
else:
return _error(ret,
('File exists where the hard link {0} should be'
.format(name)))

# If the file is a hard link, then we can simply rewrite its target since
# nothing is really being lost here.
if __salt__['file.is_hardlink'](name):

# If the inodes point to the same thing, then there's nothing to do
# except for let the user know that this has already happened.
if _hardlink_same(name, target):
ret['result'] = True
ret['comment'] = ('Target of hard link {0} is already pointing '
'to {1}'.format(name, target))
return ret

# First remove the old hard link since a reference to it already exists
os.remove(name)

# Now we can remake it
try:
__salt__['file.link'](target, name)

# Or not...
except CommandExecutionError as E:
ret['result'] = False
ret['comment'] = ('Unable to set target of hard link {0} -> '
'{1}: {2}'.format(name, target, E))
return ret

# Good to go
ret['result'] = True
ret['comment'] = 'Set target of hard link {0} -> {1}'.format(name, target)
ret['changes']['new'] = name

# The link is not present, so simply make it
elif not os.path.exists(name):
try:
__salt__['file.link'](target, name)

# Or not...
except CommandExecutionError as E:
ret['result'] = False
ret['comment'] = ('Unable to create new hard link {0} -> '
'{1}: {2}'.format(name, target, E))
return ret

# Made a new hard link, things are ok
ret['result'] = True
ret['comment'] = 'Created new hard link {0} -> {1}'.format(name, target)
ret['changes']['new'] = name

return ret


def symlink(
name,
target,
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/modules/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2074,6 +2074,30 @@ def test_symlink_already_in_desired_state(self):
result = filemod.symlink(self.tfile.name, self.directory + '/a_link')
self.assertTrue(result)

@skipIf(salt.utils.platform.is_windows(), 'os.link is not available on Windows')
def test_hardlink_sanity(self):
target = os.path.join(self.directory, 'a_hardlink')
self.addCleanup(os.remove, target)
result = filemod.link(self.tfile.name, target)
self.assertTrue(result)

@skipIf(salt.utils.platform.is_windows(), 'os.link is not available on Windows')
def test_hardlink_numlinks(self):
target = os.path.join(self.directory, 'a_hardlink')
self.addCleanup(os.remove, target)
result = filemod.link(self.tfile.name, target)
name_i = os.stat(self.tfile.name).st_nlink
self.assertTrue(name_i > 1)

@skipIf(salt.utils.platform.is_windows(), 'os.link is not available on Windows')
def test_hardlink_working(self):
target = os.path.join(self.directory, 'a_hardlink')
self.addCleanup(os.remove, target)
result = filemod.link(self.tfile.name, target)
name_i = os.stat(self.tfile.name).st_ino
target_i = os.stat(target).st_ino
self.assertTrue(name_i == target_i)

def test_source_list_for_list_returns_file_from_dict_via_http(self):
with patch('salt.modules.file.os.remove') as remove:
remove.return_value = None
Expand Down
Loading