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.
Files changed (64) hide show
  1. pyinfra/api/__init__.py +1 -0
  2. pyinfra/api/arguments.py +9 -2
  3. pyinfra/api/arguments_typed.py +18 -23
  4. pyinfra/api/command.py +9 -3
  5. pyinfra/api/deploy.py +1 -1
  6. pyinfra/api/exceptions.py +12 -0
  7. pyinfra/api/facts.py +20 -4
  8. pyinfra/api/host.py +3 -0
  9. pyinfra/api/inventory.py +2 -2
  10. pyinfra/api/metadata.py +69 -0
  11. pyinfra/api/operation.py +9 -4
  12. pyinfra/api/operations.py +16 -14
  13. pyinfra/api/util.py +22 -5
  14. pyinfra/connectors/docker.py +25 -1
  15. pyinfra/connectors/ssh.py +57 -0
  16. pyinfra/connectors/sshuserclient/client.py +47 -28
  17. pyinfra/connectors/util.py +16 -9
  18. pyinfra/facts/crontab.py +10 -8
  19. pyinfra/facts/files.py +12 -3
  20. pyinfra/facts/flatpak.py +1 -1
  21. pyinfra/facts/npm.py +1 -1
  22. pyinfra/facts/server.py +18 -2
  23. pyinfra/operations/apk.py +2 -1
  24. pyinfra/operations/apt.py +15 -7
  25. pyinfra/operations/brew.py +1 -0
  26. pyinfra/operations/crontab.py +4 -1
  27. pyinfra/operations/dnf.py +4 -1
  28. pyinfra/operations/docker.py +70 -16
  29. pyinfra/operations/files.py +87 -12
  30. pyinfra/operations/flatpak.py +1 -0
  31. pyinfra/operations/gem.py +1 -0
  32. pyinfra/operations/git.py +1 -0
  33. pyinfra/operations/iptables.py +1 -0
  34. pyinfra/operations/lxd.py +1 -0
  35. pyinfra/operations/mysql.py +1 -0
  36. pyinfra/operations/opkg.py +2 -1
  37. pyinfra/operations/pacman.py +1 -0
  38. pyinfra/operations/pip.py +1 -0
  39. pyinfra/operations/pipx.py +1 -0
  40. pyinfra/operations/pkg.py +1 -0
  41. pyinfra/operations/pkgin.py +1 -0
  42. pyinfra/operations/postgres.py +7 -1
  43. pyinfra/operations/puppet.py +1 -0
  44. pyinfra/operations/python.py +1 -0
  45. pyinfra/operations/selinux.py +1 -0
  46. pyinfra/operations/server.py +1 -0
  47. pyinfra/operations/snap.py +2 -1
  48. pyinfra/operations/ssh.py +1 -0
  49. pyinfra/operations/systemd.py +1 -0
  50. pyinfra/operations/sysvinit.py +2 -1
  51. pyinfra/operations/util/docker.py +172 -8
  52. pyinfra/operations/util/packaging.py +2 -0
  53. pyinfra/operations/xbps.py +1 -0
  54. pyinfra/operations/yum.py +4 -1
  55. pyinfra/operations/zfs.py +1 -0
  56. pyinfra/operations/zypper.py +1 -0
  57. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/METADATA +2 -1
  58. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/RECORD +64 -63
  59. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/WHEEL +1 -1
  60. pyinfra_cli/cli.py +20 -4
  61. pyinfra_cli/inventory.py +26 -1
  62. pyinfra_cli/util.py +1 -1
  63. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/entry_points.txt +0 -0
  64. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -28,15 +28,17 @@ HOST_KEYS_LOCK = BoundedSemaphore()
28
28
  class StrictPolicy(MissingHostKeyPolicy):
29
29
  @override
30
30
  def missing_host_key(self, client, hostname, key):
31
- logger.error("No host key for {0} found in known_hosts".format(hostname))
32
- raise SSHException(
33
- "StrictPolicy: No host key for {0} found in known_hosts".format(hostname),
34
- )
31
+ logger.error("No host key for %s found in known_hosts", hostname)
32
+ raise SSHException(f"StrictPolicy: No host key for {hostname} found in known_hosts")
35
33
 
