pyinfra 3.5.1__py3-none-any.whl → 3.6.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/api/__init__.py +1 -0
- pyinfra/api/arguments.py +9 -2
- pyinfra/api/arguments_typed.py +18 -23
- pyinfra/api/command.py +9 -3
- pyinfra/api/deploy.py +1 -1
- pyinfra/api/exceptions.py +12 -0
- pyinfra/api/facts.py +20 -4
- pyinfra/api/host.py +3 -0
- pyinfra/api/inventory.py +2 -2
- pyinfra/api/metadata.py +69 -0
- pyinfra/api/operation.py +9 -4
- pyinfra/api/operations.py +16 -14
- pyinfra/api/util.py +22 -5
- pyinfra/connectors/docker.py +25 -1
- pyinfra/connectors/ssh.py +57 -0
- pyinfra/connectors/sshuserclient/client.py +47 -28
- pyinfra/connectors/util.py +16 -9
- pyinfra/facts/crontab.py +10 -8
- pyinfra/facts/files.py +12 -3
- pyinfra/facts/flatpak.py +1 -1
- 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 +70 -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 +7 -1
- 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 +172 -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.1.dist-info}/METADATA +2 -1
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/RECORD +64 -63
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/WHEEL +1 -1
- pyinfra_cli/cli.py +20 -4
- pyinfra_cli/inventory.py +26 -1
- pyinfra_cli/util.py +1 -1
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/entry_points.txt +0 -0
- {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/licenses/LICENSE.md +0 -0
pyinfra/operations/files.py
CHANGED
|
@@ -117,6 +117,7 @@ def download(
|
|
|
117
117
|
|
|
118
118
|
.. code:: python
|
|
119
119
|
|
|
120
|
+
from pyinfra.operations import files
|
|
120
121
|
files.download(
|
|
121
122
|
name="Download the Docker repo file",
|
|
122
123
|
src="https://download.docker.com/linux/centos/docker-ce.repo",
|
|
@@ -167,8 +168,12 @@ def download(
|
|
|
167
168
|
|
|
168
169
|
# If we download, always do user/group/mode as SSH user may be different
|
|
169
170
|
if download:
|
|
171
|
+
# Use explicit temp_dir if provided, otherwise fall back to _temp_dir global argument
|
|
172
|
+
effective_temp_dir = temp_dir
|
|
173
|
+
if effective_temp_dir is None and host.current_op_global_arguments:
|
|
174
|
+
effective_temp_dir = host.current_op_global_arguments.get("_temp_dir")
|
|
170
175
|
temp_file = host.get_temp_filename(
|
|
171
|
-
dest, temp_directory=str(
|
|
176
|
+
dest, temp_directory=str(effective_temp_dir) if effective_temp_dir is not None else None
|
|
172
177
|
)
|
|
173
178
|
|
|
174
179
|
curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
|
|
@@ -751,6 +756,15 @@ def _file_equal(local_path: str | IO[Any] | None, remote_path: str) -> bool:
|
|
|
751
756
|
return False
|
|
752
757
|
|
|
753
758
|
|
|
759
|
+
def _remote_file_equal(remote_path_a: str, remote_path_b: str) -> bool:
|
|
760
|
+
for fact in [Sha1File, Md5File, Sha256File]:
|
|
761
|
+
sum_a = host.get_fact(fact, path=remote_path_a)
|
|
762
|
+
sum_b = host.get_fact(fact, path=remote_path_b)
|
|
763
|
+
if sum_a and sum_b:
|
|
764
|
+
return sum_a == sum_b
|
|
765
|
+
return False
|
|
766
|
+
|
|
767
|
+
|
|
754
768
|
@operation(
|
|
755
769
|
# We don't (currently) cache the local state, so there's nothing we can
|
|
756
770
|
# update to flag the local file as present.
|
|
@@ -797,19 +811,32 @@ def get(
|
|
|
797
811
|
|
|
798
812
|
remote_file = host.get_fact(File, path=src)
|
|
799
813
|
|
|
814
|
+
# Use _temp_dir global argument if provided
|
|
815
|
+
temp_dir = None
|
|
816
|
+
if host.current_op_global_arguments:
|
|
817
|
+
temp_dir = host.current_op_global_arguments.get("_temp_dir")
|
|
818
|
+
|
|
800
819
|
# No remote file, so assume exists and download it "blind"
|
|
801
820
|
if not remote_file or force:
|
|
802
|
-
yield FileDownloadCommand(
|
|
821
|
+
yield FileDownloadCommand(
|
|
822
|
+
src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
|
|
823
|
+
)
|
|
803
824
|
|
|
804
825
|
# No local file, so always download
|
|
805
826
|
elif not os.path.exists(dest):
|
|
806
|
-
yield FileDownloadCommand(
|
|
827
|
+
yield FileDownloadCommand(
|
|
828
|
+
src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
|
|
829
|
+
)
|
|
807
830
|
|
|
808
831
|
# Remote file exists - check if it matches our local
|
|
809
832
|
else:
|
|
810
833
|
# Check hash sum, download if needed
|
|
811
834
|
if not _file_equal(dest, src):
|
|
812
|
-
yield FileDownloadCommand(
|
|
835
|
+
yield FileDownloadCommand(
|
|
836
|
+
src,
|
|
837
|
+
dest,
|
|
838
|
+
remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
|
|
839
|
+
)
|
|
813
840
|
else:
|
|
814
841
|
host.noop("file {0} has already been downloaded".format(dest))
|
|
815
842
|
|
|
@@ -998,6 +1025,11 @@ def put(
|
|
|
998
1025
|
if create_remote_dir:
|
|
999
1026
|
yield from _create_remote_dir(dest, user, group)
|
|
1000
1027
|
|
|
1028
|
+
# Use _temp_dir global argument if provided
|
|
1029
|
+
temp_dir = None
|
|
1030
|
+
if host.current_op_global_arguments:
|
|
1031
|
+
temp_dir = host.current_op_global_arguments.get("_temp_dir")
|
|
1032
|
+
|
|
1001
1033
|
# No remote file, always upload and user/group/mode if supplied
|
|
1002
1034
|
if not remote_file or force:
|
|
1003
1035
|
if state.config.DIFF:
|
|
@@ -1013,7 +1045,7 @@ def put(
|
|
|
1013
1045
|
yield FileUploadCommand(
|
|
1014
1046
|
local_file,
|
|
1015
1047
|
dest,
|
|
1016
|
-
remote_temp_filename=host.get_temp_filename(dest),
|
|
1048
|
+
remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
|
|
1017
1049
|
)
|
|
1018
1050
|
|
|
1019
1051
|
if user or group:
|
|
@@ -1060,7 +1092,7 @@ def put(
|
|
|
1060
1092
|
yield FileUploadCommand(
|
|
1061
1093
|
local_file,
|
|
1062
1094
|
dest,
|
|
1063
|
-
remote_temp_filename=host.get_temp_filename(dest),
|
|
1095
|
+
remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
|
|
1064
1096
|
)
|
|
1065
1097
|
|
|
1066
1098
|
if user or group:
|
|
@@ -1156,11 +1188,14 @@ def template(
|
|
|
1156
1188
|
if not set explicitly.
|
|
1157
1189
|
|
|
1158
1190
|
Notes:
|
|
1159
|
-
|
|
1160
|
-
|
|
1191
|
+
Common convention is to store templates in a "templates" directory and
|
|
1192
|
+
have a filename suffix with '.j2' (for jinja2).
|
|
1193
|
+
|
|
1194
|
+
The default template lookup directory (used with jinjas ``extends``, ``import`` and
|
|
1195
|
+
``include`` statements) is the current working directory.
|
|
1161
1196
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1197
|
+
For information on the template syntax, see
|
|
1198
|
+
`the jinja2 docs <https://jinja.palletsprojects.com>`_.
|
|
1164
1199
|
|
|
1165
1200
|
**Examples:**
|
|
1166
1201
|
|
|
@@ -1309,6 +1344,39 @@ def move(src: str, dest: str, overwrite=False):
|
|
|
1309
1344
|
yield StringCommand("mv", QuoteString(src), QuoteString(dest))
|
|
1310
1345
|
|
|
1311
1346
|
|
|
1347
|
+
@operation()
|
|
1348
|
+
def copy(src: str, dest: str, overwrite=False):
|
|
1349
|
+
"""
|
|
1350
|
+
Copy remote file/directory/link into remote directory
|
|
1351
|
+
|
|
1352
|
+
+ src: remote file/directory to copy
|
|
1353
|
+
+ dest: remote directory to copy `src` into
|
|
1354
|
+
+ overwrite: whether to overwrite dest, if present
|
|
1355
|
+
"""
|
|
1356
|
+
src_is_dir = host.get_fact(Directory, src)
|
|
1357
|
+
if not host.get_fact(File, src) and not src_is_dir:
|
|
1358
|
+
raise OperationError(f"src {src} does not exist")
|
|
1359
|
+
|
|
1360
|
+
if not host.get_fact(Directory, dest):
|
|
1361
|
+
raise OperationError(f"dest {dest} is not an existing directory")
|
|
1362
|
+
|
|
1363
|
+
dest_file_path = os.path.join(dest, os.path.basename(src))
|
|
1364
|
+
dest_file_exists = host.get_fact(File, dest_file_path)
|
|
1365
|
+
if dest_file_exists and not overwrite:
|
|
1366
|
+
if _remote_file_equal(src, dest_file_path):
|
|
1367
|
+
host.noop(f"{dest_file_path} already exists")
|
|
1368
|
+
return
|
|
1369
|
+
else:
|
|
1370
|
+
raise OperationError(f"{dest_file_path} already exists and is different than src")
|
|
1371
|
+
|
|
1372
|
+
cp_cmd = ["cp -r"]
|
|
1373
|
+
|
|
1374
|
+
if overwrite:
|
|
1375
|
+
cp_cmd.append("-f")
|
|
1376
|
+
|
|
1377
|
+
yield StringCommand(*cp_cmd, QuoteString(src), QuoteString(dest))
|
|
1378
|
+
|
|
1379
|
+
|
|
1312
1380
|
def _validate_path(path):
|
|
1313
1381
|
try:
|
|
1314
1382
|
return os.fspath(path)
|
|
@@ -1786,7 +1854,7 @@ def block(
|
|
|
1786
1854
|
# put complex alias into .zshrc
|
|
1787
1855
|
files.block(
|
|
1788
1856
|
path="/home/user/.zshrc",
|
|
1789
|
-
content="eval $(
|
|
1857
|
+
content="eval $(thef -a)",
|
|
1790
1858
|
try_prevent_shell_expansion=True,
|
|
1791
1859
|
marker="## {mark} ALIASES ##"
|
|
1792
1860
|
)
|
|
@@ -1800,6 +1868,13 @@ def block(
|
|
|
1800
1868
|
current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
|
|
1801
1869
|
cmd = None
|
|
1802
1870
|
|
|
1871
|
+
# Use _temp_dir global argument if provided, otherwise fall back to config
|
|
1872
|
+
tmp_dir = None
|
|
1873
|
+
if host.current_op_global_arguments:
|
|
1874
|
+
tmp_dir = host.current_op_global_arguments.get("_temp_dir")
|
|
1875
|
+
if not tmp_dir:
|
|
1876
|
+
tmp_dir = host.get_temp_dir_config()
|
|
1877
|
+
|
|
1803
1878
|
# standard awk doesn't have an "in-place edit" option so we write to a tempfile and
|
|
1804
1879
|
# if edits were successful move to dest i.e. we do: <out_prep> ... do some work ... <real_out>
|
|
1805
1880
|
q_path = QuoteString(path)
|
|
@@ -1815,7 +1890,7 @@ def block(
|
|
|
1815
1890
|
)
|
|
1816
1891
|
)
|
|
1817
1892
|
out_prep = StringCommand(
|
|
1818
|
-
'OUT="$(TMPDIR
|
|
1893
|
+
f'OUT="$(TMPDIR={tmp_dir} mktemp -t pyinfra.XXXXXX)" && ',
|
|
1819
1894
|
*mode_get,
|
|
1820
1895
|
'OWNER="$(stat -c "%u:%g"',
|
|
1821
1896
|
q_path,
|
pyinfra/operations/flatpak.py
CHANGED
pyinfra/operations/gem.py
CHANGED
pyinfra/operations/git.py
CHANGED
pyinfra/operations/iptables.py
CHANGED
pyinfra/operations/lxd.py
CHANGED
pyinfra/operations/mysql.py
CHANGED
pyinfra/operations/opkg.py
CHANGED
|
@@ -52,7 +52,8 @@ def packages(
|
|
|
52
52
|
|
|
53
53
|
.. code:: python
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
from pyinfra.operations import opkg
|
|
56
|
+
# Ensure packages are installed (will not force package upgrade)
|
|
56
57
|
opkg.packages(['asterisk', 'vim'], name="Install Asterisk and Vim")
|
|
57
58
|
|
|
58
59
|
# Install the latest versions of packages (always check)
|
pyinfra/operations/pacman.py
CHANGED
pyinfra/operations/pip.py
CHANGED
pyinfra/operations/pipx.py
CHANGED
pyinfra/operations/pkg.py
CHANGED
pyinfra/operations/pkgin.py
CHANGED
pyinfra/operations/postgres.py
CHANGED
|
@@ -53,7 +53,12 @@ def sql(
|
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
@operation(
|
|
56
|
+
@operation(
|
|
57
|
+
idempotent_notice=(
|
|
58
|
+
"This operation will always execute commands when a password is provided, "
|
|
59
|
+
"as pyinfra cannot reliably validate the current password."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
57
62
|
def role(
|
|
58
63
|
role: str,
|
|
59
64
|
present: bool = True,
|
|
@@ -96,6 +101,7 @@ def role(
|
|
|
96
101
|
|
|
97
102
|
.. code:: python
|
|
98
103
|
|
|
104
|
+
from pyinfra.operations import postgresql
|
|
99
105
|
postgresql.role(
|
|
100
106
|
name="Create the pyinfra PostgreSQL role",
|
|
101
107
|
role="pyinfra",
|
pyinfra/operations/puppet.py
CHANGED
pyinfra/operations/python.py
CHANGED
pyinfra/operations/selinux.py
CHANGED
pyinfra/operations/server.py
CHANGED
pyinfra/operations/snap.py
CHANGED
pyinfra/operations/ssh.py
CHANGED
pyinfra/operations/systemd.py
CHANGED
pyinfra/operations/sysvinit.py
CHANGED
|
@@ -38,7 +38,7 @@ def service(
|
|
|
38
38
|
support enabling/disabling services:
|
|
39
39
|
|
|
40
40
|
+ Ubuntu/Debian (``update-rc.d``)
|
|
41
|
-
+
|
|
41
|
+
+ Fedora/RHEL (``chkconfig``)
|
|
42
42
|
+ Gentoo (``rc-update``)
|
|
43
43
|
|
|
44
44
|
For other distributions and more granular service control, see the
|
|
@@ -48,6 +48,7 @@ def service(
|
|
|
48
48
|
|
|
49
49
|
.. code:: python
|
|
50
50
|
|
|
51
|
+
from pyinfra.operations import sysvinit
|
|
51
52
|
sysvinit.service(
|
|
52
53
|
name="Restart and enable rsyslog",
|
|
53
54
|
service="rsyslog",
|
|
@@ -1,17 +1,172 @@
|
|
|
1
|
-
import
|
|
2
|
-
from typing import Any
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
4
|
from pyinfra.api import OperationError
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
@
|
|
7
|
+
@dataclass
|
|
8
|
+
class ImageReference:
|
|
9
|
+
"""Represents a parsed Docker image reference."""
|
|
10
|
+
|
|
11
|
+
repository: str
|
|
12
|
+
namespace: str | None = None
|
|
13
|
+
tag: str | None = None
|
|
14
|
+
digest: str | None = None
|
|
15
|
+
registry_host: str | None = None
|
|
16
|
+
registry_port: int | None = None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def registry(self) -> str | None:
|
|
20
|
+
"""Get the full registry address (host:port)."""
|
|
21
|
+
if not self.registry_host:
|
|
22
|
+
return None
|
|
23
|
+
if self.registry_port:
|
|
24
|
+
return f"{self.registry_host}:{self.registry_port}"
|
|
25
|
+
return self.registry_host
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
"""Get the full image name without tag or digest."""
|
|
30
|
+
parts = []
|
|
31
|
+
if self.registry:
|
|
32
|
+
parts.append(self.registry)
|
|
33
|
+
if self.namespace:
|
|
34
|
+
parts.append(self.namespace)
|
|
35
|
+
parts.append(self.repository)
|
|
36
|
+
return "/".join(parts)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def full_reference(self) -> str:
|
|
40
|
+
"""Get the complete image reference string."""
|
|
41
|
+
ref = self.name
|
|
42
|
+
if self.tag:
|
|
43
|
+
ref += f":{self.tag}"
|
|
44
|
+
if self.digest:
|
|
45
|
+
ref += f"@{self.digest}"
|
|
46
|
+
return ref
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_registry(registry: str) -> tuple[str, int | None]:
|
|
50
|
+
"""
|
|
51
|
+
Parse a registry string into host and port components.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
registry: String like "registry.io:5000" or "registry.io"
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
tuple: (host, port) where port is None if not specified
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If port is specified but not a valid integer
|
|
61
|
+
"""
|
|
62
|
+
if ":" in registry:
|
|
63
|
+
host, port_str = registry.rsplit(":", 1)
|
|
64
|
+
if port_str: # Only try to parse if port_str is not empty
|
|
65
|
+
try:
|
|
66
|
+
port = int(port_str)
|
|
67
|
+
if port < 0 or port > 65535:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Invalid port number: {port}. Port must be between 0 and 65535"
|
|
70
|
+
)
|
|
71
|
+
return host, port
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
if "invalid literal" in str(e):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Invalid port in registry '{registry}': '{port_str}' is not a valid port number"
|
|
76
|
+
)
|
|
77
|
+
raise # Re-raise port range error
|
|
78
|
+
else:
|
|
79
|
+
# Empty port (e.g., "registry.io:")
|
|
80
|
+
raise ValueError(f"Invalid registry format '{registry}': port cannot be empty")
|
|
81
|
+
else:
|
|
82
|
+
return registry, None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_image_reference(image: str) -> ImageReference:
|
|
86
|
+
"""
|
|
87
|
+
Parse a Docker image reference into components.
|
|
88
|
+
|
|
89
|
+
Format: [HOST[:PORT]/]NAMESPACE/REPOSITORY[:TAG][@DIGEST]
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If the image reference is empty or invalid
|
|
93
|
+
"""
|
|
94
|
+
if not image or not image.strip():
|
|
95
|
+
raise ValueError("Image reference cannot be empty")
|
|
96
|
+
|
|
97
|
+
original = image.strip()
|
|
98
|
+
registry_host = None
|
|
99
|
+
registry_port = None
|
|
100
|
+
namespace = None
|
|
101
|
+
repository = None
|
|
102
|
+
tag = None
|
|
103
|
+
digest = None
|
|
104
|
+
|
|
105
|
+
# Extract digest first (format: name@digest)
|
|
106
|
+
if "@" in original:
|
|
107
|
+
original, digest = original.rsplit("@", 1)
|
|
108
|
+
|
|
109
|
+
# Extract tag (format: name:tag)
|
|
110
|
+
if ":" in original:
|
|
111
|
+
parts = original.split(":")
|
|
112
|
+
if len(parts) >= 2:
|
|
113
|
+
potential_tag = parts[-1]
|
|
114
|
+
# Tag cannot contain '/' - if it does, the colon is part of the registry, separating host and port
|
|
115
|
+
if "/" not in potential_tag:
|
|
116
|
+
original = ":".join(parts[:-1])
|
|
117
|
+
tag = potential_tag
|
|
118
|
+
|
|
119
|
+
# Split by '/' to separate registry/namespace/repository
|
|
120
|
+
parts = original.split("/")
|
|
121
|
+
|
|
122
|
+
if len(parts) == 1:
|
|
123
|
+
# Just repository name (e.g., "nginx")
|
|
124
|
+
repository = parts[0]
|
|
125
|
+
elif len(parts) == 2:
|
|
126
|
+
# Could be namespace/repository or registry/repository
|
|
127
|
+
if "." in parts[0] or ":" in parts[0]:
|
|
128
|
+
# Likely a registry (registry.io:5000/repo or registry.io/repo)
|
|
129
|
+
registry_host, registry_port = parse_registry(parts[0])
|
|
130
|
+
repository = parts[1]
|
|
131
|
+
else:
|
|
132
|
+
# Likely namespace/repository
|
|
133
|
+
namespace = parts[0]
|
|
134
|
+
repository = parts[1]
|
|
135
|
+
elif len(parts) >= 3:
|
|
136
|
+
# registry/namespace/repository or registry/nested/namespace/repository
|
|
137
|
+
registry_host, registry_port = parse_registry(parts[0])
|
|
138
|
+
namespace = "/".join(parts[1:-1])
|
|
139
|
+
repository = parts[-1]
|
|
140
|
+
|
|
141
|
+
# Validate that we found a repository
|
|
142
|
+
if not repository:
|
|
143
|
+
raise ValueError(f"Invalid image reference: no repository found in '{image}'")
|
|
144
|
+
|
|
145
|
+
# Default tag to 'latest' if neither tag nor digest specified. This is Docker's default behavior.
|
|
146
|
+
if tag is None and digest is None:
|
|
147
|
+
tag = "latest"
|
|
148
|
+
|
|
149
|
+
return ImageReference(
|
|
150
|
+
repository=repository,
|
|
151
|
+
namespace=namespace,
|
|
152
|
+
tag=tag,
|
|
153
|
+
digest=digest,
|
|
154
|
+
registry_host=registry_host,
|
|
155
|
+
registry_port=registry_port,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
8
160
|
class ContainerSpec:
|
|
9
161
|
image: str = ""
|
|
10
|
-
ports:
|
|
11
|
-
networks:
|
|
12
|
-
volumes:
|
|
13
|
-
env_vars:
|
|
162
|
+
ports: list[str] = field(default_factory=list)
|
|
163
|
+
networks: list[str] = field(default_factory=list)
|
|
164
|
+
volumes: list[str] = field(default_factory=list)
|
|
165
|
+
env_vars: list[str] = field(default_factory=list)
|
|
166
|
+
labels: list[str] = field(default_factory=list)
|
|
14
167
|
pull_always: bool = False
|
|
168
|
+
restart_policy: str | None = None
|
|
169
|
+
auto_remove: bool = False
|
|
15
170
|
|
|
16
171
|
def container_create_args(self):
|
|
17
172
|
args = []
|
|
@@ -27,14 +182,23 @@ class ContainerSpec:
|
|
|
27
182
|
for env_var in self.env_vars:
|
|
28
183
|
args.append("-e {0}".format(env_var))
|
|
29
184
|
|
|
185
|
+
for label in self.labels:
|
|
186
|
+
args.append("--label {0}".format(label))
|
|
187
|
+
|
|
30
188
|
if self.pull_always:
|
|
31
189
|
args.append("--pull always")
|
|
32
190
|
|
|
191
|
+
if self.restart_policy:
|
|
192
|
+
args.append("--restart {0}".format(self.restart_policy))
|
|
193
|
+
|
|
194
|
+
if self.auto_remove:
|
|
195
|
+
args.append("--rm")
|
|
196
|
+
|
|
33
197
|
args.append(self.image)
|
|
34
198
|
|
|
35
199
|
return args
|
|
36
200
|
|
|
37
|
-
def diff_from_inspect(self, inspect_dict:
|
|
201
|
+
def diff_from_inspect(self, inspect_dict: dict[str, Any]) -> list[str]:
|
|
38
202
|
# TODO(@minor-fixes): Diff output of "docker inspect" against this spec
|
|
39
203
|
# to determine if the container needs to be recreated. Currently, this
|
|
40
204
|
# function will never recreate when attributes change, which is
|
|
@@ -172,6 +172,8 @@ def ensure_packages(
|
|
|
172
172
|
return
|
|
173
173
|
if isinstance(packages_to_ensure, str):
|
|
174
174
|
packages_to_ensure = [packages_to_ensure]
|
|
175
|
+
if len(packages_to_ensure) == 0:
|
|
176
|
+
return
|
|
175
177
|
|
|
176
178
|
packages: list[PkgInfo] = []
|
|
177
179
|
if isinstance(packages_to_ensure[0], PkgInfo):
|
pyinfra/operations/xbps.py
CHANGED
pyinfra/operations/yum.py
CHANGED
|
@@ -25,6 +25,9 @@ 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
|
yum.key(
|
|
30
33
|
name="Add the Docker CentOS gpg key",
|
|
@@ -75,7 +78,7 @@ def repo(
|
|
|
75
78
|
|
|
76
79
|
# Create the repository file from baseurl/etc
|
|
77
80
|
yum.repo(
|
|
78
|
-
name="Add the Docker
|
|
81
|
+
name="Add the Docker repo",
|
|
79
82
|
src="DockerCE",
|
|
80
83
|
baseurl="https://download.docker.com/linux/centos/7/$basearch/stable",
|
|
81
84
|
)
|
pyinfra/operations/zfs.py
CHANGED