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