36
34
 
37
35
  def append_hostkey(client, hostname, key):
38
36
  """Append hostname to the clients host_keys_file"""
39
37
 
38
+ if client._host_keys_filename is None:
39
+ logger.warning("No host keys filename, not saving key for: %s", hostname)
40
+ return
41
+
40
42
  with HOST_KEYS_LOCK:
41
43
  # The paramiko client saves host keys incorrectly whereas the host keys object does
42
44
  # this correctly, so use that with the client filename variable.
@@ -67,30 +69,28 @@ class AcceptNewPolicy(MissingHostKeyPolicy):
67
69
  )
68
70
 
69
71
  append_hostkey(client, hostname, key)
70
- logger.warning("Added host key for {0} to known_hosts".format(hostname))
72
+ logger.warning("Added host key for %s to known_hosts", hostname)
71
73
 
72
74
 
73
75
  class AskPolicy(MissingHostKeyPolicy):
74
76
  @override
75
77
  def missing_host_key(self, client, hostname, key):
76
78
  should_continue = input(
77
- "No host key for {0} found in known_hosts, do you want to continue [y/n] ".format(
78
- hostname,
79
- ),
79
+ f"No host key for {hostname} found in known_hosts, do you want to continue [y/n] ",
80
80
  )
81
81
  if should_continue.lower() != "y":
82
82
  raise SSHException(
83
- "AskPolicy: No host key for {0} found in known_hosts".format(hostname),
83
+ f"AskPolicy: No host key for {hostname} found in known_hosts",
84
84
  )
85
85
  append_hostkey(client, hostname, key)
86
- logger.warning("Added host key for {0} to known_hosts".format(hostname))
86
+ logger.warning("Added host key for %s to known_hosts", hostname)
87
87
  return
88
88
 
89
89
 
90
90
  class WarningPolicy(MissingHostKeyPolicy):
91
91
  @override
92
92
  def missing_host_key(self, client, hostname, key):
93
- logger.warning("No host key for {0} found in known_hosts".format(hostname))
93
+ logger.warning("No host key for %s found in known_hosts", hostname)
94
94
 
95
95
 
96
96
  def get_missing_host_key_policy(policy):
@@ -102,7 +102,7 @@ def get_missing_host_key_policy(policy):
102
102
  return StrictPolicy()
103
103
  if policy == "accept-new":
104
104
  return AcceptNewPolicy()
105
- raise SSHException("Invalid value StrictHostKeyChecking={}".format(policy))
105
+ raise SSHException(f"Invalid value StrictHostKeyChecking={policy}")
106
106
 
107
107
 
108
108
  @memoize
@@ -120,17 +120,24 @@ def get_ssh_config(user_config_file=None):
120
120
 
121
121
 
122
122
  @memoize
123
- def get_host_keys(filename):
123
+ def get_host_keys(filenames):
124
+ """
125
+ Load host keys from one or more files.
126
+
127
+ Args:
128
+ filenames: A tuple of filenames to load host keys from.
129
+ """
124
130
  with HOST_KEYS_LOCK:
125
131
  host_keys = HostKeys()
126
132
 
127
- try:
128
- host_keys.load(filename)
129
- # When paramiko encounters a bad host keys line it sometimes bails the
130
- # entire load incorrectly.
131
- # See: https://github.com/paramiko/paramiko/pull/1990
132
- except Exception as e:
133
- logger.warning("Failed to load host keys from {0}: {1}".format(filename, e))
133
+ for filename in filenames:
134
+ try:
135
+ host_keys.load(filename)
136
+ # When paramiko encounters a bad host keys line it sometimes bails the
137
+ # entire load incorrectly.
138
+ # See: https://github.com/paramiko/paramiko/pull/1990
139
+ except Exception as e:
140
+ logger.warning("Failed to load host keys from %s: %s", filename, e)
134
141
 
135
142
  return host_keys
136
143
 
@@ -157,7 +164,7 @@ class SSHClient(ParamikoClient):
157
164
  config,
158
165
  forward_agent,
159
166
  missing_host_key_policy,
160
- host_keys_file,
167
+ host_keys_files,
161
168
  keep_alive,
