pyinfra 0.11.dev3__py3-none-any.whl → 3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +19 -3
  4. pyinfra/api/arguments.py +413 -0
  5. pyinfra/api/arguments_typed.py +79 -0
  6. pyinfra/api/command.py +274 -0
  7. pyinfra/api/config.py +222 -28
  8. pyinfra/api/connect.py +33 -13
  9. pyinfra/api/connectors.py +27 -0
  10. pyinfra/api/deploy.py +65 -66
  11. pyinfra/api/exceptions.py +73 -18
  12. pyinfra/api/facts.py +267 -200
  13. pyinfra/api/host.py +416 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/metadata.py +69 -0
  16. pyinfra/api/operation.py +432 -262
  17. pyinfra/api/operations.py +273 -260
  18. pyinfra/api/state.py +302 -248
  19. pyinfra/api/util.py +309 -369
  20. pyinfra/connectors/base.py +173 -0
  21. pyinfra/connectors/chroot.py +212 -0
  22. pyinfra/connectors/docker.py +405 -0
  23. pyinfra/connectors/dockerssh.py +297 -0
  24. pyinfra/connectors/local.py +238 -0
  25. pyinfra/connectors/scp/__init__.py +1 -0
  26. pyinfra/connectors/scp/client.py +204 -0
  27. pyinfra/connectors/ssh.py +727 -0
  28. pyinfra/connectors/ssh_util.py +114 -0
  29. pyinfra/connectors/sshuserclient/client.py +309 -0
  30. pyinfra/connectors/sshuserclient/config.py +102 -0
  31. pyinfra/connectors/terraform.py +135 -0
  32. pyinfra/connectors/util.py +417 -0
  33. pyinfra/connectors/vagrant.py +183 -0
  34. pyinfra/context.py +145 -0
  35. pyinfra/facts/__init__.py +7 -6
  36. pyinfra/facts/apk.py +22 -7
  37. pyinfra/facts/apt.py +117 -60
  38. pyinfra/facts/brew.py +100 -15
  39. pyinfra/facts/bsdinit.py +23 -0
  40. pyinfra/facts/cargo.py +37 -0
  41. pyinfra/facts/choco.py +47 -0
  42. pyinfra/facts/crontab.py +195 -0
  43. pyinfra/facts/deb.py +94 -0
  44. pyinfra/facts/dnf.py +48 -0
  45. pyinfra/facts/docker.py +96 -23
  46. pyinfra/facts/efibootmgr.py +113 -0
  47. pyinfra/facts/files.py +629 -58
  48. pyinfra/facts/flatpak.py +77 -0
  49. pyinfra/facts/freebsd.py +70 -0
  50. pyinfra/facts/gem.py +19 -6
  51. pyinfra/facts/git.py +59 -14
  52. pyinfra/facts/gpg.py +150 -0
  53. pyinfra/facts/hardware.py +313 -167
  54. pyinfra/facts/iptables.py +72 -62
  55. pyinfra/facts/launchd.py +44 -0
  56. pyinfra/facts/lxd.py +17 -4
  57. pyinfra/facts/mysql.py +122 -86
  58. pyinfra/facts/npm.py +17 -9
  59. pyinfra/facts/openrc.py +71 -0
  60. pyinfra/facts/opkg.py +246 -0
  61. pyinfra/facts/pacman.py +50 -7
  62. pyinfra/facts/pip.py +24 -7
  63. pyinfra/facts/pipx.py +82 -0
  64. pyinfra/facts/pkg.py +15 -6
  65. pyinfra/facts/pkgin.py +35 -0
  66. pyinfra/facts/podman.py +54 -0
  67. pyinfra/facts/postgres.py +178 -0
  68. pyinfra/facts/postgresql.py +6 -147
  69. pyinfra/facts/rpm.py +105 -0
  70. pyinfra/facts/runit.py +77 -0
  71. pyinfra/facts/selinux.py +161 -0
  72. pyinfra/facts/server.py +762 -285
  73. pyinfra/facts/snap.py +88 -0
  74. pyinfra/facts/systemd.py +139 -0
  75. pyinfra/facts/sysvinit.py +59 -0
  76. pyinfra/facts/upstart.py +35 -0
  77. pyinfra/facts/util/__init__.py +17 -0
  78. pyinfra/facts/util/databases.py +4 -6
  79. pyinfra/facts/util/packaging.py +37 -6
  80. pyinfra/facts/util/units.py +30 -0
  81. pyinfra/facts/util/win_files.py +99 -0
  82. pyinfra/facts/vzctl.py +20 -13
  83. pyinfra/facts/xbps.py +35 -0
  84. pyinfra/facts/yum.py +34 -40
  85. pyinfra/facts/zfs.py +77 -0
  86. pyinfra/facts/zypper.py +42 -0
  87. pyinfra/local.py +45 -83
  88. pyinfra/operations/__init__.py +12 -0
  89. pyinfra/operations/apk.py +99 -0
  90. pyinfra/operations/apt.py +496 -0
  91. pyinfra/operations/brew.py +232 -0
  92. pyinfra/operations/bsdinit.py +59 -0
  93. pyinfra/operations/cargo.py +45 -0
  94. pyinfra/operations/choco.py +61 -0
  95. pyinfra/operations/crontab.py +194 -0
  96. pyinfra/operations/dnf.py +213 -0
  97. pyinfra/operations/docker.py +492 -0
  98. pyinfra/operations/files.py +2014 -0
  99. pyinfra/operations/flatpak.py +95 -0
  100. pyinfra/operations/freebsd/__init__.py +12 -0
  101. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  102. pyinfra/operations/freebsd/pkg.py +219 -0
  103. pyinfra/operations/freebsd/service.py +116 -0
  104. pyinfra/operations/freebsd/sysrc.py +92 -0
  105. pyinfra/operations/gem.py +48 -0
  106. pyinfra/operations/git.py +420 -0
  107. pyinfra/operations/iptables.py +312 -0
  108. pyinfra/operations/launchd.py +45 -0
  109. pyinfra/operations/lxd.py +69 -0
  110. pyinfra/operations/mysql.py +610 -0
  111. pyinfra/operations/npm.py +57 -0
  112. pyinfra/operations/openrc.py +63 -0
  113. pyinfra/operations/opkg.py +89 -0
  114. pyinfra/operations/pacman.py +82 -0
  115. pyinfra/operations/pip.py +206 -0
  116. pyinfra/operations/pipx.py +103 -0
  117. pyinfra/operations/pkg.py +71 -0
  118. pyinfra/operations/pkgin.py +92 -0
  119. pyinfra/operations/postgres.py +437 -0
  120. pyinfra/operations/postgresql.py +30 -0
  121. pyinfra/operations/puppet.py +41 -0
  122. pyinfra/operations/python.py +73 -0
  123. pyinfra/operations/runit.py +184 -0
  124. pyinfra/operations/selinux.py +190 -0
  125. pyinfra/operations/server.py +1100 -0
  126. pyinfra/operations/snap.py +118 -0
  127. pyinfra/operations/ssh.py +217 -0
  128. pyinfra/operations/systemd.py +150 -0
  129. pyinfra/operations/sysvinit.py +142 -0
  130. pyinfra/operations/upstart.py +68 -0
  131. pyinfra/operations/util/__init__.py +12 -0
  132. pyinfra/operations/util/docker.py +407 -0
  133. pyinfra/operations/util/files.py +247 -0
  134. pyinfra/operations/util/packaging.py +338 -0
  135. pyinfra/operations/util/service.py +46 -0
  136. pyinfra/operations/vzctl.py +137 -0
  137. pyinfra/operations/xbps.py +78 -0
  138. pyinfra/operations/yum.py +213 -0
  139. pyinfra/operations/zfs.py +176 -0
  140. pyinfra/operations/zypper.py +193 -0
  141. pyinfra/progress.py +44 -32
  142. pyinfra/py.typed +0 -0
  143. pyinfra/version.py +9 -1
  144. pyinfra-3.6.dist-info/METADATA +142 -0
  145. pyinfra-3.6.dist-info/RECORD +160 -0
  146. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -2
  147. pyinfra-3.6.dist-info/entry_points.txt +12 -0
  148. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info/licenses}/LICENSE.md +1 -1
  149. pyinfra_cli/__init__.py +1 -0
  150. pyinfra_cli/cli.py +793 -0
  151. pyinfra_cli/commands.py +66 -0
  152. pyinfra_cli/exceptions.py +155 -65
  153. pyinfra_cli/inventory.py +233 -89
  154. pyinfra_cli/log.py +39 -43
  155. pyinfra_cli/main.py +26 -495
  156. pyinfra_cli/prints.py +215 -156
  157. pyinfra_cli/util.py +172 -105
  158. pyinfra_cli/virtualenv.py +25 -20
  159. pyinfra/api/connectors/__init__.py +0 -21
  160. pyinfra/api/connectors/ansible.py +0 -99
  161. pyinfra/api/connectors/docker.py +0 -178
  162. pyinfra/api/connectors/local.py +0 -169
  163. pyinfra/api/connectors/ssh.py +0 -402
  164. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  165. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  166. pyinfra/api/connectors/util.py +0 -63
  167. pyinfra/api/connectors/vagrant.py +0 -155
  168. pyinfra/facts/init.py +0 -176
  169. pyinfra/facts/util/files.py +0 -102
  170. pyinfra/hook.py +0 -41
  171. pyinfra/modules/__init__.py +0 -11
  172. pyinfra/modules/apk.py +0 -64
  173. pyinfra/modules/apt.py +0 -272
  174. pyinfra/modules/brew.py +0 -122
  175. pyinfra/modules/files.py +0 -711
  176. pyinfra/modules/gem.py +0 -30
  177. pyinfra/modules/git.py +0 -115
  178. pyinfra/modules/init.py +0 -344
  179. pyinfra/modules/iptables.py +0 -271
  180. pyinfra/modules/lxd.py +0 -45
  181. pyinfra/modules/mysql.py +0 -347
  182. pyinfra/modules/npm.py +0 -47
  183. pyinfra/modules/pacman.py +0 -60
  184. pyinfra/modules/pip.py +0 -99
  185. pyinfra/modules/pkg.py +0 -43
  186. pyinfra/modules/postgresql.py +0 -245
  187. pyinfra/modules/puppet.py +0 -20
  188. pyinfra/modules/python.py +0 -37
  189. pyinfra/modules/server.py +0 -524
  190. pyinfra/modules/ssh.py +0 -150
  191. pyinfra/modules/util/files.py +0 -52
  192. pyinfra/modules/util/packaging.py +0 -118
  193. pyinfra/modules/vzctl.py +0 -133
  194. pyinfra/modules/yum.py +0 -171
  195. pyinfra/pseudo_modules.py +0 -64
  196. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  197. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  198. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  199. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  200. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  201. pyinfra_cli/__main__.py +0 -40
  202. pyinfra_cli/config.py +0 -92
  203. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  204. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
