-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathstow
311 lines (231 loc) · 9.82 KB
/
stow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
#!/usr/bin/python3
'''
author : Caian R. Ertl <hi@caian.org>
code : github.com/caian-org/ansible-stow
project : ansible-stow
description : An Ansible module that interacts with GNU Stow packages
license : CC0 (Public Domain)
The person who associated a work with this deed has dedicated the work to the
public domain by waiving all of his or her rights to the work worldwide under
copyright law, including all related and neighboring rights, to the extent
allowed by law.
You can copy, modify, distribute and perform the work, even for commercial
purposes, all without asking permission.
AFFIRMER OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
INCLUDING WITHOUT LIMITATION WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, NON INFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER
DEFECTS, ACCURACY, OR THE PRESENT OR ABSENCE OF ERRORS, WHETHER OR NOT
DISCOVERABLE, ALL TO THE GREATEST EXTENT PERMISSIBLE UNDER APPLICABLE LAW.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>
'''
import os
import re
from ansible.module_utils.basic import AnsibleModule
STOW_CONFLICTS_MESSAGE_REGEX_BY_VERSION = {
'2.3.1': r'^\* existing target is neither a link nor a directory: (?P<link_path>.+)$',
# pylint: disable-next=line-too-long
'2.4.0': r'^\* cannot stow (?P<package_path>.+) over existing target (?P<link_path>.+) since neither a link nor a directory and --adopt not specified$',
}
SUPPORTED_STOW_VERSIONS = list(STOW_CONFLICTS_MESSAGE_REGEX_BY_VERSION.keys())
def purge_conflicts(conflicted_files):
"""Delete a file or unlink a symlink conflicting with a package.
Args:
conflicted_files (list): Path of files or symlinks on the
filesystem that conflicts with package files.
Returns:
dict or null: If the file is purged successfully, a None object is
returned. If something goes wrong, a dictionary is returned
containing the error message.
"""
try:
for file in conflicted_files:
if os.path.islink(file):
os.unlink(file)
else:
os.remove(file)
except Exception as err: # pylint: disable=broad-except
return {'message': f'unable to purge file "{file}"; error: {str(err)}'}
return None
def is_stow_in_path(module):
"""Verify if stow is installed and accessible (on the $PATH).
Args:
module (AnsibleModule): The Ansible module object.
Returns:
bool: True if stow is installed and accessible. False otherwise.
"""
rc_, _, _ = module.run_command('which stow', check_rc=False)
return rc_ == 0
def get_stow_version(module):
"""Get the installed stow version.
Args:
module (AnsibleModule): The Ansible module object.
Returns:
str or null: The installed stow version in the format "X.Y.Z". If stow
is not installed or the version can't be determined, a None object
is returned.
"""
rc_, stdout, _ = module.run_command('stow --version')
if rc_ != 0:
return None
stow_version = re.compile(r'^stow \(GNU Stow\) version (?P<version>\d+\.\d+\.\d+)$').match(stdout.strip())
if not stow_version:
return None
return stow_version.group('version')
def stow_has_conflicts(stow_version, module, package, cmd):
"""Verify if a package has any conflicting files.
Args:
stow_version (str): The installed stow version.
module (AnsibleModule): The Ansible module object.
package (str): The name of the package to be un/re/stowed.
cmd (str): The complete stow command, with all flags and arguments, to
be executed in dry-run mode (no change is made on the filesystem).
Returns:
dict or null: If no conflict is found, a None object is returned.
If a conflict is found, a dictionary is returned.
Recoverable (i.e., conflicts on pre-existing files and symlinks on
the filesystem) conflicts returns a different dict from a
non-recoverable one:
{
'recoverable': True,
'message': '...',
'files': ['/home/user/.bashrc', '/home/user/.config/foo']
}
---
{
'recoverable': False,
'message': '...'
}
"""
params = module.params
# Dry-run to check if there's any conflict.
cmd = f'{cmd} --no'
rc_, _, stderr = module.run_command(cmd)
if rc_ == 0:
return None
# Return code 2 means that the package points to a path that has a
# directory on it. E.g.:
#
# Package "foo" is meant to be stowed at the home directory at
# ".config/foo/bar" (absolute path being "/home/user/.config/foo/bar").
#
# If "bar" already exists as a directory at "/home/user/.config/foo",
# stow can't continue.
#
# One way of dealing with this situation would be removing the directory.
# Another way would be by backuping it. Either way, it's risky to
# recursively delete an entire directory or even move it.
#
# This kind of scenario should be handled manually by the user, hence
# the function returns with a non-recoverable flag error.
if rc_ == 2:
return {'recoverable': False, 'message': ''}
conflict_re = re.compile(STOW_CONFLICTS_MESSAGE_REGEX_BY_VERSION[stow_version])
conflicts = []
for sel in stderr.split('\n'):
conflict_match = conflict_re.match(sel.strip())
if conflict_match:
conflicts.append(os.path.join(params['target'], conflict_match.group('link_path')))
conff = ', '.join(f'"{f}"' for f in conflicts)
msg = f'unable to stow package "{package}" to "{params["target"]}"; conflicted files: {conff}'
return {'recoverable': True, 'message': msg, 'files': conflicts}
def has_stow_changed_links(stow_output):
if stow_output.strip() == '':
return 0
n_linked = set()
link_re = re.compile(r'^LINK: (?P<link_path>.+) => (?P<package_path>.+)(?P<reverts> \(reverts previous action\))?$')
n_unlinked = set()
unlink_re = re.compile(r'^UNLINK: (?P<link_path>.+)$')
for sel in stow_output.split('\n'):
sel = sel.strip()
link_match = link_re.match(sel)
if link_match:
n_linked.add(link_match.group('link_path'))
continue
unlink_match = unlink_re.match(sel)
if unlink_match:
n_unlinked.add(unlink_match.group('link_path'))
return n_linked != n_unlinked
def stow(stow_version, module, package, state):
"""Perform stow on a package against the filesystem.
Args:
stow_version (str): The installed stow version.
module (AnsibleModule): The Ansible module object.
package (str): The name of the package to be un/re/stowed.
state (str): The desirable package state within the system.
Returns:
dict: A dictionary that contains an error flag, the returned message
and wether something changed or not.
"""
params = module.params
flag = ''
if state in ('present', 'supress'):
flag = '--stow'
elif state == 'absent':
flag = '--delete'
elif state == 'latest':
flag = '--restow'
cmd = f'stow {flag} {package} --target={params["target"]} --dir={params["dir"]} --verbose'
conflict = stow_has_conflicts(stow_version, module, package, cmd)
if conflict:
if state != 'supress' or not conflict['recoverable']:
return {'error': True, 'message': conflict['message']}
err = purge_conflicts(conflict['files'])
if err:
return {'error': True, 'message': err['message']}
# When increasing verbosity level with the "--verbose" flag, all output
# will be sent to the standard error (stderr).
#
# Stow is, by itself, an idempotent tool.
# If a given package is already stowed, the tool will not perform again.
# If a package is succesfully stowed, stow will output what have been done.
#
# That's why "information on stderr" equals "something has changed"
# (supposing execution passed all errors checking).
rc_, _, se_ = module.run_command(cmd)
if rc_ != 0:
msg = f'execution of command "{cmd}" failed with error code {rc_}; output: "{se_}"'
return {'error': True, 'message': msg}
return {'error': False, 'changed': has_stow_changed_links(se_)}
def main():
'''The module main routine.'''
module = AnsibleModule(
argument_spec={
'dir': {
'required': True,
'type': 'str',
},
'package': {
'required': True,
'type': 'list',
},
'target': {
'required': False,
'type': 'str',
'default': os.environ.get('HOME'),
},
'state': {
'required': True,
'type': 'str',
'choices': ['absent', 'present', 'latest', 'supress'],
},
}
)
params = module.params
if not is_stow_in_path(module):
module.fail_json(msg='unable to find stow')
stow_version = get_stow_version(module)
if not stow_version:
module.fail_json(msg='unable to determine stow version')
if stow_version not in SUPPORTED_STOW_VERSIONS:
module.fail_json(msg=f'stow version {stow_version} is not supported')
has_changed = False
for package in list(params['package']):
ret = stow(stow_version, module, package, params['state'])
if ret['error']:
module.fail_json(msg=ret['message'])
has_changed = has_changed or ret['changed']
module.exit_json(changed=has_changed)
if __name__ == '__main__':
main()