162
169
  ) = self.parse_config(
163
170
  hostname,
@@ -169,11 +176,13 @@ class SSHClient(ParamikoClient):
169
176
  config.update(kwargs)
170
177
 
171
178
  if _pyinfra_ssh_known_hosts_file:
172
- host_keys_file = _pyinfra_ssh_known_hosts_file
179
+ host_keys_files = (path.expanduser(_pyinfra_ssh_known_hosts_file),)
173
180
 
174
181
  # Overwrite paramiko empty defaults with @memoize-d host keys object
175
- self._host_keys = get_host_keys(host_keys_file)
176
- self._host_keys_filename = host_keys_file
182
+ self._host_keys = get_host_keys(host_keys_files)
183
+ # Use the first file for writing new host keys
184
+ if len(host_keys_files) > 0:
185
+ self._host_keys_filename = host_keys_files[0]
177
186
 
178
187
  if _pyinfra_ssh_paramiko_connect_kwargs:
179
188
  config.update(_pyinfra_ssh_paramiko_connect_kwargs)
@@ -217,11 +226,18 @@ class SSHClient(ParamikoClient):
217
226
  keep_alive = 0
218
227
  forward_agent = False
219
228
  missing_host_key_policy = get_missing_host_key_policy(strict_host_key_checking)
220
- host_keys_file = path.expanduser("~/.ssh/known_hosts") # OpenSSH default
229
+ host_keys_files = (path.expanduser("~/.ssh/known_hosts"),)
221
230
 
222
231
  ssh_config = get_ssh_config(ssh_config_file)
223
232
  if not ssh_config:
224
- return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
233
+ return (
234
+ hostname,
235
+ cfg,
236
+ forward_agent,
237
+ missing_host_key_policy,
238
+ host_keys_files,
239
+ keep_alive,
240
+ )
225
241
 
226
242
  host_config = ssh_config.lookup(hostname)
227
243
  forward_agent = host_config.get("forwardagent") == "yes"
@@ -233,7 +249,10 @@ class SSHClient(ParamikoClient):
233
249
  )
234
250
 
235
251
  if "userknownhostsfile" in host_config:
236
- host_keys_file = path.expanduser(host_config["userknownhostsfile"])
252
+ # OpenSSH supports multiple space-separated known hosts files
253
+ host_keys_files = tuple(
254
+ path.expanduser(f) for f in host_config["userknownhostsfile"].split()
255
+ )
237
256
 
238
257
  if "hostname" in host_config:
239
258
  hostname = host_config["hostname"]
@@ -275,7 +294,7 @@ class SSHClient(ParamikoClient):
275
294
  sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
276
295
  cfg["sock"] = sock
277
296
 
278
- return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_file, keep_alive
297
+ return hostname, cfg, forward_agent, missing_host_key_policy, host_keys_files, keep_alive
279
298
 
280
299
  @staticmethod
281
300
  def derive_shorthand(ssh_config, host_string):
@@ -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:=/tmp}}/pyinfra-sudo-askpass-XXXXXXXXXXXX")
28
+ temp=$(mktemp "${{TMPDIR:={0}}}/pyinfra-sudo-askpass-XXXXXXXXXXXX")
27
29
  cat >"$temp"<<'__EOF__'
28
30
  #!/bin/sh
29
- printf '%s\n' "${0}"
31
+ printf '%s\n' "${1}"
30
32
  __EOF__
31
33
  chmod 755 "$temp"
32
34
  echo "$temp"