@@ -0,0 +1,2014 @@
1
+ """
2
+ The files operations handles filesystem state, file uploads and template generation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import posixpath
9
+ import sys
10
+ import traceback
11
+ from datetime import datetime, timedelta, timezone
12
+ from fnmatch import fnmatch
13
+ from io import StringIO
14
+ from pathlib import Path
15
+ from typing import IO, Any, Union
16
+
17
+ import click
18
+ from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError
19
+
20
+ from pyinfra import host, logger, state
21
+ from pyinfra.api import (
22
+ FileDownloadCommand,
23
+ FileUploadCommand,
24
+ OperationError,
25
+ OperationTypeError,
26
+ OperationValueError,
27
+ QuoteString,
28
+ RsyncCommand,
29
+ StringCommand,
30
+ operation,
31
+ )
32
+ from pyinfra.api.command import make_formatted_string_command
33
+ from pyinfra.api.util import (
34
+ get_call_location,
35
+ get_file_io,
36
+ get_file_md5,
37
+ get_file_sha1,
38
+ get_file_sha256,
39
+ get_path_permissions_mode,
40
+ get_template,
41
+ memoize,
42
+ )
43
+ from pyinfra.facts.files import (
44
+ MARKER_BEGIN_DEFAULT,
45
+ MARKER_DEFAULT,
46
+ MARKER_END_DEFAULT,
47
+ Block,
48
+ Directory,
49
+ File,
50
+ FileContents,
51
+ FindFiles,
52
+ FindInFile,
53
+ Flags,
54
+ Link,
55
+ Md5File,
56
+ Sha1File,
57
+ Sha256File,
58
+ Sha384File,
59
+ )
60
+ from pyinfra.facts.server import Date, Which
61
+
62
+ from .util import files as file_utils
63
+ from .util.files import (
64
+ MetadataTimeField,
65
+ adjust_regex,
66
+ ensure_mode_int,
67
+ generate_color_diff,
68
+ get_timestamp,
69
+ sed_delete,
70
+ sed_replace,
71
+ unix_path_join,
72
+ )
73
+
74
+
75
+ @operation()
76
+ def download(
77
+ src: str,
78
+ dest: str,
79
+ user: str | None = None,
80
+ group: str | None = None,
81
+ mode: str | None = None,
82
+ cache_time: int | None = None,
83
+ force=False,
84
+ sha384sum: str | None = None,
85
+ sha256sum: str | None = None,
86
+ sha1sum: str | None = None,
87
+ md5sum: str | None = None,
88
+ headers: dict[str, str] | None = None,
89
+ insecure=False,
90
+ proxy: str | None = None,
91
+ temp_dir: str | Path | None = None,
92
+ extra_curl_args: dict[str, str] | None = None,
93
+ extra_wget_args: dict[str, str] | None = None,
94
+ ):
95
+ """
96
+ Download files from remote locations using ``curl`` or ``wget``.
97
+
98
+ + src: source URL of the file
99
+ + dest: where to save the file
100
+ + user: user to own the files
101
+ + group: group to own the files
102
+ + mode: permissions of the files
103
+ + cache_time: if the file exists already, re-download after this time (in seconds)
104
+ + force: always download the file, even if it already exists
105
+ + sha384sum: sha384 hash to checksum the downloaded file against
106
+ + sha256sum: sha256 hash to checksum the downloaded file against
107
+ + sha1sum: sha1 hash to checksum the downloaded file against
108
+ + md5sum: md5 hash to checksum the downloaded file against
109
+ + headers: optional dictionary of headers to set for the HTTP request
110
+ + insecure: disable SSL verification for the HTTP request
111
+ + proxy: simple HTTP proxy through which we can download files, form `http://<yourproxy>:<port>`
112
+ + temp_dir: use this custom temporary directory during the download
113
+ + extra_curl_args: optional dictionary with custom arguments for curl
114
+ + extra_wget_args: optional dictionary with custom arguments for wget
115
+
116
+ **Example:**
117
+
118
+ .. code:: python
119
+
120
+ from pyinfra.operations import files
121
+ files.download(
122
+ name="Download the Docker repo file",
123
+ src="https://download.docker.com/linux/centos/docker-ce.repo",
124
+ dest="/etc/yum.repos.d/docker-ce.repo",
125
+ )
126
+ """
127
+
128
+ info = host.get_fact(File, path=dest)
129
+
130
+ # Destination is a directory?
131
+ if info is False:
132
+ raise OperationError(
133
+ "Destination {0} already exists and is not a file".format(dest),
134
+ )
135
+
136
+ # Do we download the file? Force by default
137
+ download = force
138
+
139
+ # Doesn't exist, lets download it
140
+ if info is None:
141
+ download = True
142
+
143
+ # Destination file exists & cache_time: check when the file was last modified,
144
+ # download if old
145
+ else:
146
+ if cache_time:
147
+ # Time on files is not tz-aware, and will be the same tz as the server's time,
148
+ # so we can safely remove the tzinfo from the Date fact before comparison.
149
+ ctime = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
150
+ if info["mtime"] and info["mtime"] < ctime:
151
+ download = True
152
+
153
+ if sha1sum:
154
+ if sha1sum != host.get_fact(Sha1File, path=dest):
155
+ download = True
156
+
157
+ if sha256sum:
158
+ if sha256sum != host.get_fact(Sha256File, path=dest):
159
+ download = True
160
+
161
+ if sha384sum:
162
+ if sha384sum != host.get_fact(Sha384File, path=dest):
163
+ download = True
164
+
165
+ if md5sum:
166
+ if md5sum != host.get_fact(Md5File, path=dest):
167
+ download = True
168
+
169
+ # If we download, always do user/group/mode as SSH user may be different
170
+ if download:
171
+ # Use explicit temp_dir if provided, otherwise fall back to _temp_dir global argument
172
+ effective_temp_dir = temp_dir
173
+ if effective_temp_dir is None and host.current_op_global_arguments:
174
+ effective_temp_dir = host.current_op_global_arguments.get("_temp_dir")
175
+ temp_file = host.get_temp_filename(
176
+ dest, temp_directory=str(effective_temp_dir) if effective_temp_dir is not None else None
177
+ )
178
+
179
+ curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
180
+ wget_args: list[Union[str, StringCommand]] = ["-q"]
181
+
182
+ if extra_curl_args:
183
+ for key, value in extra_curl_args.items():
184
+ curl_args.append(StringCommand(key, QuoteString(value)))
185
+
186
+ if extra_wget_args:
187
+ for key, value in extra_wget_args.items():
188
+ wget_args.append(StringCommand(key, QuoteString(value)))
189
+
190
+ if proxy:
191
+ curl_args.append(f"--proxy {proxy}")
192
+ wget_args.append("-e use_proxy=yes")
193
+ wget_args.append(f"-e http_proxy={proxy}")
194
+
195
+ if insecure:
196
+ curl_args.append("--insecure")
197
+ wget_args.append("--no-check-certificate")
198
+
199
+ if headers:
200
+ for key, value in headers.items():
201
+ header_arg = StringCommand("--header", QuoteString(f"{key}: {value}"))
202
+ curl_args.append(header_arg)
203
+ wget_args.append(header_arg)
204
+
205
+ curl_command = make_formatted_string_command(
206
+ "curl {0} {1} -o {2}",
207
+ StringCommand(*curl_args),
208
+ QuoteString(src),
209
+ QuoteString(temp_file),
210
+ )
211
+ wget_command = make_formatted_string_command(
212
+ "wget {0} {1} -O {2} || ( rm -f {2} ; exit 1 )",
213
+ StringCommand(*wget_args),
214
+ QuoteString(src),
215
+ QuoteString(temp_file),
216
+ )
217
+
218
+ if host.get_fact(Which, command="curl"):
219
+ yield curl_command
220
+ elif host.get_fact(Which, command="wget"):
221
+ yield wget_command
222
+ else:
223
+ yield "( {0} ) || ( {1} )".format(curl_command, wget_command)
224
+
225
+ yield StringCommand("mv", QuoteString(temp_file), QuoteString(dest))
226
+
227
+ if user or group:
228
+ yield file_utils.chown(dest, user, group)
229
+
230
+ if mode:
231
+ yield file_utils.chmod(dest, mode)
232
+
233
+ if sha1sum:
234
+ yield make_formatted_string_command(
235
+ (
236
+ "(( sha1sum {0} 2> /dev/null || shasum {0} || sha1 {0} ) | grep {1} ) "
237
+ "|| ( echo {2} && exit 1 )"
238
+ ),
239
+ QuoteString(dest),
240
+ sha1sum,
241
+ QuoteString("SHA1 did not match!"),
242
+ )
243
+
244
+ if sha256sum:
245
+ yield make_formatted_string_command(
246
+ (
247
+ "(( sha256sum {0} 2> /dev/null || shasum -a 256 {0} || sha256 {0} ) "
248
+ "| grep {1}) || ( echo {2} && exit 1 )"
249
+ ),
250
+ QuoteString(dest),
251
+ sha256sum,
252
+ QuoteString("SHA256 did not match!"),
253
+ )
254
+
255
+ if sha384sum:
256
+ yield make_formatted_string_command(
257
+ (
258
+ "(( sha384sum {0} 2> /dev/null || shasum -a 384 {0} ) "
259
+ "| grep {1}) || ( echo {2} && exit 1 )"
260
+ ),
261
+ QuoteString(dest),
262
+ sha384sum,
263
+ QuoteString("SHA384 did not match!"),
264
+ )
265
+
266
+ if md5sum:
267
+ yield make_formatted_string_command(
268
+ ("(( md5sum {0} 2> /dev/null || md5 {0} ) | grep {1}) || ( echo {2} && exit 1 )"),
269
+ QuoteString(dest),
270
+ md5sum,
271
+ QuoteString("MD5 did not match!"),
272
+ )
273
+ else:
274
+ host.noop("file {0} has already been downloaded".format(dest))
275
+
276
+
277
+ @operation()
278
+ def line(
279
+ path: str,
280
+ line: str,
281
+ present=True,
282
+ replace: str | None = None,
283
+ flags: list[str] | None = None,
284
+ backup=False,
285
+ interpolate_variables=False,
286
+ escape_regex_characters=False,
287
+ ensure_newline=False,
288
+ ):
289
+ """
290
+ Ensure lines in files using grep to locate and sed to replace.
291
+
292
+ + path: target remote file to edit
293
+ + line: string or regex matching the target line
294
+ + present: whether the line should be in the file
295
+ + replace: text to replace entire matching lines when ``present=True``
296
+ + flags: list of flags to pass to sed when replacing/deleting
297
+ + backup: whether to backup the file (see below)
298
+ + interpolate_variables: whether to interpolate variables in ``replace``
299
+ + escape_regex_characters: whether to escape regex characters from the matching line
300
+ + ensure_newline: ensures that the appended line is on a new line
301
+
302
+ Regex line matching:
303
+ Unless line matches a line (starts with ^, ends $), pyinfra will wrap it such that
304
+ it does, like: ``^.*LINE.*$``. This means we don't swap parts of lines out. To
305
+ change bits of lines, see ``files.replace``.
306
+
307
+ Regex line escaping:
308
+ If matching special characters (eg a crontab line containing ``*``), remember to escape
309
+ it first using Python's ``re.escape``.
310
+
311
+ Backup:
312
+ If set to ``True``, any editing of the file will place an old copy with the ISO
313
+ date (taken from the machine running ``pyinfra``) appended as the extension. If
314
+ you pass a string value this will be used as the extension of the backed up file.
315
+
316
+ Append:
317
+ If ``line`` is not in the file but we want it (``present`` set to ``True``), then
318
+ it will be append to the end of the file.
319
+
320
+ Ensure new line:
321
+ This will ensure that the ``line`` being appended is always on a separate new
322
+ line in case the file doesn't end with a newline character.
323
+
324
+
325
+ **Examples:**
326
+
327
+ .. code:: python
328
+
329
+ # prepare to do some maintenance
330
+ maintenance_line = "SYSTEM IS DOWN FOR MAINTENANCE"
331
+ files.line(
332
+ name="Add the down-for-maintenance line in /etc/motd",
333
+ path="/etc/motd",
334
+ line=maintenance_line,
335
+ )
336
+
337
+ # Then, after the maintenance is done, remove the maintenance line
338
+ files.line(
339
+ name="Remove the down-for-maintenance line in /etc/motd",
340
+ path="/etc/motd",
341
+ line=maintenance_line,
342
+ replace="",
343
+ present=False,
344
+ )
345
+
346
+ # example where there is '*' in the line
347
+ files.line(
348
+ name="Ensure /netboot/nfs is in /etc/exports",
349
+ path="/etc/exports",
350
+ line=r"/netboot/nfs .*",
351
+ replace="/netboot/nfs *(ro,sync,no_wdelay,insecure_locks,no_root_squash,"
352
+ "insecure,no_subtree_check)",
353
+ )
354
+
355
+ files.line(
356
+ name="Ensure myweb can run /usr/bin/python3 without password",
357
+ path="/etc/sudoers",
358
+ line=r"myweb .*",
359
+ replace="myweb ALL=(ALL) NOPASSWD: /usr/bin/python3",
360
+ )
361
+
362
+ # example when there are double quotes (")
363
+ line = 'QUOTAUSER=""'
364
+ files.line(
365
+ name="Example with double quotes (")",
366
+ path="/etc/adduser.conf",
367
+ line="^{}$".format(line),
368
+ replace=line,
369
+ )
370
+ """
371
+
372
+ match_line = adjust_regex(line, escape_regex_characters)
373
+ #
374
+ # if escape_regex_characters:
375
+ # match_line = re.sub(r"([\.\\\+\*\?\[\^\]\$\(\)\{\}\-])", r"\\\1", match_line)
376
+ #
377
+ # # Ensure we're matching a whole line, note: match may be a partial line so we
378
+ # # put any matches on either side.
379
+ # if not match_line.startswith("^"):
380
+ # match_line = "^.*{0}".format(match_line)
381
+ # if not match_line.endswith("$"):
382
+ # match_line = "{0}.*$".format(match_line)
383
+
384
+ # Is there a matching line in this file?
385
+ present_lines = host.get_fact(
386
+ FindInFile,
387
+ path=path,
388
+ pattern=match_line,
389
+ interpolate_variables=interpolate_variables,
390
+ )
391
+
392
+ # If replace present, use that over the matching line
393
+ if replace:
394
+ line = replace
395
+ # We must provide some kind of replace to sed_replace_command below
396
+ else:
397
+ replace = ""
398
+
399
+ # Save commands for re-use in dynamic script when file not present at fact stage
400
+ if ensure_newline:
401
+ echo_command = make_formatted_string_command(
402
+ "( [ $(tail -c1 {1} | wc -l) -eq 0 ] && echo ; echo {0} ) >> {1}",
403
+ '"{0}"'.format(line) if interpolate_variables else QuoteString(line),
404
+ QuoteString(path),
405
+ )
406
+ else:
407
+ echo_command = make_formatted_string_command(
408
+ "echo {0} >> {1}",
409
+ '"{0}"'.format(line) if interpolate_variables else QuoteString(line),
410
+ QuoteString(path),
411
+ )
412
+
413
+ if backup:
414
+ backup_filename = "{0}.{1}".format(path, get_timestamp())
415
+ echo_command = StringCommand(
416
+ make_formatted_string_command(
417
+ "cp {0} {1} && ",
418
+ QuoteString(path),
419
+ QuoteString(backup_filename),
420
+ ),
421
+ echo_command,
422
+ )
423
+
424
+ sed_replace_command = sed_replace(
425
+ path,
426
+ match_line,
427
+ replace,
428
+ flags=flags,
429
+ backup=backup,
430
+ interpolate_variables=interpolate_variables,
431
+ )
432
+
433
+ # No line and we want it, append it
434
+ if not present_lines and present:
435
+ # If we're doing replacement, only append if the *replacement* line
436
+ # does not exist (as we are appending the replacement).
437
+ if replace:
438
+ # Ensure replace explicitly matches a whole line
439
+ replace_line = replace
440
+ if not replace_line.startswith("^"):
441
+ replace_line = f"^{replace_line}"
442
+ if not replace_line.endswith("$"):
443
+ replace_line = f"{replace_line}$"
444
+
445
+ present_lines = host.get_fact(
446
+ FindInFile,
447
+ path=path,
448
+ pattern=replace_line,
449
+ interpolate_variables=interpolate_variables,
450
+ )
451
+
452
+ if not present_lines:
453
+ yield echo_command
454
+ else:
455
+ host.noop('line "{0}" exists in {1}'.format(replace or line, path))
456
+
457
+ # Line(s) exists and we want to remove them
458
+ elif present_lines and not present:
459
+ yield sed_delete(
460
+ path,
461
+ match_line,
462
+ "",
463
+ flags=flags,
464
+ backup=backup,
465
+ interpolate_variables=interpolate_variables,
466
+ )
467
+
468
+ # Line(s) exists and we have want to ensure they're correct
469
+ elif present_lines and present:
470
+ # If any of lines are different, sed replace them
471
+ if replace and any(line != replace for line in present_lines):
472
+ yield sed_replace_command
473
+ else:
474
+ host.noop('line "{0}" exists in {1}'.format(replace or line, path))
475
+
476
+
477
+ @operation()
478
+ def replace(
479
+ path: str,
480
+ text: str | None = None,
481
+ replace: str | None = None,
482
+ flags: list[str] | None = None,
483
+ backup=False,
484
+ interpolate_variables=False,
485
+ match=None, # deprecated
486
+ ):
487
+ """
488
+ Replace contents of a file using ``sed``.
489
+
490
+ + path: target remote file to edit
491
+ + text: text/regex to match against
492
+ + replace: text to replace with
493
+ + flags: list of flags to pass to sed
494
+ + backup: whether to backup the file (see below)
495
+ + interpolate_variables: whether to interpolate variables in ``replace``
496
+
497
+ Backup:
498
+ If set to ``True``, any editing of the file will place an old copy with the ISO
499
+ date (taken from the machine running ``pyinfra``) appended as the extension. If
500
+ you pass a string value this will be used as the extension of the backed up file.
501
+
502
+ **Example:**
503
+
504
+ .. code:: python
505
+
506
+ files.replace(
507
+ name="Change part of a line in a file",
508
+ path="/etc/motd",
509
+ text="verboten",
510
+ replace="forbidden",
511
+ )
512
+ """
513
+
514
+ if text is None and match:
515
+ text = match
516
+ logger.warning(
517
+ (
518
+ "The `match` argument has been replaced by "
519
+ "`text` in the `files.replace` operation ({0})"
520
+ ).format(get_call_location()),
521
+ )
522
+
523
+ if text is None:
524
+ raise TypeError("Missing argument `text` required in `files.replace` operation")
525
+
526
+ if replace is None:
527
+ raise TypeError("Missing argument `replace` required in `files.replace` operation")
528
+
529
+ existing_lines = host.get_fact(
530
+ FindInFile,
531
+ path=path,
532
+ pattern=text,
533
+ interpolate_variables=interpolate_variables,
534
+ )
535
+
536
+ # Only do the replacement if the file does not exist (it may be created earlier)
537
+ # or we have matching lines.
538
+ if existing_lines is None or existing_lines:
539
+ yield sed_replace(
540
+ path,
541
+ text,
542
+ replace,
543
+ flags=flags,
544
+ backup=backup,
545
+ interpolate_variables=interpolate_variables,
546
+ )
547
+ else:
548
+ host.noop('string "{0}" does not exist in {1}'.format(text, path))
549
+
550
+
551
+ @operation()
552
+ def sync(
553
+ src: str,
554
+ dest: str,
555
+ user: str | None = None,
556
+ group: str | None = None,
557
+ mode: str | None = None,
558
+ dir_mode: str | None = None,
559
+ delete=False,
560
+ exclude: str | list[str] | tuple[str] | None = None,
561
+ exclude_dir: str | list[str] | tuple[str] | None = None,
562
+ add_deploy_dir=True,
563
+ ):
564
+ """
565
+ Syncs a local directory with a remote one, with delete support. Note that delete will
566
+ remove extra files on the remote side, but not extra directories.
567
+
568
+ + src: local directory to sync
569
+ + dest: remote directory to sync to
570
+ + user: user to own the files and directories
571
+ + group: group to own the files and directories
572
+ + mode: permissions of the files
573
+ + dir_mode: permissions of the directories
574
+ + delete: delete remote files not present locally
575
+ + exclude: string or list/tuple of strings to match & exclude files (eg ``*.pyc``)
576
+ + exclude_dir: string or list/tuple of strings to match & exclude directories (eg node_modules)
577
+ + add_deploy_dir: interpret src as relative to deploy directory instead of current directory
578
+
579
+ **Example:**
580
+
581
+ .. code:: python
582
+
583
+ # Sync local files/tempdir to remote /tmp/tempdir
584
+ files.sync(
585
+ name="Sync a local directory with remote",
586
+ src="files/tempdir",
587
+ dest="/tmp/tempdir",
588
+ )
589
+
590
+ Note: ``exclude`` and ``exclude_dir`` use ``fnmatch`` behind the scenes to do the filtering.
591
+
592
+ + ``exclude`` matches against the filename.
593
+ + ``exclude_dir`` matches against the path of the directory, relative to ``src``.
594
+ Since fnmatch does not treat path separators (``/`` or ``\\``) as special characters,
595
+ excluding all directories matching a given name, however deep under ``src`` they are,
596
+ can be done for example with ``exclude_dir=["__pycache__", "*/__pycache__"]``
597
+
598
+ """
599
+ original_src = src # Keep a copy to reference in errors
600
+ src = os.path.normpath(src)
601
+
602
+ # Add deploy directory?
603
+ if add_deploy_dir and state.cwd:
604
+ src = os.path.join(state.cwd, src)
605
+
606
+ # Ensure the source directory exists
607
+ if not os.path.isdir(src):
608
+ raise IOError("No such directory: {0}".format(original_src))
609
+
610
+ # Ensure exclude is a list/tuple
611
+ if exclude is not None:
612
+ if not isinstance(exclude, (list, tuple)):
613
+ exclude = [exclude]
614
+
615
+ # Ensure exclude_dir is a list/tuple
616
+ if exclude_dir is not None:
617
+ if not isinstance(exclude_dir, (list, tuple)):
618
+ exclude_dir = [exclude_dir]
619
+
620
+ put_files = []
621
+ ensure_dirnames = []
622
+ for dirpath, dirnames, filenames in os.walk(src, topdown=True):
623
+ remote_dirpath = Path(os.path.normpath(os.path.relpath(dirpath, src))).as_posix()
624
+
625
+ # Filter excluded dirs
626
+ for child_dir in dirnames[:]:
627
+ child_path = os.path.normpath(os.path.join(remote_dirpath, child_dir))
628
+ if exclude_dir and any(fnmatch(child_path, match) for match in exclude_dir):
629
+ dirnames.remove(child_dir)
630
+
631
+ if remote_dirpath and remote_dirpath != os.path.curdir:
632
+ ensure_dirnames.append((remote_dirpath, get_path_permissions_mode(dirpath)))
633
+
634
+ for filename in filenames:
635
+ full_filename = os.path.join(dirpath, filename)
636
+
637
+ # Should we exclude this file?
638
+ if exclude and any(fnmatch(full_filename, match) for match in exclude):
639
+ continue
640
+
641
+ remote_full_filename = unix_path_join(
642
+ *[
643
+ item
644
+ for item in (dest, remote_dirpath, filename)
645
+ if item and item != os.path.curdir
646
+ ]
647
+ )
648
+ put_files.append((full_filename, remote_full_filename))
649
+
650
+ # Ensure the destination directory - if the destination is a link, ensure
651
+ # the link target is a directory.
652
+ dest_to_ensure = dest
653
+ dest_link_info = host.get_fact(Link, path=dest)
654
+ if dest_link_info:
655
+ dest_to_ensure = dest_link_info["link_target"]
656
+
657
+ yield from directory._inner(
658
+ path=dest_to_ensure,
659
+ user=user,
660
+ group=group,
661
+ mode=dir_mode or get_path_permissions_mode(src),
662
+ )
663
+
664
+ # Ensure any remote dirnames
665
+ for dir_path_curr, dir_mode_curr in ensure_dirnames:
666
+ yield from directory._inner(
667
+ path=unix_path_join(dest, dir_path_curr),
668
+ user=user,
669
+ group=group,
670
+ mode=dir_mode or dir_mode_curr,
671
+ )
672
+
673
+ # Put each file combination
674
+ for local_filename, remote_filename in put_files:
675
+ yield from put._inner(
676
+ src=local_filename,
677
+ dest=remote_filename,
678
+ user=user,
679
+ group=group,
680
+ mode=mode or get_path_permissions_mode(local_filename),
681
+ add_deploy_dir=False,
682
+ create_remote_dir=False, # handled above
683
+ )
684
+
685
+ # Delete any extra files
686
+ if delete:
687
+ remote_filenames = set(host.get_fact(FindFiles, path=dest) or [])
688
+ wanted_filenames = set([remote_filename for _, remote_filename in put_files])
689
+ files_to_delete = remote_filenames - wanted_filenames
690
+ for filename in files_to_delete:
691
+ # Should we exclude this file?
692
+ if exclude and any(fnmatch(filename, match) for match in exclude):
693
+ continue
694
+
695
+ yield from file._inner(path=filename, present=False)
696
+
697
+
698
+ @memoize
699
+ def show_rsync_warning() -> None:
700
+ logger.warning("The `files.rsync` operation is in alpha!")
701
+
702
+
703
+ @operation(is_idempotent=False)
704
+ def rsync(src: str, dest: str, flags: list[str] | None = None):
705
+ """
706
+ Use ``rsync`` to sync a local directory to the remote system. This operation will actually call
707
+ the ``rsync`` binary on your system.
708
+
709
+ .. important::
710
+ The ``files.rsync`` operation is in alpha, and only supported using SSH
711
+ or ``@local`` connectors. When using the SSH connector, rsync will automatically use the
712
+ StrictHostKeyChecking setting, config and known_hosts file (when specified).
713
+
714
+ .. caution::
715
+ When using SSH, the ``files.rsync`` operation only supports the ``sudo`` and ``sudo_user``
716
+ global arguments.
717
+ """
718
+
719
+ if flags is None:
720
+ flags = ["-ax", "--delete"]
721
+ show_rsync_warning()
722
+
723
+ try:
724
+ host.check_can_rsync()
725
+ except NotImplementedError as e:
726
+ raise OperationError(*e.args)
727
+
728
+ yield RsyncCommand(src, dest, flags)
729
+
730
+
731
+ def _create_remote_dir(remote_filename, user, group):
732
+ # Always use POSIX style path as local might be Windows, remote always *nix
733
+ remote_dirname = posixpath.dirname(remote_filename)
734
+ if remote_dirname:
735
+ yield from directory._inner(
736
+ path=remote_dirname,
737
+ user=user,
738
+ group=group,
739
+ _no_check_owner_mode=True, # don't check existing user/mode
740
+ _no_fail_on_link=True, # don't fail if the path is a link
741
+ )
742
+
743
+
744
+ def _file_equal(local_path: str | IO[Any] | None, remote_path: str) -> bool:
745
+ if local_path is None:
746
+ return False
747
+ for fact, get_sum in [
748
+ (Sha1File, get_file_sha1),
749
+ (Md5File, get_file_md5),
750
+ (Sha256File, get_file_sha256),
751
+ ]:
752
+ remote_sum = host.get_fact(fact, path=remote_path)
753
+ if remote_sum:
754
+ local_sum = get_sum(local_path)
755
+ return local_sum == remote_sum
756
+ return False
757
+
758
+
759
+ def _remote_file_equal(remote_path_a: str, remote_path_b: str) -> bool:
760
+ for fact in [Sha1File, Md5File, Sha256File]:
761
+ sum_a = host.get_fact(fact, path=remote_path_a)
762
+ sum_b = host.get_fact(fact, path=remote_path_b)
763
+ if sum_a and sum_b:
764
+ return sum_a == sum_b
765
+ return False
766
+
767
+
768
+ @operation(
769
+ # We don't (currently) cache the local state, so there's nothing we can
770
+ # update to flag the local file as present.
771
+ is_idempotent=False,
772
+ )
773
+ def get(
774
+ src: str,
775
+ dest: str,
776
+ add_deploy_dir=True,
777
+ create_local_dir=False,
778
+ force=False,
779
+ ):
780
+ """
781
+ Download a file from the remote system.
782
+
783
+ + src: the remote filename to download
784
+ + dest: the local filename to download the file to
785
+ + add_deploy_dir: dest is relative to the deploy directory
786
+ + create_local_dir: create the local directory if it doesn't exist
787
+ + force: always download the file, even if the local copy matches
788
+
789
+ Note:
790
+ This operation is not suitable for large files as it may involve copying
791
+ the remote file before downloading it.
792
+
793
+ **Example:**
794
+
795
+ .. code:: python
796
+
797
+ files.get(
798
+ name="Download a file from a remote",
799
+ src="/etc/centos-release",
800
+ dest="/tmp/whocares",
801
+ )
802
+ """
803
+
804
+ if add_deploy_dir and state.cwd:
805
+ dest = os.path.join(state.cwd, dest)
806
+
807
+ if create_local_dir:
808
+ local_pathname = os.path.dirname(dest)
809
+ if not os.path.exists(local_pathname):
810
+ os.makedirs(local_pathname)
811
+
812
+ remote_file = host.get_fact(File, path=src)
813
+
814
+ # Use _temp_dir global argument if provided
815
+ temp_dir = None
816
+ if host.current_op_global_arguments:
817
+ temp_dir = host.current_op_global_arguments.get("_temp_dir")
818
+
819
+ # No remote file, so assume exists and download it "blind"
820
+ if not remote_file or force:
821
+ yield FileDownloadCommand(
822
+ src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
823
+ )
824
+
825
+ # No local file, so always download
826
+ elif not os.path.exists(dest):
827
+ yield FileDownloadCommand(
828
+ src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
829
+ )
830
+
831
+ # Remote file exists - check if it matches our local
832
+ else:
833
+ # Check hash sum, download if needed
834
+ if not _file_equal(dest, src):
835
+ yield FileDownloadCommand(
836
+ src,
837
+ dest,
838
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
839
+ )
840
+ else:
841
+ host.noop("file {0} has already been downloaded".format(dest))
842
+
843
+
844
+ def _canonicalize_timespec(field: MetadataTimeField, local_file, timespec):
845
+ if isinstance(timespec, datetime):
846
+ if not timespec.tzinfo:
847
+ # specify remote host timezone
848
+ timespec_with_tz = timespec.replace(tzinfo=host.get_fact(Date).tzinfo)
849
+ return timespec_with_tz
850
+ else:
851
+ return timespec
852
+ elif isinstance(timespec, bool) and timespec:
853
+ lf_ts = (
854
+ os.stat(local_file).st_atime
855
+ if field is MetadataTimeField.ATIME
856
+ else os.stat(local_file).st_mtime
857
+ )
858
+ return datetime.fromtimestamp(lf_ts, tz=timezone.utc)
859
+ else:
860
+ try:
861
+ isodatetime = datetime.fromisoformat(timespec)
862
+ if not isodatetime.tzinfo:
863
+ return isodatetime.replace(tzinfo=host.get_fact(Date).tzinfo)
864
+ else:
865
+ return isodatetime
866
+ except ValueError:
867
+ try:
868
+ timestamp = float(timespec)
869
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
870
+ except ValueError:
871
+ # verify there is a remote file matching path in timesrc
872
+ ref_file = host.get_fact(File, path=timespec)
873
+ if ref_file:
874
+ if field is MetadataTimeField.ATIME:
875
+ assert ref_file["atime"] is not None
876
+ return ref_file["atime"].replace(tzinfo=timezone.utc)
877
+ else:
878
+ assert ref_file["mtime"] is not None
879
+ return ref_file["mtime"].replace(tzinfo=timezone.utc)
880
+ else:
881
+ ValueError("Bad argument for `timesspec`: {0}".format(timespec))
882
+
883
+
884
+ # returns True for a visible difference in the second field between the datetime values
885
+ # in the ref's TZ
886
+ def _times_differ_in_s(ref, cand):
887
+ assert ref.tzinfo and cand.tzinfo
888
+ cand_in_ref_tz = cand.astimezone(ref.tzinfo)
889
+ return (abs((cand_in_ref_tz - ref).total_seconds()) >= 1.0) or (
890
+ ref.second != cand_in_ref_tz.second
891
+ )
892
+
893
+
894
+ @operation()
895
+ def put(
896
+ src: str | IO[Any],
897
+ dest: str,
898
+ user: str | None = None,
899
+ group: str | None = None,
900
+ mode: int | str | bool | None = None,
901
+ add_deploy_dir=True,
902
+ create_remote_dir=True,
903
+ force=False,
904
+ assume_exists=False,
905
+ atime: datetime | float | int | str | bool | None = None,
906
+ mtime: datetime | float | int | str | bool | None = None,
907
+ ):
908
+ """
909
+ Upload a local file, or file-like object, to the remote system.
910
+
911
+ + src: filename or IO-like object to upload
912
+ + dest: remote filename to upload to
913
+ + user: user to own the files
914
+ + group: group to own the files
915
+ + mode: permissions of the files, use ``True`` to copy the local file
916
+ + add_deploy_dir: src is relative to the deploy directory
917
+ + create_remote_dir: create the remote directory if it doesn't exist
918
+ + force: always upload the file, even if the remote copy matches
919
+ + assume_exists: whether to assume the local file exists
920
+ + atime: value of atime the file should have, use ``True`` to match the local file
921
+ + mtime: value of mtime the file should have, use ``True`` to match the local file
922
+
923
+ ``dest``:
924
+ If this is a directory that already exists on the remote side, the local
925
+ file will be uploaded to that directory with the same filename.
926
+
927
+ ``mode``:
928
+ When set to ``True`` the permissions of the local file are applied to the
929
+ remote file after the upload is complete. If set to an octal value with
930
+ digits for at least user, group, and other, either as an ``int`` or
931
+ ``str``, those permissions will be used.
932
+
933
+ ``create_remote_dir``:
934
+ If the remote directory does not exist it will be created using the same
935
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
936
+ if this is required call ``files.directory`` separately.
937
+
938
+ ``atime`` and ``mtime``:
939
+ When set to values other than ``False`` or ``None``, the respective metadata
940
+ fields on the remote file will updated accordingly. Timestamp values are
941
+ considered equivalent if the difference is less than one second and they have
942
+ the identical number in the seconds field. If set to ``True`` the local
943
+ file is the source of the value. Otherwise, these values can be provided as
944
+ ``datetime`` objects, POSIX timestamps, or strings that can be parsed into
945
+ either of these date and time specifications. They can also be reference file
946
+ paths on the remote host, as with the ``-r`` argument to ``touch``. If a
947
+ ``datetime`` argument has no ``tzinfo`` value (i.e., it is naive), it is
948
+ assumed to be in the remote host's local timezone. There is no shortcut for
949
+ setting both ``atime` and ``mtime`` values with a single time specification,
950
+ unlike the native ``touch`` command.
951
+
952
+ Notes:
953
+ This operation is not suitable for large files as it may involve copying
954
+ the file before uploading it.
955
+
956
+ Currently, if the mode argument is anything other than a ``bool`` or a full
957
+ octal permission set and the remote file exists, the operation will always
958
+ behave as if the remote file does not match the specified permissions and
959
+ requires a change.
960
+
961
+ If the ``atime`` argument is set for a given file, unless the remote
962
+ filesystem is mounted ``noatime`` or ``relatime``, multiple runs of this
963
+ operation will trigger the change detection for that file, since the act of
964
+ reading and checksumming the file will cause the host OS to update the file's
965
+ ``atime``.
966
+
967
+ **Examples:**
968
+
969
+ .. code:: python
970
+
971
+ files.put(
972
+ name="Update the message of the day file",
973
+ src="files/motd",
974
+ dest="/etc/motd",
975
+ mode="644",
976
+ )
977
+
978
+ files.put(
979
+ name="Upload a StringIO object",
980
+ src=StringIO("file contents"),
981
+ dest="/etc/motd",
982
+ )
983
+ """
984
+
985
+ # Upload IO objects as-is
986
+ if hasattr(src, "read"):
987
+ local_file = src
988
+ local_sum_path = src
989
+
990
+ # Assume string filename
991
+ else:
992
+ assert isinstance(src, (str, Path))
993
+ # Add deploy directory?
994
+ if add_deploy_dir and state.cwd:
995
+ src = os.path.join(state.cwd, src)
996
+
997
+ local_file = src
998
+
999
+ if os.path.isfile(local_file):
1000
+ local_sum_path = local_file
1001
+ elif assume_exists:
1002
+ local_sum_path = None
1003
+ else:
1004
+ raise IOError("No such file: {0}".format(local_file))
1005
+
1006
+ if mode is True:
1007
+ if isinstance(local_file, str) and os.path.isfile(local_file):
1008
+ mode = get_path_permissions_mode(local_file)
1009
+ else:
1010
+ logger.warning(
1011
+ ("No local file exists to get permissions from with `mode=True` ({0})").format(
1012
+ get_call_location(),
1013
+ ),
1014
+ )
1015
+ else:
1016
+ mode = ensure_mode_int(mode)
1017
+
1018
+ remote_file = host.get_fact(File, path=dest)
1019
+
1020
+ if not remote_file and bool(host.get_fact(Directory, path=dest)):
1021
+ assert isinstance(src, str)
1022
+ dest = unix_path_join(dest, os.path.basename(src))
1023
+ remote_file = host.get_fact(File, path=dest)
1024
+
1025
+ if create_remote_dir:
1026
+ yield from _create_remote_dir(dest, user, group)
1027
+
1028
+ # Use _temp_dir global argument if provided
1029
+ temp_dir = None
1030
+ if host.current_op_global_arguments:
1031
+ temp_dir = host.current_op_global_arguments.get("_temp_dir")
1032
+
1033
+ # No remote file, always upload and user/group/mode if supplied
1034
+ if not remote_file or force:
1035
+ if state.config.DIFF:
1036
+ host.log(f"Will create {click.style(dest, bold=True)}", logger.info)
1037
+
1038
+ with get_file_io(src, "r") as f:
1039
+ desired_lines = f.readlines()
1040
+
1041
+ for line in generate_color_diff([], desired_lines):
1042
+ logger.info(f" {line}")
1043
+ logger.info("")
1044
+
1045
+ yield FileUploadCommand(
1046
+ local_file,
1047
+ dest,
1048
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
1049
+ )
1050
+
1051
+ if user or group:
1052
+ yield file_utils.chown(dest, user, group)
1053
+
1054
+ if mode:
1055
+ yield file_utils.chmod(dest, mode)
1056
+
1057
+ # do mtime before atime to ensure atime setting isn't undone by mtime setting
1058
+ if mtime:
1059
+ yield file_utils.touch(
1060
+ dest,
1061
+ MetadataTimeField.MTIME,
1062
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1063
+ )
1064
+
1065
+ if atime:
1066
+ yield file_utils.touch(
1067
+ dest,
1068
+ MetadataTimeField.ATIME,
1069
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1070
+ )
1071
+
1072
+ # File exists, check sum and check user/group/mode/atime/mtime if supplied
1073
+ else:
1074
+ if not _file_equal(local_sum_path, dest):
1075
+ if state.config.DIFF:
1076
+ # Generate diff when contents change
1077
+ current_contents = host.get_fact(FileContents, path=dest)
1078
+ if current_contents:
1079
+ current_lines = [line + "\n" for line in current_contents]
1080
+ else:
1081
+ current_lines = []
1082
+
1083
+ host.log(f"Will modify {click.style(dest, bold=True)}", logger.info)
1084
+
1085
+ with get_file_io(src, "r") as f:
1086
+ desired_lines = f.readlines()
1087
+
1088
+ for line in generate_color_diff(current_lines, desired_lines):
1089
+ logger.info(f" {line}")
1090
+ logger.info("")
1091
+
1092
+ yield FileUploadCommand(
1093
+ local_file,
1094
+ dest,
1095
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
1096
+ )
1097
+
1098
+ if user or group:
1099
+ yield file_utils.chown(dest, user, group)
1100
+
1101
+ if mode:
1102
+ yield file_utils.chmod(dest, mode)
1103
+
1104
+ if mtime:
1105
+ yield file_utils.touch(
1106
+ dest,
1107
+ MetadataTimeField.MTIME,
1108
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1109
+ )
1110
+
1111
+ if atime:
1112
+ yield file_utils.touch(
1113
+ dest,
1114
+ MetadataTimeField.ATIME,
1115
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1116
+ )
1117
+
1118
+ else:
1119
+ changed = False
1120
+
1121
+ # Check mode
1122
+ if mode and remote_file["mode"] != mode:
1123
+ yield file_utils.chmod(dest, mode)
1124
+ changed = True
1125
+
1126
+ # Check user/group
1127
+ if (user and remote_file["user"] != user) or (group and remote_file["group"] != group):
1128
+ yield file_utils.chown(dest, user, group)
1129
+ changed = True
1130
+
1131
+ # Check mtime
1132
+ if mtime:
1133
+ canonical_mtime = _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime)
1134
+ assert remote_file["mtime"] is not None
1135
+ if _times_differ_in_s(
1136
+ canonical_mtime, remote_file["mtime"].replace(tzinfo=timezone.utc)
1137
+ ):
1138
+ yield file_utils.touch(dest, MetadataTimeField.MTIME, canonical_mtime)
1139
+ changed = True
1140
+
1141
+ # Check atime
1142
+ if atime:
1143
+ canonical_atime = _canonicalize_timespec(MetadataTimeField.ATIME, src, atime)
1144
+ assert remote_file["atime"] is not None
1145
+ if _times_differ_in_s(
1146
+ canonical_atime, remote_file["atime"].replace(tzinfo=timezone.utc)
1147
+ ):
1148
+ yield file_utils.touch(dest, MetadataTimeField.ATIME, canonical_atime)
1149
+ changed = True
1150
+
1151
+ if not changed:
1152
+ host.noop("file {0} is already uploaded".format(dest))
1153
+
1154
+
1155
+ @operation()
1156
+ def template(
1157
+ src: str | IO[Any],
1158
+ dest: str,
1159
+ user: str | None = None,
1160
+ group: str | None = None,
1161
+ mode: str | None = None,
1162
+ create_remote_dir: bool = True,
1163
+ jinja_env_kwargs: dict[str, Any] | None = None,
1164
+ **data,
1165
+ ):
1166
+ '''
1167
+ Generate a template using jinja2 and write it to the remote system.
1168
+
1169
+ + src: template filename or IO-like object
1170
+ + dest: remote filename
1171
+ + user: user to own the files
1172
+ + group: group to own the files
1173
+ + mode: permissions of the files
1174
+ + create_remote_dir: create the remote directory if it doesn't exist
1175
+ + jinja_env_kwargs: keyword arguments to be passed into the jinja Environment()
1176
+
1177
+ ``create_remote_dir``:
1178
+ If the remote directory does not exist it will be created using the same
1179
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1180
+ if this is required call ``files.directory`` separately.
1181
+
1182
+ ``jinja_env_kwargs``:
1183
+ To have more control over how jinja2 renders your template, you can pass
1184
+ a dict with arguments that will be passed as keyword args to the jinja2
1185
+ `Environment() <https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.Environment>`_.
1186
+
1187
+ The ``host``, ``state``, and ``inventory`` objects will be automatically passed to the template
1188
+ if not set explicitly.
1189
+
1190
+ Notes:
1191
+ Common convention is to store templates in a "templates" directory and
1192
+ have a filename suffix with '.j2' (for jinja2).
1193
+
1194
+ The default template lookup directory (used with jinjas ``extends``, ``import`` and
1195
+ ``include`` statements) is the current working directory.
1196
+
1197
+ For information on the template syntax, see
1198
+ `the jinja2 docs <https://jinja.palletsprojects.com>`_.
1199
+
1200
+ **Examples:**
1201
+
1202
+ .. code:: python
1203
+
1204
+ files.template(
1205
+ name="Create a templated file",
1206
+ src="templates/somefile.conf.j2",
1207
+ dest="/etc/somefile.conf",
1208
+ )
1209
+
1210
+ files.template(
1211
+ name="Create service file",
1212
+ src="templates/myweb.service.j2",
1213
+ dest="/etc/systemd/system/myweb.service",
1214
+ mode="755",
1215
+ user="root",
1216
+ group="root",
1217
+ )
1218
+
1219
+ # Example showing how to pass python variable to template file. You can also
1220
+ # use dicts and lists. The .j2 file can use `{{ foo_variable }}` to be interpolated.
1221
+ foo_variable = 'This is some foo variable contents'
1222
+ foo_dict = {
1223
+ "str1": "This is string 1",
1224
+ "str2": "This is string 2"
1225
+ }
1226
+ foo_list = [
1227
+ "entry 1",
1228
+ "entry 2"
1229
+ ]
1230
+
1231
+ template = StringIO("""
1232
+ name: "{{ foo_variable }}"
1233
+ dict_contents:
1234
+ str1: "{{ foo_dict.str1 }}"
1235
+ str2: "{{ foo_dict.str2 }}"
1236
+ list_contents:
1237
+ {% for entry in foo_list %}
1238
+ - "{{ entry }}"
1239
+ {% endfor %}
1240
+ """)
1241
+
1242
+ files.template(
1243
+ name="Create a templated file",
1244
+ src=template,
1245
+ dest="/tmp/foo.yml",
1246
+ foo_variable=foo_variable,
1247
+ foo_dict=foo_dict,
1248
+ foo_list=foo_list
1249
+ )
1250
+
1251
+ # Example showing how to use host and inventory in a template file.
1252
+ template = StringIO("""
1253
+ name: "{{ host.name }}"
1254
+ list_contents:
1255
+ {% for entry in inventory.groups.my_servers %}
1256
+ - "{{ entry }}"
1257
+ {% endfor %}
1258
+ """)
1259
+
1260
+ files.template(
1261
+ name="Create a templated file",
1262
+ src=template,
1263
+ dest="/tmp/foo.yml"
1264
+ )
1265
+ '''
1266
+
1267
+ if not hasattr(src, "read") and state.cwd:
1268
+ src = os.path.join(state.cwd, src)
1269
+
1270
+ # Ensure host/state/inventory are available inside templates (if not set)
1271
+ data.setdefault("host", host)
1272
+ data.setdefault("state", state)
1273
+ data.setdefault("inventory", state.inventory)
1274
+
1275
+ # Render and make file-like it's output
1276
+ try:
1277
+ output = get_template(src, jinja_env_kwargs).render(data)
1278
+ except (TemplateRuntimeError, TemplateSyntaxError, UndefinedError) as e:
1279
+ trace_frames = [
1280
+ frame
1281
+ for frame in traceback.extract_tb(sys.exc_info()[2])
1282
+ if frame[2] in ("template", "<module>", "top-level template code")
1283
+ ] # thank you https://github.com/saltstack/salt/blob/master/salt/utils/templates.py
1284
+
1285
+ line_number = trace_frames[-1][1]
1286
+
1287
+ # Quickly read the line in question and one above/below for nicer debugging
1288
+ with get_file_io(src, "r") as f:
1289
+ template_lines = f.readlines()
1290
+
1291
+ template_lines = [line.strip() for line in template_lines]
1292
+ relevant_lines = template_lines[max(line_number - 2, 0) : line_number + 1]
1293
+
1294
+ raise OperationError(
1295
+ "Error in template: {0} (L{1}): {2}\n...\n{3}\n...".format(
1296
+ src,
1297
+ line_number,
1298
+ e,
1299
+ "\n".join(relevant_lines),
1300
+ ),
1301
+ ) from None
1302
+
1303
+ output_file = StringIO(output)
1304
+ # Set the template attribute for nicer debugging
1305
+ output_file.template = src # type: ignore[attr-defined]
1306
+
1307
+ # Pass to the put function
1308
+ yield from put._inner(
1309
+ src=output_file,
1310
+ dest=dest,
1311
+ user=user,
1312
+ group=group,
1313
+ mode=mode,
1314
+ add_deploy_dir=False,
1315
+ create_remote_dir=create_remote_dir,
1316
+ )
1317
+
1318
+
1319
+ @operation()
1320
+ def move(src: str, dest: str, overwrite=False):
1321
+ """
1322
+ Move remote file/directory/link into remote directory
1323
+
1324
+ + src: remote file/directory to move
1325
+ + dest: remote directory to move `src` into
1326
+ + overwrite: whether to overwrite dest, if present
1327
+ """
1328
+
1329
+ if host.get_fact(File, src) is None:
1330
+ raise OperationError("src {0} does not exist".format(src))
1331
+
1332
+ if not host.get_fact(Directory, dest):
1333
+ raise OperationError("dest {0} is not an existing directory".format(dest))
1334
+
1335
+ full_dest_path = os.path.join(dest, os.path.basename(src))
1336
+ if host.get_fact(File, full_dest_path) is not None:
1337
+ if overwrite:
1338
+ yield StringCommand("rm", "-rf", QuoteString(full_dest_path))
1339
+ else:
1340
+ raise OperationError(
1341
+ "dest {0} already exists and `overwrite` is unset".format(full_dest_path)
1342
+ )
1343
+
1344
+ yield StringCommand("mv", QuoteString(src), QuoteString(dest))
1345
+
1346
+
1347
+ @operation()
1348
+ def copy(src: str, dest: str, overwrite=False):
1349
+ """
1350
+ Copy remote file/directory/link into remote directory
1351
+
1352
+ + src: remote file/directory to copy
1353
+ + dest: remote directory to copy `src` into
1354
+ + overwrite: whether to overwrite dest, if present
1355
+ """
1356
+ src_is_dir = host.get_fact(Directory, src)
1357
+ if not host.get_fact(File, src) and not src_is_dir:
1358
+ raise OperationError(f"src {src} does not exist")
1359
+
1360
+ if not host.get_fact(Directory, dest):
1361
+ raise OperationError(f"dest {dest} is not an existing directory")
1362
+
1363
+ dest_file_path = os.path.join(dest, os.path.basename(src))
1364
+ dest_file_exists = host.get_fact(File, dest_file_path)
1365
+ if dest_file_exists and not overwrite:
1366
+ if _remote_file_equal(src, dest_file_path):
1367
+ host.noop(f"{dest_file_path} already exists")
1368
+ return
1369
+ else:
1370
+ raise OperationError(f"{dest_file_path} already exists and is different than src")
1371
+
1372
+ cp_cmd = ["cp -r"]
1373
+
1374
+ if overwrite:
1375
+ cp_cmd.append("-f")
1376
+
1377
+ yield StringCommand(*cp_cmd, QuoteString(src), QuoteString(dest))
1378
+
1379
+
1380
+ def _validate_path(path):
1381
+ try:
1382
+ return os.fspath(path)
1383
+ except TypeError:
1384
+ raise OperationTypeError("`path` must be a string or `os.PathLike` object")
1385
+
1386
+
1387
+ def _raise_or_remove_invalid_path(fs_type, path, force, force_backup, force_backup_dir):
1388
+ if force:
1389
+ if force_backup:
1390
+ backup_path = "{0}.{1}".format(path, get_timestamp())
1391
+ if force_backup_dir:
1392
+ backup_path = os.path.basename(backup_path)
1393
+ backup_path = "{0}/{1}".format(force_backup_dir, backup_path)
1394
+ yield StringCommand("mv", QuoteString(path), QuoteString(backup_path))
1395
+ else:
1396
+ yield StringCommand("rm", "-rf", QuoteString(path))
1397
+ else:
1398
+ raise OperationError("{0} exists and is not a {1}".format(path, fs_type))
1399
+
1400
+
1401
+ @operation()
1402
+ def link(
1403
+ path: str,
1404
+ target: str | None = None,
1405
+ present=True,
1406
+ user: str | None = None,
1407
+ group: str | None = None,
1408
+ symbolic=True,
1409
+ create_remote_dir=True,
1410
+ force=False,
1411
+ force_backup=True,
1412
+ force_backup_dir: str | None = None,
1413
+ ):
1414
+ """
1415
+ Add/remove/update links.
1416
+
1417
+ + path: the name of the link
1418
+ + target: the file/directory the link points to
1419
+ + present: whether the link should exist
1420
+ + user: user to own the link
1421
+ + group: group to own the link
1422
+ + symbolic: whether to make a symbolic link (vs hard link)
1423
+ + create_remote_dir: create the remote directory if it doesn't exist
1424
+ + force: if the target exists and is not a file, move or remove it and continue
1425
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1426
+ + force_backup_dir: directory to move any backup to when ``force=True``
1427
+
1428
+ ``create_remote_dir``:
1429
+ If the remote directory does not exist it will be created using the same
1430
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1431
+ if this is required call ``files.directory`` separately.
1432
+
1433
+ Source changes:
1434
+ If the link exists and points to a different target, pyinfra will remove it and
1435
+ recreate a new one pointing to then new target.
1436
+
1437
+ **Examples:**
1438
+
1439
+ .. code:: python
1440
+
1441
+ files.link(
1442
+ name="Create link /etc/issue2 that points to /etc/issue",
1443
+ path="/etc/issue2",
1444
+ target="/etc/issue",
1445
+ )
1446
+ """
1447
+
1448
+ path = _validate_path(path)
1449
+
1450
+ if present and not target:
1451
+ raise OperationError("If present is True target must be provided")
1452
+
1453
+ info = host.get_fact(Link, path=path)
1454
+
1455
+ if info is False: # not a link
1456
+ yield from _raise_or_remove_invalid_path(
1457
+ "link",
1458
+ path,
1459
+ force,
1460
+ force_backup,
1461
+ force_backup_dir,
1462
+ )
1463
+ info = None
1464
+
1465
+ add_args = ["ln"]
1466
+ if symbolic:
1467
+ add_args.append("-s")
1468
+
1469
+ remove_cmd = StringCommand("rm", "-f", QuoteString(path))
1470
+
1471
+ if not present:
1472
+ if info:
1473
+ yield remove_cmd
1474
+ else:
1475
+ host.noop("link {link} does not exist")
1476
+ return
1477
+
1478
+ assert target is not None # appease typing QuoteString below
1479
+ add_cmd = StringCommand(" ".join(add_args), QuoteString(target), QuoteString(path))
1480
+
1481
+ if info is None: # create
1482
+ if create_remote_dir:
1483
+ yield from _create_remote_dir(path, user, group)
1484
+
1485
+ yield add_cmd
1486
+
1487
+ if user or group:
1488
+ yield file_utils.chown(path, user, group, dereference=False)
1489
+
1490
+ else: # edit
1491
+ changed = False
1492
+
1493
+ # If the target is wrong, remove & recreate the link
1494
+ if not info or info["link_target"] != target:
1495
+ changed = True
1496
+ yield remove_cmd
1497
+ yield add_cmd
1498
+
1499
+ # Check user/group
1500
+ if (user and info["user"] != user) or (group and info["group"] != group):
1501
+ yield file_utils.chown(path, user, group, dereference=False)
1502
+ changed = True
1503
+
1504
+ if not changed:
1505
+ host.noop("link {0} already exists".format(path))
1506
+
1507
+
1508
+ @operation()
1509
+ def file(
1510
+ path: str,
1511
+ present=True,
1512
+ user: str | None = None,
1513
+ group: str | None = None,
1514
+ mode: int | str | None = None,
1515
+ touch=False,
1516
+ create_remote_dir=True,
1517
+ force=False,
1518
+ force_backup=True,
1519
+ force_backup_dir: str | None = None,
1520
+ ):
1521
+ """
1522
+ Add/remove/update files.
1523
+
1524
+ + path: name/path of the remote file
1525
+ + present: whether the file should exist
1526
+ + user: user to own the files
1527
+ + group: group to own the files
1528
+ + mode: permissions of the files as an integer, eg: 755
1529
+ + touch: whether to touch the file
1530
+ + create_remote_dir: create the remote directory if it doesn't exist
1531
+ + force: if the target exists and is not a file, move or remove it and continue
1532
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1533
+ + force_backup_dir: directory to move any backup to when ``force=True``
1534
+
1535
+ ``create_remote_dir``:
1536
+ If the remote directory does not exist it will be created using the same
1537
+ user & group as passed to ``files.put``. The mode will *not* be copied over,
1538
+ if this is required call ``files.directory`` separately.
1539
+
1540
+ **Example:**
1541
+
1542
+ .. code:: python
1543
+
1544
+ # Note: The directory /tmp/secret will get created with the default umask.
1545
+ files.file(
1546
+ name="Create /tmp/secret/file",
1547
+ path="/tmp/secret/file",
1548
+ mode="600",
1549
+ user="root",
1550
+ group="root",
1551
+ touch=True,
1552
+ create_remote_dir=True,
1553
+ )
1554
+ """
1555
+
1556
+ path = _validate_path(path)
1557
+
1558
+ mode = ensure_mode_int(mode)
1559
+ info = host.get_fact(File, path=path)
1560
+
1561
+ if info is False: # not a file
1562
+ yield from _raise_or_remove_invalid_path(
1563
+ "file",
1564
+ path,
1565
+ force,
1566
+ force_backup,
1567
+ force_backup_dir,
1568
+ )
1569
+ info = None
1570
+
1571
+ if not present:
1572
+ if info:
1573
+ yield StringCommand("rm", "-f", QuoteString(path))
1574
+ else:
1575
+ host.noop("file {0} does not exist")
1576
+ return
1577
+
1578
+ if info is None: # create
1579
+ if create_remote_dir:
1580
+ yield from _create_remote_dir(path, user, group)
1581
+
1582
+ yield StringCommand("touch", QuoteString(path))
1583
+
1584
+ if mode:
1585
+ yield file_utils.chmod(path, mode)
1586
+ if user or group:
1587
+ yield file_utils.chown(path, user, group)
1588
+
1589
+ else: # update
1590
+ changed = False
1591
+
1592
+ if touch:
1593
+ changed = True
1594
+ yield StringCommand("touch", QuoteString(path))
1595
+
1596
+ # Check mode
1597
+ if mode and (not info or info["mode"] != mode):
1598
+ yield file_utils.chmod(path, mode)
1599
+ changed = True
1600
+
1601
+ # Check user/group
1602
+ if (user and info["user"] != user) or (group and info["group"] != group):
1603
+ yield file_utils.chown(path, user, group)
1604
+ changed = True
1605
+
1606
+ if not changed:
1607
+ host.noop("file {0} already exists".format(path))
1608
+
1609
+
1610
+ @operation()
1611
+ def directory(
1612
+ path: str,
1613
+ present=True,
1614
+ user: str | None = None,
1615
+ group: str | None = None,
1616
+ mode: int | str | None = None,
1617
+ recursive=False,
1618
+ force=False,
1619
+ force_backup=True,
1620
+ force_backup_dir: str | None = None,
1621
+ _no_check_owner_mode=False,
1622
+ _no_fail_on_link=False,
1623
+ ):
1624
+ """
1625
+ Add/remove/update directories.
1626
+
1627
+ + path: path of the remote folder
1628
+ + present: whether the folder should exist
1629
+ + user: user to own the folder
1630
+ + group: group to own the folder
1631
+ + mode: permissions of the folder
1632
+ + recursive: recursively apply user/group/mode
1633
+ + force: if the target exists and is not a file, move or remove it and continue
1634
+ + force_backup: set to ``False`` to remove any existing non-file when ``force=True``
1635
+ + force_backup_dir: directory to move any backup to when ``force=True``
1636
+
1637
+ ``recursive``:
1638
+ Mode is only applied recursively if the base directory mode does not match
1639
+ the specified value. User and group are both applied recursively if the
1640
+ base directory does not match either one; otherwise they are unchanged for
1641
+ the whole tree.
1642
+
1643
+ **Examples:**
1644
+
1645
+ .. code:: python
1646
+
1647
+ files.directory(
1648
+ name="Ensure the /tmp/dir_that_we_want_removed is removed",
1649
+ path="/tmp/dir_that_we_want_removed",
1650
+ present=False,
1651
+ )
1652
+
1653
+ files.directory(
1654
+ name="Ensure /web exists",
1655
+ path="/web",
1656
+ user="myweb",
1657
+ group="myweb",
1658
+ )
1659
+
1660
+ # Multiple directories
1661
+ for dir in ["/netboot/tftp", "/netboot/nfs"]:
1662
+ files.directory(
1663
+ name="Ensure the directory `{}` exists".format(dir),
1664
+ path=dir,
1665
+ )
1666
+ """
1667
+
1668
+ path = _validate_path(path)
1669
+
1670
+ mode = ensure_mode_int(mode)
1671
+ info = host.get_fact(Directory, path=path)
1672
+
1673
+ if info is False: # not a directory
1674
+ if _no_fail_on_link and host.get_fact(Link, path=path):
1675
+ host.noop("directory {0} already exists (as a link)".format(path))
1676
+ return
1677
+ yield from _raise_or_remove_invalid_path(
1678
+ "directory",
1679
+ path,
1680
+ force,
1681
+ force_backup,
1682
+ force_backup_dir,
1683
+ )
1684
+ info = None
1685
+
1686
+ if not present:
1687
+ if info:
1688
+ yield StringCommand("rm", "-rf", QuoteString(path))
1689
+ else:
1690
+ host.noop("directory {0} does not exist")
1691
+ return
1692
+
1693
+ if info is None: # create
1694
+ yield StringCommand("mkdir", "-p", QuoteString(path))
1695
+ if mode:
1696
+ yield file_utils.chmod(path, mode, recursive=recursive)
1697
+ if user or group:
1698
+ yield file_utils.chown(path, user, group, recursive=recursive)
1699
+
1700
+ else: # update
1701
+ if _no_check_owner_mode:
1702
+ return
1703
+
1704
+ changed = False
1705
+
1706
+ if mode and (not info or info["mode"] != mode):
1707
+ yield file_utils.chmod(path, mode, recursive=recursive)
1708
+ changed = True
1709
+
1710
+ if (user and info["user"] != user) or (group and info["group"] != group):
1711
+ yield file_utils.chown(path, user, group, recursive=recursive)
1712
+ changed = True
1713
+
1714
+ if not changed:
1715
+ host.noop("directory {0} already exists".format(path))
1716
+
1717
+
1718
+ @operation()
1719
+ def flags(path: str, flags: list[str] | None = None, present=True):
1720
+ """
1721
+ Set/clear file flags.
1722
+
1723
+ + path: path of the remote folder
1724
+ + flags: a list of the file flags to be set or cleared
1725
+ + present: whether the flags should be set or cleared
1726
+
1727
+ **Examples:**
1728
+
1729
+ .. code:: python
1730
+
1731
+ files.flags(
1732
+ name="Ensure ~/Library is visible in the GUI",
1733
+ path="~/Library",
1734
+ flags="hidden",
1735
+ present=False
1736
+ )
1737
+
1738
+ files.directory(
1739
+ name="Ensure no one can change these files",
1740
+ path="/something/very/important",
1741
+ flags=["uchg", "schg"],
1742
+ present=True,
1743
+ _sudo=True
1744
+ )
1745
+ """
1746
+ flags = flags or []
1747
+ if not isinstance(flags, list):
1748
+ flags = [flags]
1749
+
1750
+ if len(flags) == 0:
1751
+ host.noop(f"no changes requested to flags for '{path}'")
1752
+ else:
1753
+ current_set = set(host.get_fact(Flags, path=path))
1754
+ to_change = list(set(flags) - current_set) if present else list(current_set & set(flags))
1755
+
1756
+ if len(to_change) > 0:
1757
+ prefix = "" if present else "no"
1758
+ new_flags = ",".join([prefix + flag for flag in sorted(to_change)])
1759
+ yield StringCommand("chflags", new_flags, QuoteString(path))
1760
+ else:
1761
+ host.noop(
1762
+ f"'{path}' already has '{','.join(flags)}' {'set' if present else 'clear'}",
1763
+ )
1764
+
1765
+
1766
+ @operation()
1767
+ def block(
1768
+ path: str,
1769
+ content: str | list[str] | None = None,
1770
+ present=True,
1771
+ line: str | None = None,
1772
+ backup=False,
1773
+ escape_regex_characters=False,
1774
+ try_prevent_shell_expansion=False,
1775
+ before=False,
1776
+ after=False,
1777
+ marker: str | None = None,
1778
+ begin: str | None = None,
1779
+ end: str | None = None,
1780
+ ):
1781
+ """
1782
+ Ensure content, surrounded by the appropriate markers, is present (or not) in the file.
1783
+
1784
+ + path: target remote file
1785
+ + content: what should be present in the file (between markers).
1786
+ + present: whether the content should be present in the file
1787
+ + before: should the content be added before ``line`` if it doesn't exist
1788
+ + after: should the content be added after ``line`` if it doesn't exist
1789
+ + line: regex before or after which the content should be added if it doesn't exist.
1790
+ + backup: whether to backup the file (see ``files.line``). Default False.
1791
+ + escape_regex_characters: whether to escape regex characters from the matching line
1792
+ + try_prevent_shell_expansion: tries to prevent shell expanding by values like `$`
1793
+ + marker: the base string used to mark the text. Default is ``# {mark} PYINFRA BLOCK``
1794
+ + begin: the value for ``{mark}`` in the marker before the content. Default is ``BEGIN``
1795
+ + end: the value for ``{mark}`` in the marker after the content. Default is ``END``
1796
+
1797
+ Content appended if ``line`` not found in the file
1798
+ If ``content`` is not in the file but is required (``present=True``) and ``line`` is not
1799
+ found in the file, ``content`` (surrounded by markers) will be appended to the file. The
1800
+ file is created if necessary.
1801
+
1802
+ Content prepended or appended if ``line`` not specified
1803
+ If ``content`` is not in the file but is required and ``line`` was not provided the content
1804
+ will either be prepended to the file (if both ``before`` and ``after``
1805
+ are ``True``) or appended to the file (if both are ``False``).
1806
+
1807
+ If the file is created, it is created with the default umask; otherwise the umask is preserved
1808
+ as is the owner.
1809
+
1810
+ Removal ignores ``content`` and ``line``
1811
+
1812
+ Preventing shell expansion works by wrapping the content in '`' before passing to `awk`.
1813
+ WARNING: This will break if the content contains raw single quotes.
1814
+
1815
+ **Examples:**
1816
+
1817
+ .. code:: python
1818
+
1819
+ # add entry to /etc/host
1820
+ files.block(
1821
+ name="add IP address for red server",
1822
+ path="/etc/hosts",
1823
+ content="10.0.0.1 mars-one",
1824
+ before=True,
1825
+ line=".*localhost",
1826
+ )
1827
+
1828
+ # have two entries in /etc/host
1829
+ files.block(
1830
+ name="add IP address for red server",
1831
+ path="/etc/hosts",
1832
+ content="10.0.0.1 mars-one\\n10.0.0.2 mars-two",
1833
+ before=True,
1834
+ line=".*localhost",
1835
+ )
1836
+
1837
+ # remove marked entry from /etc/hosts
1838
+ files.block(
1839
+ name="remove all 10.* addresses from /etc/hosts",
1840
+ path="/etc/hosts",
1841
+ present=False
1842
+ )
1843
+
1844
+ # add out of date warning to web page
1845
+ files.block(
1846
+ name="add out of date warning to web page",
1847
+ path="/var/www/html/something.html",
1848
+ content= "<p>Warning: this page is out of date.</p>",
1849
+ line=".*<body>.*",
1850
+ after=True
1851
+ marker="<!-- {mark} PYINFRA BLOCK -->",
1852
+ )
1853
+
1854
+ # put complex alias into .zshrc
1855
+ files.block(
1856
+ path="/home/user/.zshrc",
1857
+ content="eval $(thef -a)",
1858
+ try_prevent_shell_expansion=True,
1859
+ marker="## {mark} ALIASES ##"
1860
+ )
1861
+ """
1862
+
1863
+ logger.warning("The `files.block` operation is currently in beta!")
1864
+
1865
+ mark_1 = (marker or MARKER_DEFAULT).format(mark=begin or MARKER_BEGIN_DEFAULT)
1866
+ mark_2 = (marker or MARKER_DEFAULT).format(mark=end or MARKER_END_DEFAULT)
1867
+
1868
+ current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1869
+ cmd = None
1870
+
1871
+ # Use _temp_dir global argument if provided, otherwise fall back to config
1872
+ tmp_dir = None
1873
+ if host.current_op_global_arguments:
1874
+ tmp_dir = host.current_op_global_arguments.get("_temp_dir")
1875
+ if not tmp_dir:
1876
+ tmp_dir = host.get_temp_dir_config()
1877
+
1878
+ # standard awk doesn't have an "in-place edit" option so we write to a tempfile and
1879
+ # if edits were successful move to dest i.e. we do: <out_prep> ... do some work ... <real_out>
1880
+ q_path = QuoteString(path)
1881
+ mode_get = (
1882
+ ""
1883
+ if current is None
1884
+ else (
1885
+ 'MODE="$(stat -c %a',
1886
+ q_path,
1887
+ "2>/dev/null || stat -f %Lp",
1888
+ q_path,
1889
+ '2>/dev/null)" &&',
1890
+ )
1891
+ )
1892
+ out_prep = StringCommand(
1893
+ f'OUT="$(TMPDIR={tmp_dir} mktemp -t pyinfra.XXXXXX)" && ',
1894
+ *mode_get,
1895
+ 'OWNER="$(stat -c "%u:%g"',
1896
+ q_path,
1897
+ '2>/dev/null || stat -f "%u:%g"',
1898
+ q_path,
1899
+ '2>/dev/null || echo $(id -un):$(id -gn))" &&',
1900
+ )
1901
+
1902
+ mode_change = "" if current is None else ' && chmod "$MODE"'
1903
+ real_out = StringCommand(
1904
+ ' && mv "$OUT"', q_path, ' && chown "$OWNER"', q_path, mode_change, q_path
1905
+ )
1906
+
1907
+ if backup and (current is not None): # can't back up something that doesn't exist
1908
+ out_prep = StringCommand(
1909
+ "cp",
1910
+ q_path,
1911
+ QuoteString(f"{path}.{get_timestamp()}"),
1912
+ "&&",
1913
+ out_prep,
1914
+ )
1915
+
1916
+ current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1917
+ # None means file didn't exist, empty list means marker was not found
1918
+ cmd = None
1919
+ if present:
1920
+ if not content:
1921
+ raise OperationValueError("'content' must be supplied when 'present' == True")
1922
+ if line:
1923
+ if before == after:
1924
+ raise OperationValueError(
1925
+ "only one of 'before' or 'after' used when 'line` is specified"
1926
+ )
1927
+ elif before != after:
1928
+ raise OperationValueError(
1929
+ "'line' must be supplied or 'before' and 'after' must be equal"
1930
+ )
1931
+ if isinstance(content, str):
1932
+ # convert string to list of lines
1933
+ content = content.split("\n")
1934
+
1935
+ the_block = "\n".join([mark_1, *content, mark_2])
1936
+ if try_prevent_shell_expansion:
1937
+ the_block = f"'{the_block}'"
1938
+ if any("'" in line for line in content):
1939
+ logger.warning(
1940
+ "content contains single quotes, shell expansion prevention may fail"
1941
+ )
1942
+ else:
1943
+ the_block = f'"{the_block}"'
1944
+
1945
+ if (current is None) or ((current == []) and (before == after)):
1946
+ # a) no file or b) file but no markers and we're adding at start or end.
1947
+ # here = hex(random.randint(0, 2147483647)) # not used as not testable
1948
+ here = "PYINFRAHERE"
1949
+ original = q_path if current is not None else QuoteString("/dev/null")
1950
+ cmd = StringCommand(
1951
+ out_prep,
1952
+ "(",
1953
+ "awk '{{print}}'",
1954
+ original if not before else " - ",
1955
+ original if before else " - ",
1956
+ '> "$OUT"',
1957
+ f"<<{here}\n{the_block[1:-1]}\n{here}\n",
1958
+ ")",
1959
+ real_out,
1960
+ )
1961
+ elif current == []: # markers not found and have a pattern to match (not start or end)
1962
+ if not isinstance(line, str):
1963
+ raise OperationTypeError("'line' must be a regex or a string")
1964
+ regex = adjust_regex(line, escape_regex_characters)
1965
+ print_before = "{ print }" if before else ""
1966
+ print_after = "{ print }" if after else ""
1967
+ prog = (
1968
+ 'awk \'BEGIN {x=ARGV[2]; ARGV[2]=""} '
1969
+ f"{print_after} f!=1 && /{regex}/ {{ print x; f=1}} "
1970
+ f"END {{if (f==0) print x }} {print_before}'"
1971
+ )
1972
+ cmd = StringCommand(
1973
+ out_prep,
1974
+ prog,
1975
+ q_path,
1976
+ the_block,
1977
+ '> "$OUT"',
1978
+ real_out,
1979
+ )
1980
+ else:
1981
+ if (len(current) != len(content)) or (
1982
+ not all(lines[0] == lines[1] for lines in zip(content, current, strict=True))
1983
+ ): # marked_block found but text is different
1984
+ prog = (
1985
+ 'awk \'BEGIN {{f=1; x=ARGV[2]; ARGV[2]=""}}'
1986
+ f"/{mark_1}/ {{print; print x; f=0}} /{mark_2}/ {{print; f=1; next}} f'"
1987
+ )
1988
+ cmd = StringCommand(
1989
+ out_prep,
1990
+ prog,
1991
+ q_path,
1992
+ (
1993
+ '"' + "\n".join(content) + '"'
1994
+ if not try_prevent_shell_expansion
1995
+ else "'" + "\n".join(content) + "'"
1996
+ ),
1997
+ '> "$OUT"',
1998
+ real_out,
1999
+ )
2000
+ else:
2001
+ host.noop("content already present")
2002
+
2003
+ if cmd:
2004
+ yield cmd
2005
+ else: # remove the marked_block
2006
+ if content:
2007
+ logger.warning("'content' ignored when removing a marked_block")
2008
+ if current is None:
2009
+ host.noop("no remove required: file did not exist")
2010
+ elif current == []:
2011
+ host.noop("no remove required: markers not found")
2012
+ else:
2013
+ cmd = StringCommand(f"awk '/{mark_1}/,/{mark_2}/ {{next}} 1'")
2014
+ yield StringCommand(out_prep, cmd, q_path, "> $OUT", real_out)