pyinfra 3.5.1__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.
- pyinfra/api/__init__.py +1 -0
- pyinfra/api/arguments.py +7 -0
- pyinfra/api/exceptions.py +6 -0
- pyinfra/api/facts.py +17 -1
- pyinfra/api/host.py +3 -0
- pyinfra/api/metadata.py +69 -0
- pyinfra/api/operations.py +3 -3
- pyinfra/api/util.py +22 -5
- pyinfra/connectors/docker.py +25 -1
- pyinfra/connectors/ssh.py +57 -0
- pyinfra/connectors/util.py +16 -9
- pyinfra/facts/crontab.py +7 -7
- pyinfra/facts/files.py +1 -2
- pyinfra/facts/npm.py +1 -1
- pyinfra/facts/server.py +18 -2
- pyinfra/operations/apk.py +2 -1
- pyinfra/operations/apt.py +15 -7
- pyinfra/operations/brew.py +1 -0
- pyinfra/operations/crontab.py +4 -1
- pyinfra/operations/dnf.py +4 -1
- pyinfra/operations/docker.py +62 -16
- pyinfra/operations/files.py +87 -12
- pyinfra/operations/flatpak.py +1 -0
- pyinfra/operations/gem.py +1 -0
- pyinfra/operations/git.py +1 -0
- pyinfra/operations/iptables.py +1 -0
- pyinfra/operations/lxd.py +1 -0
- pyinfra/operations/mysql.py +1 -0
- pyinfra/operations/opkg.py +2 -1
- pyinfra/operations/pacman.py +1 -0
- pyinfra/operations/pip.py +1 -0
- pyinfra/operations/pipx.py +1 -0
- pyinfra/operations/pkg.py +1 -0
- pyinfra/operations/pkgin.py +1 -0
- pyinfra/operations/postgres.py +1 -0
- pyinfra/operations/puppet.py +1 -0
- pyinfra/operations/python.py +1 -0
- pyinfra/operations/selinux.py +1 -0
- pyinfra/operations/server.py +1 -0
- pyinfra/operations/snap.py +2 -1
- pyinfra/operations/ssh.py +1 -0
- pyinfra/operations/systemd.py +1 -0
- pyinfra/operations/sysvinit.py +2 -1
- pyinfra/operations/util/docker.py +164 -8
- pyinfra/operations/util/packaging.py +2 -0
- pyinfra/operations/xbps.py +1 -0
- pyinfra/operations/yum.py +4 -1
- pyinfra/operations/zfs.py +1 -0
- pyinfra/operations/zypper.py +1 -0
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/METADATA +2 -1
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/RECORD +55 -54
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -1
- pyinfra_cli/cli.py +13 -0
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/entry_points.txt +0 -0
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.dist-info}/licenses/LICENSE.md +0 -0
pyinfra/api/__init__.py
CHANGED
pyinfra/api/arguments.py
CHANGED
|
@@ -77,6 +77,9 @@ class ConnectorArguments(TypedDict, total=False):
|
|
|
77
77
|
_retry_delay: Union[int, float]
|
|
78
78
|
_retry_until: Optional[Callable[[dict], bool]]
|
|
79
79
|
|
|
80
|
+
# Temp directory argument
|
|
81
|
+
_temp_dir: str
|
|
82
|
+
|
|
80
83
|
|
|
81
84
|
def generate_env(config: "Config", value: dict) -> dict:
|
|
82
85
|
env = config.ENV.copy()
|
|
@@ -163,6 +166,10 @@ shell_argument_meta: dict[str, ArgumentMeta] = {
|
|
|
163
166
|
"String or buffer to send to the stdin of any commands.",
|
|
164
167
|
default=lambda _: None,
|
|
165
168
|
),
|
|
169
|
+
"_temp_dir": ArgumentMeta(
|
|
170
|
+
"Temporary directory on the remote host for file operations.",
|
|
171
|
+
default=lambda config: config.TEMP_DIR,
|
|
172
|
+
),
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
|
pyinfra/api/exceptions.py
CHANGED
|
@@ -29,6 +29,12 @@ class FactValueError(FactError, ValueError):
|
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
class FactProcessError(FactError, RuntimeError):
|
|
33
|
+
"""
|
|
34
|
+
Exception raised when the data gathered for a fact cannot be processed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
32
38
|
class OperationError(PyinfraError):
|
|
33
39
|
"""
|
|
34
40
|
Exception raised during fact gathering staging if an operation is unable to
|
pyinfra/api/facts.py
CHANGED
|
@@ -24,6 +24,7 @@ from typing_extensions import override
|
|
|
24
24
|
from pyinfra import logger
|
|
25
25
|
from pyinfra.api import StringCommand
|
|
26
26
|
from pyinfra.api.arguments import all_global_arguments, pop_global_arguments
|
|
27
|
+
from pyinfra.api.exceptions import FactProcessError
|
|
27
28
|
from pyinfra.api.util import (
|
|
28
29
|
get_kwargs_str,
|
|
29
30
|
log_error_or_warning,
|
|
@@ -269,7 +270,22 @@ def _get_fact(
|
|
|
269
270
|
|
|
270
271
|
if status:
|
|
271
272
|
if stdout_lines:
|
|
272
|
-
|
|
273
|
+
try:
|
|
274
|
+
data = fact.process(stdout_lines)
|
|
275
|
+
except FactProcessError as e:
|
|
276
|
+
log_error_or_warning(
|
|
277
|
+
host,
|
|
278
|
+
global_kwargs["_ignore_errors"],
|
|
279
|
+
description=("could not process fact: {0} {1}").format(
|
|
280
|
+
name, get_kwargs_str(fact_kwargs)
|
|
281
|
+
),
|
|
282
|
+
exception=e,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Check we've not failed
|
|
286
|
+
if apply_failed_hosts and not global_kwargs["_ignore_errors"]:
|
|
287
|
+
state.fail_hosts({host})
|
|
288
|
+
|
|
273
289
|
elif stderr_lines:
|
|
274
290
|
# If we have error output and that error is sudo or su stating the user
|
|
275
291
|
# does not exist, do not fail but instead return the default fact value.
|
pyinfra/api/host.py
CHANGED
pyinfra/api/metadata.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Support parsing pyinfra-metadata.toml
|
|
3
|
+
|
|
4
|
+
Currently just parses plugins and their metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from typing import Literal, get_args
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, TypeAdapter, field_validator
|
|
11
|
+
|
|
12
|
+
AllowedTagType = Literal[
|
|
13
|
+
"boot",
|
|
14
|
+
"containers",
|
|
15
|
+
"database",
|
|
16
|
+
"service-management",
|
|
17
|
+
"package-manager",
|
|
18
|
+
"python",
|
|
19
|
+
"ruby",
|
|
20
|
+
"javascript",
|
|
21
|
+
"configuration-management",
|
|
22
|
+
"security",
|
|
23
|
+
"storage",
|
|
24
|
+
"system",
|
|
25
|
+
"rust",
|
|
26
|
+
"version-control-system",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Tag(BaseModel):
|
|
31
|
+
"""Representation of a plugin tag."""
|
|
32
|
+
|
|
33
|
+
value: AllowedTagType
|
|
34
|
+
|
|
35
|
+
@field_validator("value", mode="before")
|
|
36
|
+
def _validate_value(cls, v) -> AllowedTagType:
|
|
37
|
+
allowed_tags = set(get_args(AllowedTagType))
|
|
38
|
+
if v not in allowed_tags:
|
|
39
|
+
raise ValueError(f"Invalid tag: {v}. Allowed: {allowed_tags}")
|
|
40
|
+
return v
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def title_case(self) -> str:
|
|
44
|
+
return " ".join([t.title() for t in self.value.split("-")])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ALLOWED_TAGS = [Tag(value=tag) for tag in set(get_args(AllowedTagType))]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Plugin(BaseModel):
|
|
51
|
+
"""Representation of a pyinfra plugin."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
# description: str # FUTURE we should grab these from doc strings
|
|
55
|
+
path: str
|
|
56
|
+
type: Literal["operation", "fact", "connector", "deploy"]
|
|
57
|
+
tags: list[Tag]
|
|
58
|
+
|
|
59
|
+
@field_validator("tags", mode="before")
|
|
60
|
+
def _wrap_tags(cls, v):
|
|
61
|
+
return [Tag(value=tag) if not isinstance(tag, Tag) else tag for tag in v]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_plugins(metadata_text: str) -> list[Plugin]:
|
|
65
|
+
"""Given the contents of a pyinfra-metadata.toml parse out the plugins."""
|
|
66
|
+
pyinfra_metadata = tomllib.loads(metadata_text).get("pyinfra", None)
|
|
67
|
+
if not pyinfra_metadata:
|
|
68
|
+
raise ValueError("Missing [pyinfra.plugins] section in pyinfra-metadata.toml")
|
|
69
|
+
return TypeAdapter(list[Plugin]).validate_python(pyinfra_metadata["plugins"].values())
|
pyinfra/api/operations.py
CHANGED
|
@@ -194,11 +194,11 @@ def _run_host_op(state: "State", host: "Host", op_hash: str) -> Optional[bool]:
|
|
|
194
194
|
host_results.ops += 1
|
|
195
195
|
host_results.success_ops += 1
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
_status_text = "Success" if executed_commands > 0 else "No changes"
|
|
198
198
|
if retry_attempt > 0:
|
|
199
|
-
|
|
199
|
+
_status_text = f"{_status_text} on retry {retry_attempt}"
|
|
200
200
|
|
|
201
|
-
_click_log_status = click.style(
|
|
201
|
+
_click_log_status = click.style(_status_text, "green" if executed_commands > 0 else "cyan")
|
|
202
202
|
logger.info("{0}{1}".format(host.print_prefix, _click_log_status))
|
|
203
203
|
|
|
204
204
|
state.trigger_callbacks("operation_host_success", host, op_hash, retry_attempt)
|
pyinfra/api/util.py
CHANGED
|
@@ -10,7 +10,7 @@ from socket import error as socket_error, timeout as timeout_error
|
|
|
10
10
|
from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union
|
|
11
11
|
|
|
12
12
|
import click
|
|
13
|
-
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
13
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Template
|
|
14
14
|
from paramiko import SSHException
|
|
15
15
|
from typeguard import TypeCheckError, check_type
|
|
16
16
|
|
|
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|
|
26
26
|
BLOCKSIZE = 65536
|
|
27
27
|
|
|
28
28
|
# Caches
|
|
29
|
-
TEMPLATES: Dict[
|
|
29
|
+
TEMPLATES: Dict[str, Template] = {}
|
|
30
30
|
FILE_SHAS: Dict[Any, Any] = {}
|
|
31
31
|
|
|
32
32
|
PYINFRA_INSTALL_DIR = path.normpath(path.join(path.dirname(__file__), ".."))
|
|
@@ -139,7 +139,9 @@ def get_operation_order_from_stack(state: "State"):
|
|
|
139
139
|
return line_numbers
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
def get_template(
|
|
142
|
+
def get_template(
|
|
143
|
+
filename_or_io: str | IO, jinja_env_kwargs: dict[str, Any] | None = None
|
|
144
|
+
) -> Template:
|
|
143
145
|
"""
|
|
144
146
|
Gets a jinja2 ``Template`` object for the input filename or string, with caching
|
|
145
147
|
based on the filename of the template, or the SHA1 of the input string.
|
|
@@ -155,10 +157,11 @@ def get_template(filename_or_io: str | IO, jinja_env_kwargs: dict[str, Any] | No
|
|
|
155
157
|
with file_data as file_io:
|
|
156
158
|
template_string = file_io.read()
|
|
157
159
|
|
|
160
|
+
default_loader = FileSystemLoader(getcwd())
|
|
158
161
|
template = Environment(
|
|
159
162
|
undefined=StrictUndefined,
|
|
160
163
|
keep_trailing_newline=True,
|
|
161
|
-
loader=
|
|
164
|
+
loader=jinja_env_kwargs.pop("loader", default_loader),
|
|
162
165
|
**jinja_env_kwargs,
|
|
163
166
|
).from_string(template_string)
|
|
164
167
|
|
|
@@ -219,7 +222,11 @@ def log_operation_start(
|
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
def log_error_or_warning(
|
|
222
|
-
host: "Host",
|
|
225
|
+
host: "Host",
|
|
226
|
+
ignore_errors: bool,
|
|
227
|
+
description: str = "",
|
|
228
|
+
continue_on_error: bool = False,
|
|
229
|
+
exception: Exception | None = None,
|
|
223
230
|
) -> None:
|
|
224
231
|
log_func = logger.error
|
|
225
232
|
log_color = "red"
|
|
@@ -234,6 +241,16 @@ def log_error_or_warning(
|
|
|
234
241
|
if description:
|
|
235
242
|
log_text = f"{log_text}: "
|
|
236
243
|
|
|
244
|
+
if exception:
|
|
245
|
+
exc = exception.__cause__ or exception
|
|
246
|
+
exc_text = "{0}: {1}".format(type(exc).__name__, exc)
|
|
247
|
+
log_func(
|
|
248
|
+
"{0}{1}".format(
|
|
249
|
+
host.print_prefix,
|
|
250
|
+
click.style(exc_text, log_color),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
237
254
|
log_func(
|
|
238
255
|
"{0}{1}{2}".format(
|
|
239
256
|
host.print_prefix,
|
pyinfra/connectors/docker.py
CHANGED
|
@@ -26,10 +26,14 @@ if TYPE_CHECKING:
|
|
|
26
26
|
|
|
27
27
|
class ConnectorData(TypedDict):
|
|
28
28
|
docker_identifier: str
|
|
29
|
+
docker_platform: str
|
|
30
|
+
docker_architecture: str
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
connector_data_meta: dict[str, DataMeta] = {
|
|
32
34
|
"docker_identifier": DataMeta("ID of container or image to start from"),
|
|
35
|
+
"docker_platform": DataMeta("Platform to use for Docker image (e.g., linux/amd64)"),
|
|
36
|
+
"docker_architecture": DataMeta("Architecture to use for Docker image (e.g., amd64, arm64)"),
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
|
|
@@ -108,9 +112,29 @@ class DockerConnector(BaseConnector):
|
|
|
108
112
|
return container_id, True
|
|
109
113
|
|
|
110
114
|
def _start_docker_image(self, image_name):
|
|
115
|
+
docker_cmd_parts = [
|
|
116
|
+
self.docker_cmd,
|
|
117
|
+
"run",
|
|
118
|
+
"-d",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
if self.data.get("docker_platform"):
|
|
122
|
+
docker_cmd_parts.extend(["--platform", self.data["docker_platform"]])
|
|
123
|
+
if self.data.get("docker_architecture"):
|
|
124
|
+
docker_cmd_parts.extend(["--arch", self.data["docker_architecture"]])
|
|
125
|
+
|
|
126
|
+
docker_cmd_parts.extend(
|
|
127
|
+
[
|
|
128
|
+
image_name,
|
|
129
|
+
"tail",
|
|
130
|
+
"-f",
|
|
131
|
+
"/dev/null",
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
|
|
111
135
|
try:
|
|
112
136
|
return local.shell(
|
|
113
|
-
|
|
137
|
+
" ".join(docker_cmd_parts),
|
|
114
138
|
splitlines=True,
|
|
115
139
|
)[-1] # last line is the container ID
|
|
116
140
|
except PyinfraError as e:
|
pyinfra/connectors/ssh.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import IO, TYPE_CHECKING, Any, Iterable, Optional, Protocol, Tuple
|
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
11
|
from paramiko import AuthenticationException, BadHostKeyException, SFTPClient, SSHException
|
|
12
|
+
from paramiko.agent import Agent
|
|
12
13
|
from typing_extensions import TypedDict, Unpack, override
|
|
13
14
|
|
|
14
15
|
from pyinfra import logger
|
|
@@ -286,10 +287,64 @@ class SSHConnector(BaseConnector):
|
|
|
286
287
|
f"Host key for {e.hostname} does not match.",
|
|
287
288
|
)
|
|
288
289
|
|
|
290
|
+
except SSHException as e:
|
|
291
|
+
if self._retry_paramiko_agent_keys(hostname, kwargs, e):
|
|
292
|
+
return
|
|
293
|
+
raise
|
|
294
|
+
|
|
289
295
|
@override
|
|
290
296
|
def disconnect(self) -> None:
|
|
291
297
|
self.get_file_transfer_connection.cache.clear()
|
|
292
298
|
|
|
299
|
+
def _retry_paramiko_agent_keys(
|
|
300
|
+
self,
|
|
301
|
+
hostname: str,
|
|
302
|
+
kwargs: dict[str, Any],
|
|
303
|
+
error: SSHException,
|
|
304
|
+
) -> bool:
|
|
305
|
+
# Workaround for Paramiko multi-key bug (paramiko/paramiko#1390).
|
|
306
|
+
if "no existing session" not in str(error).lower():
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
if not kwargs.get("allow_agent"):
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
agent_keys = list(Agent().get_keys())
|
|
314
|
+
except Exception:
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
if not agent_keys:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
# Skip the first agent key, since Paramiko already attempted it
|
|
321
|
+
attempt_keys = agent_keys[1:] if len(agent_keys) > 1 else agent_keys
|
|
322
|
+
|
|
323
|
+
for agent_key in attempt_keys:
|
|
324
|
+
if self.client is not None:
|
|
325
|
+
try:
|
|
326
|
+
self.client.close()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
self.client = SSHClient()
|
|
331
|
+
|
|
332
|
+
single_key_kwargs = dict(kwargs)
|
|
333
|
+
single_key_kwargs["allow_agent"] = False
|
|
334
|
+
single_key_kwargs["pkey"] = agent_key
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
self.client.connect(hostname, **single_key_kwargs)
|
|
338
|
+
return True
|
|
339
|
+
except AuthenticationException:
|
|
340
|
+
continue
|
|
341
|
+
except SSHException as retry_error:
|
|
342
|
+
if "no existing session" in str(retry_error).lower():
|
|
343
|
+
continue
|
|
344
|
+
raise retry_error
|
|
345
|
+
|
|
346
|
+
return False
|
|
347
|
+
|
|
293
348
|
@override
|
|
294
349
|
def run_shell_command(
|
|
295
350
|
self,
|
|
@@ -342,8 +397,10 @@ class SSHConnector(BaseConnector):
|
|
|
342
397
|
get_pty=_get_pty,
|
|
343
398
|
)
|
|
344
399
|
|
|
400
|
+
# Write any stdin and then close it
|
|
345
401
|
if _stdin:
|
|
346
402
|
write_stdin(_stdin, stdin_buffer)
|
|
403
|
+
stdin_buffer.close()
|
|
347
404
|
|
|
348
405
|
combined_output = read_output_buffers(
|
|
349
406
|
stdout_buffer,
|
pyinfra/connectors/util.py
CHANGED
|
@@ -22,17 +22,17 @@ if TYPE_CHECKING:
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
SUDO_ASKPASS_ENV_VAR = "PYINFRA_SUDO_PASSWORD"
|
|
25
|
+
|
|
26
|
+
|
|
25
27
|
SUDO_ASKPASS_COMMAND = r"""
|
|
26
|
-
temp=$(mktemp "${{TMPDIR
|
|
28
|
+
temp=$(mktemp "${{TMPDIR:={0}}}/pyinfra-sudo-askpass-XXXXXXXXXXXX")
|
|
27
29
|
cat >"$temp"<<'__EOF__'
|
|
28
30
|
#!/bin/sh
|
|
29
|
-
printf '%s\n' "${
|
|
31
|
+
printf '%s\n' "${1}"
|
|
30
32
|
__EOF__
|
|
31
33
|
chmod 755 "$temp"
|
|
32
34
|
echo "$temp"
|
|
33
|
-
"""
|
|
34
|
-
SUDO_ASKPASS_ENV_VAR,
|
|
35
|
-
)
|
|
35
|
+
"""
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def run_local_process(
|
|
@@ -44,11 +44,14 @@ def run_local_process(
|
|
|
44
44
|
) -> tuple[int, "CommandOutput"]:
|
|
45
45
|
process = Popen(command, shell=True, stdout=PIPE, stderr=PIPE, stdin=PIPE)
|
|
46
46
|
|
|
47
|
-
if stdin:
|
|
48
|
-
write_stdin(stdin, process.stdin)
|
|
49
|
-
|
|
50
47
|
assert process.stdout is not None
|
|
51
48
|
assert process.stderr is not None
|
|
49
|
+
assert process.stdin is not None
|
|
50
|
+
|
|
51
|
+
# Write any stdin and then close it
|
|
52
|
+
if stdin:
|
|
53
|
+
write_stdin(stdin, process.stdin)
|
|
54
|
+
process.stdin.close()
|
|
52
55
|
|
|
53
56
|
combined_output = read_output_buffers(
|
|
54
57
|
process.stdout,
|
|
@@ -264,7 +267,9 @@ def extract_control_arguments(arguments: "ConnectorArguments") -> "ConnectorArgu
|
|
|
264
267
|
def _ensure_sudo_askpass_set_for_host(host: "Host"):
|
|
265
268
|
if host.connector_data.get("sudo_askpass_path"):
|
|
266
269
|
return
|
|
267
|
-
_, output = host.run_shell_command(
|
|
270
|
+
_, output = host.run_shell_command(
|
|
271
|
+
SUDO_ASKPASS_COMMAND.format(host.get_temp_dir_config(), SUDO_ASKPASS_ENV_VAR)
|
|
272
|
+
)
|
|
268
273
|
host.connector_data["sudo_askpass_path"] = shlex.quote(output.stdout_lines[0])
|
|
269
274
|
|
|
270
275
|
|
|
@@ -318,6 +323,8 @@ def make_unix_command(
|
|
|
318
323
|
_retries=0,
|
|
319
324
|
_retry_delay=0,
|
|
320
325
|
_retry_until=None,
|
|
326
|
+
# Temp dir config (ignored in command generation, used for temp file path generation)
|
|
327
|
+
_temp_dir=None,
|
|
321
328
|
) -> StringCommand:
|
|
322
329
|
"""
|
|
323
330
|
Builds a shell command with various kwargs.
|
pyinfra/facts/crontab.py
CHANGED
|
@@ -125,15 +125,15 @@ class Crontab(FactBase[CrontabFile]):
|
|
|
125
125
|
# or CrontabFile.to_json()
|
|
126
126
|
[
|
|
127
127
|
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
"command": "/path/to/command",
|
|
129
|
+
"minute": "*",
|
|
130
|
+
"hour": "*",
|
|
131
|
+
"month": "*",
|
|
132
|
+
"day_of_month": "*",
|
|
133
|
+
"day_of_week": "*",
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
"command": "echo another command
|
|
136
|
+
"command": "echo another command",
|
|
137
137
|
"special_time": "@daily",
|
|
138
138
|
}
|
|
139
139
|
]
|
pyinfra/facts/files.py
CHANGED
|
@@ -664,8 +664,7 @@ class Block(FactBase):
|
|
|
664
664
|
|
|
665
665
|
class FileContents(FactBase):
|
|
666
666
|
"""
|
|
667
|
-
Returns the contents of a file as a list of lines.
|
|
668
|
-
``None`` if the file doest not exist.
|
|
667
|
+
Returns the contents of a file as a list of lines. Returns ``None`` if the file does not exist.
|
|
669
668
|
"""
|
|
670
669
|
|
|
671
670
|
@override
|
pyinfra/facts/npm.py
CHANGED
|
@@ -30,7 +30,7 @@ class NpmPackages(FactBase):
|
|
|
30
30
|
@override
|
|
31
31
|
def command(self, directory=None):
|
|
32
32
|
if directory:
|
|
33
|
-
return ("cd {0} && npm list -g --depth=0").format(directory)
|
|
33
|
+
return ("! test -d {0} || (cd {0} && npm list -g --depth=0)").format(directory)
|
|
34
34
|
return "npm list -g --depth=0"
|
|
35
35
|
|
|
36
36
|
@override
|
pyinfra/facts/server.py
CHANGED
|
@@ -52,12 +52,28 @@ class Path(FactBase):
|
|
|
52
52
|
|
|
53
53
|
class TmpDir(FactBase):
|
|
54
54
|
"""
|
|
55
|
-
Returns the temporary directory of the current server
|
|
55
|
+
Returns the temporary directory of the current server.
|
|
56
|
+
|
|
57
|
+
According to POSIX standards, checks environment variables in this order:
|
|
58
|
+
1. TMPDIR (if set and accessible)
|
|
59
|
+
2. TMP (if set and accessible)
|
|
60
|
+
3. TEMP (if set and accessible)
|
|
61
|
+
4. Falls back to empty string
|
|
56
62
|
"""
|
|
57
63
|
|
|
58
64
|
@override
|
|
59
65
|
def command(self):
|
|
60
|
-
return "
|
|
66
|
+
return """
|
|
67
|
+
if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ] && [ -w "$TMPDIR" ]; then
|
|
68
|
+
echo "$TMPDIR"
|
|
69
|
+
elif [ -n "$TMP" ] && [ -d "$TMP" ] && [ -w "$TMP" ]; then
|
|
70
|
+
echo "$TMP"
|
|
71
|
+
elif [ -n "$TEMP" ] && [ -d "$TEMP" ] && [ -w "$TEMP" ]; then
|
|
72
|
+
echo "$TEMP"
|
|
73
|
+
else
|
|
74
|
+
echo ""
|
|
75
|
+
fi
|
|
76
|
+
""".strip()
|
|
61
77
|
|
|
62
78
|
|
|
63
79
|
class Hostname(FactBase):
|
pyinfra/operations/apk.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Manage apk packages.
|
|
2
|
+
Manage apk packages. (Alpine Linux)
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
@@ -64,6 +64,7 @@ def packages(
|
|
|
64
64
|
|
|
65
65
|
.. code:: python
|
|
66
66
|
|
|
67
|
+
from pyinfra.operations import apk
|
|
67
68
|
# Update package list and install packages
|
|
68
69
|
apk.packages(
|
|
69
70
|
name="Install Asterisk and Vim",
|
pyinfra/operations/apt.py
CHANGED
|
@@ -4,7 +4,7 @@ Manage apt packages and repositories.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from datetime import timedelta
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
8
|
from urllib.parse import urlparse
|
|
9
9
|
|
|
10
10
|
from pyinfra import host
|
|
@@ -67,6 +67,7 @@ def key(src: str | None = None, keyserver: str | None = None, keyid: str | list[
|
|
|
67
67
|
|
|
68
68
|
.. code:: python
|
|
69
69
|
|
|
70
|
+
from pyinfra.operations import apt
|
|
70
71
|
# Note: If using URL, wget is assumed to be installed.
|
|
71
72
|
apt.key(
|
|
72
73
|
name="Add the Docker apt gpg key",
|
|
@@ -309,12 +310,19 @@ def update(cache_time: int | None = None):
|
|
|
309
310
|
# Ubuntu provides this handy file
|
|
310
311
|
cache_info = host.get_fact(File, path=APT_UPDATE_FILENAME)
|
|
311
312
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
if cache_info and cache_info["mtime"]:
|
|
314
|
+
# The fact Date contains the date of the server in its timezone.
|
|
315
|
+
# cache_info["mtime"] ignores the timezone and consider the timestamp as UTC.
|
|
316
|
+
# So let's do the same here for the server current Date : ignore the
|
|
317
|
+
# timezone and consider it as UTC to have correct comparison with
|
|
318
|
+
# cache_info["mtime].
|
|
319
|
+
host_utc_current_time = datetime.fromtimestamp(
|
|
320
|
+
host.get_fact(Date).timestamp(), timezone.utc
|
|
321
|
+
).replace(tzinfo=None)
|
|
322
|
+
host_cache_time = host_utc_current_time - timedelta(seconds=cache_time)
|
|
323
|
+
if cache_info["mtime"] > host_cache_time:
|
|
324
|
+
host.noop("apt is already up to date")
|
|
325
|
+
return
|
|
318
326
|
|
|
319
327
|
yield "apt-get update"
|
|
320
328
|
|
pyinfra/operations/brew.py
CHANGED
pyinfra/operations/crontab.py
CHANGED
|
@@ -54,6 +54,7 @@ def crontab(
|
|
|
54
54
|
|
|
55
55
|
.. code:: python
|
|
56
56
|
|
|
57
|
+
from pyinfra.operations import crontab
|
|
57
58
|
# simple example for a crontab
|
|
58
59
|
crontab.crontab(
|
|
59
60
|
name="Backup /etc weekly",
|
|
@@ -84,7 +85,9 @@ def crontab(
|
|
|
84
85
|
ctb = ctb0
|
|
85
86
|
name_comment = "# pyinfra-name={0}".format(cron_name)
|
|
86
87
|
|
|
87
|
-
existing_crontab = ctb.get_command(
|
|
88
|
+
existing_crontab = ctb.get_command(
|
|
89
|
+
command=command if cron_name is None else None, name=cron_name
|
|
90
|
+
)
|
|
88
91
|
existing_crontab_command = existing_crontab["command"] if existing_crontab else command
|
|
89
92
|
existing_crontab_match = existing_crontab["command"] if existing_crontab else command
|
|
90
93
|
|
pyinfra/operations/dnf.py
CHANGED
|
@@ -25,9 +25,12 @@ def key(src: str):
|
|
|
25
25
|
|
|
26
26
|
.. code:: python
|
|
27
27
|
|
|
28
|
+
from pyinfra import host
|
|
29
|
+
from pyinfra.operations import dnf
|
|
30
|
+
from pyinfra.facts.server import LinuxDistribution
|
|
28
31
|
linux_id = host.get_fact(LinuxDistribution)["release_meta"].get("ID")
|
|
29
32
|
dnf.key(
|
|
30
|
-
name="Add the Docker
|
|
33
|
+
name="Add the Docker gpg key",
|
|
31
34
|
src=f"https://download.docker.com/linux/{linux_id}/gpg",
|
|
32
35
|
)
|
|
33
36
|
|