33
- """.format(
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(SUDO_ASKPASS_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
@@ -52,9 +52,11 @@ class CrontabFile:
52
52
 
53
53
  name_comment = "# pyinfra-name={0}".format(name)
54
54
  for cmd in self.commands:
55
+ if "command" not in cmd:
56
+ continue
55
57
  if cmd.get("command") == command:
56
58
  return cmd
57
- if cmd.get("comments") and name_comment in cmd["comments"]:
59
+ if name_comment in cmd.get("comments", []):
58
60
  return cmd
59
61
  return None
60
62
 
@@ -125,15 +127,15 @@ class Crontab(FactBase[CrontabFile]):
125
127
  # or CrontabFile.to_json()
126
128
  [
127
129
  {
128
- command: "/path/to/command",
129
- minute: "*",
130
- hour: "*",
131
- month: "*",
132
- day_of_month: "*",
133
- day_of_week: "*",
130
+ "command": "/path/to/command",
131
+ "minute": "*",
132
+ "hour": "*",
133
+ "month": "*",
134
+ "day_of_month": "*",
135
+ "day_of_week": "*",
134
136
  },
135
137
  {
136
- "command": "echo another command
138
+ "command": "echo another command",
137
139
  "special_time": "@daily",
138
140
  }
139
141
  ]
pyinfra/facts/files.py CHANGED
@@ -547,6 +547,8 @@ class FindFilesBase(FactBase):
547
547
  command.append("-regex")
548
548
  command.append(maybe_quote(regex))
549
549
 
550
+ command.extend(args)
551
+
550
552
  command.append("||")
551
553
  command.append("true")
552
554
 
@@ -664,14 +666,21 @@ class Block(FactBase):
664
666
 
665
667
  class FileContents(FactBase):
666
668
  """
667
- Returns the contents of a file as a list of lines. Works with both sha1sum and sha1. Returns
668
- ``None`` if the file doest not exist.
669
+ Returns the contents of a file as a list of lines, or ``None`` if the file does not exist.
669
670
  """
670
671
 
671
672
  @override
672
673
  def command(self, path):
673
- return make_formatted_string_command("cat {0}", QuoteString(path))
674
+ self.missing_flag = "{0}{1}".format(MISSING, path)
675
+ return make_formatted_string_command(
676
+ "( test -e {0} && cat {0} ) || echo {1}",
677
+ QuoteString(path),
678
+ QuoteString(self.missing_flag),
679
+ )
674
680
 
675
681
  @override
676
682
  def process(self, output):
683
+ # If output is the missing flag, the file doesn't exist
684
+ if output and output[0] == self.missing_flag:
685
+ return None
677
686
  return output
pyinfra/facts/flatpak.py CHANGED
@@ -74,4 +74,4 @@ class FlatpakPackages(FlatpakBaseFact):
74
74
 
75
75
  @override
76
76
  def process(self, output):
77
- return [flatpak for flatpak in output[1:]]
77
+ return [flatpak for i, flatpak in enumerate(output) if i > 1 or flatpak != "Application ID"]
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, if configured.
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 "echo $TMPDIR"
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
- # Time on files is not tz-aware, and will be the same tz as the server's time,
313
- # so we can safely remove the tzinfo from the Date fact before comparison.
314
- host_cache_time = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
315
- if cache_info and cache_info["mtime"] and cache_info["mtime"] > host_cache_time:
316
- host.noop("apt is already up to date")
317
- return
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
 
@@ -61,6 +61,7 @@ def packages(
61
61
 
62
62
  .. code:: python
63
63
 
64
+ from pyinfra.operations import brew
64
65
  # Update package list and install packages
65
66
  brew.packages(
66
67
  name='Install Vim and vimpager',
@@ -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(command=command, name=cron_name)
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 CentOS gpg key",
33
+ name="Add the Docker gpg key",
31
34
  src=f"https://download.docker.com/linux/{linux_id}/gpg",
32
35
  )
33
36
 
@@ -8,9 +8,15 @@ from __future__ import annotations
8
8
 
9
9
  from pyinfra import host
10
10
  from pyinfra.api import operation
11
- from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerPlugin, DockerVolume
11
+ from pyinfra.facts.docker import (
12
+ DockerContainer,
13
+ DockerImage,
14
+ DockerNetwork,
15
+ DockerPlugin,
16
+ DockerVolume,
17
+ )
12
18
 
13
- from .util.docker import ContainerSpec, handle_docker
19
+ from .util.docker import ContainerSpec, handle_docker, parse_image_reference
14
20
 
15
21
 
16
22
  @operation()
@@ -21,10 +27,13 @@ def container(
21
27
  networks: list[str] | None = None,
22
28
  volumes: list[str] | None = None,
23
29
  env_vars: list[str] | None = None,
30
+ labels: list[str] | None = None,
24
31
  pull_always: bool = False,
25
32
  present: bool = True,
26
33
  force: bool = False,
27
34
  start: bool = True,
35
+ restart_policy: str | None = None,
36
+ auto_remove: bool = False,
28
37
  ):
29
38
  """
30
39
  Manage Docker containers
@@ -35,15 +44,19 @@ def container(
35
44
  + ports: port list to expose
36
45
  + volumes: volume list to map on container
37
46
  + env_vars: environment variable list to inject on container
47
+ + labels: Label list to attach to the container
38
48
  + pull_always: force image pull
39
49
  + force: remove a container with same name and create a new one
40
50
  + present: whether the container should be up and running
41
51
  + start: start or stop the container
52
+ + restart_policy: restart policy to apply when a container exits
53
+ + auto_remove: automatically remove the container and its associated anonymous volumes when it exits
42
54
 
43
55
  **Examples:**
44
56
 
45
57
  .. code:: python
46
58
 
59
+ from pyinfra.operations import docker
47
60
  # Run a container
48
61
  docker.container(
49
62
  name="Deploy Nginx container",
@@ -55,6 +68,8 @@ def container(
55
68
  networks=["proxy", "services"],
56
69
  volumes=["nginx_data:/usr/share/nginx/html"],
57
70
  pull_always=True,
71
+ restart_policy="unless-stopped",
72
+ auto_remove=True,
58
73
  )
59
74
 
60
75
  # Stop a container
@@ -78,7 +93,10 @@ def container(
78
93
  networks or list(),
79
94
  volumes or list(),
80
95
  env_vars or list(),
96
+ labels or list(),
81
97
  pull_always,
98
+ restart_policy,
99
+ auto_remove,
82
100
  )
83
101
  existent_container = host.get_fact(DockerContainer, object_id=container)
84
102
 
@@ -127,13 +145,14 @@ def container(
127
145
  )
128
146
 
129
147
 
130
- @operation(is_idempotent=False)
131
- def image(image, present=True):
148
+ @operation()
149
+ def image(image: str, present: bool = True, force: bool = False):
132
150
  """
133
151
  Manage Docker images
134
152
 
135
153
  + image: Image and tag ex: nginx:alpine
136
154
  + present: whether the Docker image should exist
155
+ + force: always pull the image if present is True
137
156
 
138
157
  **Examples:**
139
158
 
@@ -153,20 +172,55 @@ def image(image, present=True):
153
172
  present=False,
154
173
  )
155
174
  """
156
-
175
+ image_info = parse_image_reference(image)
157
176
  if present:
158
- yield handle_docker(
159
- resource="image",
160
- command="pull",
161
- image=image,
162
- )
163
-
177
+ if force:
178
+ # always pull the image if force is True
179
+ yield handle_docker(
180
+ resource="image",
181
+ command="pull",
182
+ image=image,
183
+ )
184
+ return
185
+ else:
186
+ existent_image = host.get_fact(DockerImage, object_id=image)
187
+ if image_info.digest:
188
+ # If a digest is specified, we must ensure the exact image is present
189
+ if existent_image:
190
+ host.noop(f"Image with digest {image_info.digest} already exists!")
191
+ else:
192
+ yield handle_docker(
193
+ resource="image",
194
+ command="pull",
195
+ image=image,
196
+ )
197
+ elif image_info.tag == "latest" or not image_info.tag:
198
+ # If the tag is 'latest' or not specified, always pull to ensure freshness
199
+ yield handle_docker(
200
+ resource="image",
201
+ command="pull",
202
+ image=image,
203
+ )
204
+ else:
205
+ # For other tags, check if the image exists
206
+ if existent_image:
207
+ host.noop(f"Image with tag {image_info.tag} already exists!")
208
+ else:
209
+ yield handle_docker(
210
+ resource="image",
211
+ command="pull",
212
+ image=image,
213
+ )
164
214
  else:
165
- yield handle_docker(
166
- resource="image",
167
- command="remove",
168
- image=image,
169
- )
215
+ existent_image = host.get_fact(DockerImage, object_id=image)
216
+ if existent_image:
217
+ yield handle_docker(
218
+ resource="image",
219
+ command="remove",
220
+ image=image,
221
+ )
222
+ else:
223
+ host.noop("There is no {0} image!".format(image))
170
224
 
171
225
 
172
226
  @operation()