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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. pyinfra/__init__.py +9 -12
  2. pyinfra/__main__.py +4 -0
  3. pyinfra/api/__init__.py +19 -3
  4. pyinfra/api/arguments.py +413 -0
  5. pyinfra/api/arguments_typed.py +79 -0
  6. pyinfra/api/command.py +274 -0
  7. pyinfra/api/config.py +222 -28
  8. pyinfra/api/connect.py +33 -13
  9. pyinfra/api/connectors.py +27 -0
  10. pyinfra/api/deploy.py +65 -66
  11. pyinfra/api/exceptions.py +73 -18
  12. pyinfra/api/facts.py +267 -200
  13. pyinfra/api/host.py +416 -50
  14. pyinfra/api/inventory.py +121 -160
  15. pyinfra/api/metadata.py +69 -0
  16. pyinfra/api/operation.py +432 -262
  17. pyinfra/api/operations.py +273 -260
  18. pyinfra/api/state.py +302 -248
  19. pyinfra/api/util.py +309 -369
  20. pyinfra/connectors/base.py +173 -0
  21. pyinfra/connectors/chroot.py +212 -0
  22. pyinfra/connectors/docker.py +405 -0
  23. pyinfra/connectors/dockerssh.py +297 -0
  24. pyinfra/connectors/local.py +238 -0
  25. pyinfra/connectors/scp/__init__.py +1 -0
  26. pyinfra/connectors/scp/client.py +204 -0
  27. pyinfra/connectors/ssh.py +727 -0
  28. pyinfra/connectors/ssh_util.py +114 -0
  29. pyinfra/connectors/sshuserclient/client.py +309 -0
  30. pyinfra/connectors/sshuserclient/config.py +102 -0
  31. pyinfra/connectors/terraform.py +135 -0
  32. pyinfra/connectors/util.py +417 -0
  33. pyinfra/connectors/vagrant.py +183 -0
  34. pyinfra/context.py +145 -0
  35. pyinfra/facts/__init__.py +7 -6
  36. pyinfra/facts/apk.py +22 -7
  37. pyinfra/facts/apt.py +117 -60
  38. pyinfra/facts/brew.py +100 -15
  39. pyinfra/facts/bsdinit.py +23 -0
  40. pyinfra/facts/cargo.py +37 -0
  41. pyinfra/facts/choco.py +47 -0
  42. pyinfra/facts/crontab.py +195 -0
  43. pyinfra/facts/deb.py +94 -0
  44. pyinfra/facts/dnf.py +48 -0
  45. pyinfra/facts/docker.py +96 -23
  46. pyinfra/facts/efibootmgr.py +113 -0
  47. pyinfra/facts/files.py +629 -58
  48. pyinfra/facts/flatpak.py +77 -0
  49. pyinfra/facts/freebsd.py +70 -0
  50. pyinfra/facts/gem.py +19 -6
  51. pyinfra/facts/git.py +59 -14
  52. pyinfra/facts/gpg.py +150 -0
  53. pyinfra/facts/hardware.py +313 -167
  54. pyinfra/facts/iptables.py +72 -62
  55. pyinfra/facts/launchd.py +44 -0
  56. pyinfra/facts/lxd.py +17 -4
  57. pyinfra/facts/mysql.py +122 -86
  58. pyinfra/facts/npm.py +17 -9
  59. pyinfra/facts/openrc.py +71 -0
  60. pyinfra/facts/opkg.py +246 -0
  61. pyinfra/facts/pacman.py +50 -7
  62. pyinfra/facts/pip.py +24 -7
  63. pyinfra/facts/pipx.py +82 -0
  64. pyinfra/facts/pkg.py +15 -6
  65. pyinfra/facts/pkgin.py +35 -0
  66. pyinfra/facts/podman.py +54 -0
  67. pyinfra/facts/postgres.py +178 -0
  68. pyinfra/facts/postgresql.py +6 -147
  69. pyinfra/facts/rpm.py +105 -0
  70. pyinfra/facts/runit.py +77 -0
  71. pyinfra/facts/selinux.py +161 -0
  72. pyinfra/facts/server.py +762 -285
  73. pyinfra/facts/snap.py +88 -0
  74. pyinfra/facts/systemd.py +139 -0
  75. pyinfra/facts/sysvinit.py +59 -0
  76. pyinfra/facts/upstart.py +35 -0
  77. pyinfra/facts/util/__init__.py +17 -0
  78. pyinfra/facts/util/databases.py +4 -6
  79. pyinfra/facts/util/packaging.py +37 -6
  80. pyinfra/facts/util/units.py +30 -0
  81. pyinfra/facts/util/win_files.py +99 -0
  82. pyinfra/facts/vzctl.py +20 -13
  83. pyinfra/facts/xbps.py +35 -0
  84. pyinfra/facts/yum.py +34 -40
  85. pyinfra/facts/zfs.py +77 -0
  86. pyinfra/facts/zypper.py +42 -0
  87. pyinfra/local.py +45 -83
  88. pyinfra/operations/__init__.py +12 -0
  89. pyinfra/operations/apk.py +99 -0
  90. pyinfra/operations/apt.py +496 -0
  91. pyinfra/operations/brew.py +232 -0
  92. pyinfra/operations/bsdinit.py +59 -0
  93. pyinfra/operations/cargo.py +45 -0
  94. pyinfra/operations/choco.py +61 -0
  95. pyinfra/operations/crontab.py +194 -0
  96. pyinfra/operations/dnf.py +213 -0
  97. pyinfra/operations/docker.py +492 -0
  98. pyinfra/operations/files.py +2014 -0
  99. pyinfra/operations/flatpak.py +95 -0
  100. pyinfra/operations/freebsd/__init__.py +12 -0
  101. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  102. pyinfra/operations/freebsd/pkg.py +219 -0
  103. pyinfra/operations/freebsd/service.py +116 -0
  104. pyinfra/operations/freebsd/sysrc.py +92 -0
  105. pyinfra/operations/gem.py +48 -0
  106. pyinfra/operations/git.py +420 -0
  107. pyinfra/operations/iptables.py +312 -0
  108. pyinfra/operations/launchd.py +45 -0
  109. pyinfra/operations/lxd.py +69 -0
  110. pyinfra/operations/mysql.py +610 -0
  111. pyinfra/operations/npm.py +57 -0
  112. pyinfra/operations/openrc.py +63 -0
  113. pyinfra/operations/opkg.py +89 -0
  114. pyinfra/operations/pacman.py +82 -0
  115. pyinfra/operations/pip.py +206 -0
  116. pyinfra/operations/pipx.py +103 -0
  117. pyinfra/operations/pkg.py +71 -0
  118. pyinfra/operations/pkgin.py +92 -0
  119. pyinfra/operations/postgres.py +437 -0
  120. pyinfra/operations/postgresql.py +30 -0
  121. pyinfra/operations/puppet.py +41 -0
  122. pyinfra/operations/python.py +73 -0
  123. pyinfra/operations/runit.py +184 -0
  124. pyinfra/operations/selinux.py +190 -0
  125. pyinfra/operations/server.py +1100 -0
  126. pyinfra/operations/snap.py +118 -0
  127. pyinfra/operations/ssh.py +217 -0
  128. pyinfra/operations/systemd.py +150 -0
  129. pyinfra/operations/sysvinit.py +142 -0
  130. pyinfra/operations/upstart.py +68 -0
  131. pyinfra/operations/util/__init__.py +12 -0
  132. pyinfra/operations/util/docker.py +407 -0
  133. pyinfra/operations/util/files.py +247 -0
  134. pyinfra/operations/util/packaging.py +338 -0
  135. pyinfra/operations/util/service.py +46 -0
  136. pyinfra/operations/vzctl.py +137 -0
  137. pyinfra/operations/xbps.py +78 -0
  138. pyinfra/operations/yum.py +213 -0
  139. pyinfra/operations/zfs.py +176 -0
  140. pyinfra/operations/zypper.py +193 -0
  141. pyinfra/progress.py +44 -32
  142. pyinfra/py.typed +0 -0
  143. pyinfra/version.py +9 -1
  144. pyinfra-3.6.dist-info/METADATA +142 -0
  145. pyinfra-3.6.dist-info/RECORD +160 -0
  146. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info}/WHEEL +1 -2
  147. pyinfra-3.6.dist-info/entry_points.txt +12 -0
  148. {pyinfra-0.11.dev3.dist-info → pyinfra-3.6.dist-info/licenses}/LICENSE.md +1 -1
  149. pyinfra_cli/__init__.py +1 -0
  150. pyinfra_cli/cli.py +793 -0
  151. pyinfra_cli/commands.py +66 -0
  152. pyinfra_cli/exceptions.py +155 -65
  153. pyinfra_cli/inventory.py +233 -89
  154. pyinfra_cli/log.py +39 -43
  155. pyinfra_cli/main.py +26 -495
  156. pyinfra_cli/prints.py +215 -156
  157. pyinfra_cli/util.py +172 -105
  158. pyinfra_cli/virtualenv.py +25 -20
  159. pyinfra/api/connectors/__init__.py +0 -21
  160. pyinfra/api/connectors/ansible.py +0 -99
  161. pyinfra/api/connectors/docker.py +0 -178
  162. pyinfra/api/connectors/local.py +0 -169
  163. pyinfra/api/connectors/ssh.py +0 -402
  164. pyinfra/api/connectors/sshuserclient/client.py +0 -105
  165. pyinfra/api/connectors/sshuserclient/config.py +0 -90
  166. pyinfra/api/connectors/util.py +0 -63
  167. pyinfra/api/connectors/vagrant.py +0 -155
  168. pyinfra/facts/init.py +0 -176
  169. pyinfra/facts/util/files.py +0 -102
  170. pyinfra/hook.py +0 -41
  171. pyinfra/modules/__init__.py +0 -11
  172. pyinfra/modules/apk.py +0 -64
  173. pyinfra/modules/apt.py +0 -272
  174. pyinfra/modules/brew.py +0 -122
  175. pyinfra/modules/files.py +0 -711
  176. pyinfra/modules/gem.py +0 -30
  177. pyinfra/modules/git.py +0 -115
  178. pyinfra/modules/init.py +0 -344
  179. pyinfra/modules/iptables.py +0 -271
  180. pyinfra/modules/lxd.py +0 -45
  181. pyinfra/modules/mysql.py +0 -347
  182. pyinfra/modules/npm.py +0 -47
  183. pyinfra/modules/pacman.py +0 -60
  184. pyinfra/modules/pip.py +0 -99
  185. pyinfra/modules/pkg.py +0 -43
  186. pyinfra/modules/postgresql.py +0 -245
  187. pyinfra/modules/puppet.py +0 -20
  188. pyinfra/modules/python.py +0 -37
  189. pyinfra/modules/server.py +0 -524
  190. pyinfra/modules/ssh.py +0 -150
  191. pyinfra/modules/util/files.py +0 -52
  192. pyinfra/modules/util/packaging.py +0 -118
  193. pyinfra/modules/vzctl.py +0 -133
  194. pyinfra/modules/yum.py +0 -171
  195. pyinfra/pseudo_modules.py +0 -64
  196. pyinfra-0.11.dev3.dist-info/.DS_Store +0 -0
  197. pyinfra-0.11.dev3.dist-info/METADATA +0 -135
  198. pyinfra-0.11.dev3.dist-info/RECORD +0 -95
  199. pyinfra-0.11.dev3.dist-info/entry_points.txt +0 -3
  200. pyinfra-0.11.dev3.dist-info/top_level.txt +0 -2
  201. pyinfra_cli/__main__.py +0 -40
  202. pyinfra_cli/config.py +0 -92
  203. /pyinfra/{modules/util → connectors}/__init__.py +0 -0
  204. /pyinfra/{api/connectors → connectors}/sshuserclient/__init__.py +0 -0
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from tempfile import mkstemp
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+ from typing_extensions import TypedDict, Unpack, override
10
+
11
+ from pyinfra import local, logger
12
+ from pyinfra.api import QuoteString, StringCommand
13
+ from pyinfra.api.exceptions import ConnectError, InventoryError, PyinfraError
14
+ from pyinfra.api.util import get_file_io
15
+ from pyinfra.progress import progress_spinner
16
+
17
+ from .base import BaseConnector, DataMeta
18
+ from .local import LocalConnector
19
+ from .util import CommandOutput, extract_control_arguments, make_unix_command_for_host
20
+
21
+ if TYPE_CHECKING:
22
+ from pyinfra.api.arguments import ConnectorArguments
23
+ from pyinfra.api.host import Host
24
+ from pyinfra.api.state import State
25
+
26
+
27
+ class ConnectorData(TypedDict):
28
+ docker_identifier: str
29
+ docker_platform: str
30
+ docker_architecture: str
31
+
32
+
33
+ connector_data_meta: dict[str, DataMeta] = {
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)"),
37
+ }
38
+
39
+
40
+ class DockerConnector(BaseConnector):
41
+ """
42
+ The Docker connector allows you to use pyinfra to create new Docker images or modify running
43
+ Docker containers.
44
+
45
+ .. note::
46
+
47
+ The Docker connector allows pyinfra to target Docker containers as inventory and is
48
+ unrelated to the :doc:`../operations/docker` & :doc:`../facts/docker`.
49
+
50
+ You can pass either an image name or existing container ID:
51
+
52
+ + Image - will create a new container from the image, execute operations against it, save into \
53
+ a new Docker image and remove the container
54
+ + Existing container ID - will execute operations against the running container, leaving it \
55
+ running
56
+
57
+ .. code:: shell
58
+
59
+ # A Docker base image must be provided
60
+ pyinfra @docker/alpine:3.8 ...
61
+
62
+ # pyinfra can run on multiple Docker images in parallel
63
+ pyinfra @docker/alpine:3.8,@docker/ubuntu:bionic ...
64
+
65
+ # Execute against a running container
66
+ pyinfra @docker/2beb8c15a1b1 ...
67
+
68
+ The Docker connector is great for testing pyinfra operations locally, rather than connecting to
69
+ a remote host over SSH each time. This gives you a fast, local-first devloop to iterate on when
70
+ writing deploys, operations or facts.
71
+ """
72
+
73
+ # enable the use of other docker cli compatible tools like podman
74
+ docker_cmd = "docker"
75
+
76
+ handles_execution = True
77
+
78
+ data_cls = ConnectorData
79
+ data_meta = connector_data_meta
80
+ data: ConnectorData
81
+
82
+ local: LocalConnector
83
+
84
+ container_id: str
85
+ no_stop: bool = False
86
+
87
+ def __init__(self, state: "State", host: "Host"):
88
+ super().__init__(state, host)
89
+ self.local = LocalConnector(state, host)
90
+
91
+ @override
92
+ @staticmethod
93
+ def make_names_data(name=None):
94
+ if not name:
95
+ raise InventoryError("No docker base ID provided!")
96
+
97
+ yield (
98
+ f"@docker/{name}",
99
+ {"docker_identifier": name},
100
+ ["@docker"],
101
+ )
102
+
103
+ # 2 helper functions
104
+ def _find_start_docker_container(self, container_id) -> tuple[str, bool]:
105
+ docker_info = local.shell(f"{self.docker_cmd} container inspect {container_id}")
106
+ assert isinstance(docker_info, str)
107
+ docker_info = json.loads(docker_info)[0]
108
+ if docker_info["State"]["Running"] is False:
109
+ logger.info(f"Starting stopped container: {container_id}")
110
+ local.shell(f"{self.docker_cmd} container start {container_id}")
111
+ return container_id, False
112
+ return container_id, True
113
+
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
+
135
+ try:
136
+ return local.shell(
137
+ " ".join(docker_cmd_parts),
138
+ splitlines=True,
139
+ )[-1] # last line is the container ID
140
+ except PyinfraError as e:
141
+ raise ConnectError(e.args[0])
142
+
143
+ @override
144
+ def connect(self) -> None:
145
+ self.local.connect()
146
+
147
+ docker_identifier = self.data["docker_identifier"]
148
+ with progress_spinner({f"prepare {self.docker_cmd} container"}):
149
+ try:
150
+ self.container_id, was_running = self._find_start_docker_container(
151
+ docker_identifier
152
+ )
153
+ if was_running:
154
+ self.no_stop = True
155
+ except PyinfraError:
156
+ self.container_id = self._start_docker_image(docker_identifier)
157
+
158
+ @override
159
+ def disconnect(self) -> None:
160
+ container_id = self.container_id
161
+
162
+ if self.no_stop:
163
+ logger.info(
164
+ "{0}{1} build complete, container left running: {2}".format(
165
+ self.host.print_prefix,
166
+ self.docker_cmd,
167
+ click.style(container_id, bold=True),
168
+ ),
169
+ )
170
+ return
171
+
172
+ with progress_spinner({f"{self.docker_cmd} commit"}):
173
+ image_id = local.shell(f"{self.docker_cmd} commit {container_id}", splitlines=True)[-1][
174
+ 7:19
175
+ ] # last line is the image ID, get sha256:[XXXXXXXXXX]...
176
+
177
+ with progress_spinner({f"{self.docker_cmd} rm"}):
178
+ local.shell(
179
+ f"{self.docker_cmd} rm -f {container_id}",
180
+ )
181
+
182
+ logger.info(
183
+ "{0}{1} build complete, image ID: {2}".format(
184
+ self.host.print_prefix,
185
+ self.docker_cmd,
186
+ click.style(image_id, bold=True),
187
+ ),
188
+ )
189
+
190
+ @override
191
+ def run_shell_command(
192
+ self,
193
+ command: StringCommand,
194
+ print_output: bool = False,
195
+ print_input: bool = False,
196
+ **arguments: Unpack["ConnectorArguments"],
197
+ ) -> tuple[bool, CommandOutput]:
198
+ local_arguments = extract_control_arguments(arguments)
199
+
200
+ container_id = self.container_id
201
+
202
+ command = make_unix_command_for_host(self.state, self.host, command, **arguments)
203
+ command = StringCommand(QuoteString(command))
204
+
205
+ docker_flags = "-it" if local_arguments.get("_get_pty") else "-i"
206
+ docker_command = StringCommand(
207
+ self.docker_cmd,
208
+ "exec",
209
+ docker_flags,
210
+ container_id,
211
+ "sh",
212
+ "-c",
213
+ command,
214
+ )
215
+
216
+ return self.local.run_shell_command(
217
+ docker_command,
218
+ print_output=print_output,
219
+ print_input=print_input,
220
+ **local_arguments,
221
+ )
222
+
223
+ @override
224
+ def put_file(
225
+ self,
226
+ filename_or_io,
227
+ remote_filename,
228
+ remote_temp_filename=None, # ignored
229
+ print_output=False,
230
+ print_input=False,
231
+ **kwargs, # ignored (sudo/etc)
232
+ ) -> bool:
233
+ """
234
+ Upload a file/IO object to the target container by copying it to a
235
+ temporary location and then uploading it into the container using ``docker cp``.
236
+ """
237
+
238
+ fd, temp_filename = mkstemp()
239
+
240
+ try:
241
+ # Load our file or IO object and write it to the temporary file
242
+ with get_file_io(filename_or_io) as file_io:
243
+ with open(temp_filename, "wb") as temp_f:
244
+ data = file_io.read()
245
+
246
+ if isinstance(data, str):
247
+ data = data.encode()
248
+
249
+ temp_f.write(data)
250
+
251
+ docker_command = StringCommand(
252
+ self.docker_cmd,
253
+ "cp",
254
+ temp_filename,
255
+ f"{self.container_id}:{remote_filename}",
256
+ )
257
+
258
+ status, output = self.local.run_shell_command(
259
+ docker_command,
260
+ print_output=print_output,
261
+ print_input=print_input,
262
+ )
263
+ finally:
264
+ os.close(fd)
265
+ os.remove(temp_filename)
266
+
267
+ if not status:
268
+ raise IOError(output.stderr)
269
+
270
+ if print_output:
271
+ click.echo(
272
+ "{0}file uploaded to container: {1}".format(
273
+ self.host.print_prefix,
274
+ remote_filename,
275
+ ),
276
+ err=True,
277
+ )
278
+
279
+ return status
280
+
281
+ @override
282
+ def get_file(
283
+ self,
284
+ remote_filename,
285
+ filename_or_io,
286
+ remote_temp_filename=None, # ignored
287
+ print_output=False,
288
+ print_input=False,
289
+ **kwargs, # ignored (sudo/etc)
290
+ ) -> bool:
291
+ """
292
+ Download a file from the target container by copying it to a temporary
293
+ location and then reading that into our final file/IO object.
294
+ """
295
+
296
+ fd, temp_filename = mkstemp()
297
+
298
+ try:
299
+ docker_command = StringCommand(
300
+ self.docker_cmd,
301
+ "cp",
302
+ f"{self.container_id}:{remote_filename}",
303
+ temp_filename,
304
+ )
305
+
306
+ status, output = self.local.run_shell_command(
307
+ docker_command,
308
+ print_output=print_output,
309
+ print_input=print_input,
310
+ )
311
+
312
+ # Load the temporary file and write it to our file or IO object
313
+ with open(temp_filename, "rb") as temp_f:
314
+ with get_file_io(filename_or_io, "wb") as file_io:
315
+ data = temp_f.read()
316
+ file_io.write(data)
317
+ finally:
318
+ os.close(fd)
319
+ os.remove(temp_filename)
320
+
321
+ if not status:
322
+ raise IOError(output.stderr)
323
+
324
+ if print_output:
325
+ click.echo(
326
+ "{0}file downloaded from container: {1}".format(
327
+ self.host.print_prefix,
328
+ remote_filename,
329
+ ),
330
+ err=True,
331
+ )
332
+
333
+ return status
334
+
335
+
336
+ class PodmanConnector(DockerConnector):
337
+ """
338
+ The Podman connector allows you to use pyinfra to create new Podman images or modify running
339
+ Podman containers.
340
+
341
+ .. note::
342
+
343
+ The Podman connector allows pyinfra to target Podman containers as inventory and is
344
+ unrelated to the :doc:`../operations/docker` & :doc:`../facts/docker`.
345
+
346
+ You can pass either an image name or existing container ID:
347
+
348
+ + Image - will create a new container from the image, execute operations against it, save into \
349
+ a new Podman image and remove the container
350
+ + Existing container ID - will execute operations against the running container, leaving it \
351
+ running
352
+
353
+ .. code:: shell
354
+
355
+ # A Podman base image must be provided
356
+ pyinfra @podman/alpine:3.8 ...
357
+
358
+ # pyinfra can run on multiple Docker images in parallel
359
+ pyinfra @podman/alpine:3.8,@podman/ubuntu:bionic ...
360
+
361
+ # Execute against a running container
362
+ pyinfra @podman/2beb8c15a1b1 ...
363
+
364
+ The Podman connector is great for testing pyinfra operations locally, rather than connecting to
365
+ a remote host over SSH each time. This gives you a fast, local-first devloop to iterate on when
366
+ writing deploys, operations or facts.
367
+ """
368
+
369
+ docker_cmd = "podman"
370
+
371
+ @override
372
+ @staticmethod
373
+ def make_names_data(name=None):
374
+ if not name:
375
+ raise InventoryError("No podman base ID provided!")
376
+
377
+ yield (
378
+ f"@podman/{name}",
379
+ {"docker_identifier": name},
380
+ ["@podman"],
381
+ )
382
+
383
+ # Duplicate function definition to swap the docstring.
384
+ @override
385
+ def put_file(
386
+ self,
387
+ filename_or_io,
388
+ remote_filename,
389
+ remote_temp_filename=None, # ignored
390
+ print_output=False,
391
+ print_input=False,
392
+ **kwargs, # ignored (sudo/etc)
393
+ ) -> bool:
394
+ """
395
+ Upload a file/IO object to the target container by copying it to a
396
+ temporary location and then uploading it into the container using ``podman cp``.
397
+ """
398
+ return super().put_file(
399
+ filename_or_io,
400
+ remote_filename,
401
+ remote_temp_filename, # ignored
402
+ print_output,
403
+ print_input,
404
+ **kwargs, # ignored (sudo/etc)
405
+ )
@@ -0,0 +1,297 @@
1
+ import os
2
+ from tempfile import mkstemp
3
+ from typing import TYPE_CHECKING
4
+
5
+ import click
6
+ from typing_extensions import Unpack, override
7
+
8
+ from pyinfra import logger
9
+ from pyinfra.api import QuoteString, StringCommand
10
+ from pyinfra.api.exceptions import ConnectError, InventoryError, PyinfraError
11
+ from pyinfra.api.util import get_file_io, memoize
12
+ from pyinfra.progress import progress_spinner
13
+
14
+ from .base import BaseConnector
15
+ from .ssh import SSHConnector
16
+ from .util import extract_control_arguments, make_unix_command_for_host
17
+
18
+ if TYPE_CHECKING:
19
+ from pyinfra.api.arguments import ConnectorArguments
20
+ from pyinfra.api.host import Host
21
+ from pyinfra.api.state import State
22
+
23
+
24
+ @memoize
25
+ def show_warning() -> None:
26
+ logger.warning("The @dockerssh connector is in beta!")
27
+
28
+
29
+ class DockerSSHConnector(BaseConnector):
30
+ """
31
+ **Note**: this connector is in beta!
32
+
33
+ The ``@dockerssh`` connector allows you to run commands on Docker containers \
34
+ on a remote machine.
35
+
36
+ .. code:: shell
37
+
38
+ # A Docker base image must be provided
39
+ pyinfra @dockerssh/remotehost:alpine:3.8 ...
40
+
41
+ # pyinfra can run on multiple Docker images in parallel
42
+ pyinfra @dockerssh/remotehost:alpine:3.8,@dockerssh/remotehost:ubuntu:bionic ...
43
+ """
44
+
45
+ handles_execution = True
46
+
47
+ ssh: SSHConnector
48
+
49
+ def __init__(self, state: "State", host: "Host"):
50
+ super().__init__(state, host)
51
+ self.ssh = SSHConnector(state, host)
52
+
53
+ @override
54
+ @staticmethod
55
+ def make_names_data(name):
56
+ try:
57
+ hostname, image = name.split(":", 1)
58
+ except (AttributeError, ValueError): # failure to parse the name
59
+ raise InventoryError("No ssh host or docker base image provided!")
60
+
61
+ if not image:
62
+ raise InventoryError("No docker base image provided!")
63
+
64
+ show_warning()
65
+
66
+ yield (
67
+ "@dockerssh/{0}:{1}".format(hostname, image),
68
+ {"ssh_hostname": hostname, "docker_image": image},
69
+ ["@dockerssh"],
70
+ )
71
+
72
+ @override
73
+ def connect(self) -> None:
74
+ self.ssh.connect()
75
+
76
+ if "docker_container_id" in self.host.host_data: # user can provide a docker_container_id
77
+ return
78
+
79
+ try:
80
+ with progress_spinner({"docker run"}):
81
+ # last line is the container ID
82
+ status, output = self.ssh.run_shell_command(
83
+ StringCommand(
84
+ "docker",
85
+ "run",
86
+ "-d",
87
+ self.host.data.docker_image,
88
+ "tail",
89
+ "-f",
90
+ "/dev/null",
91
+ ),
92
+ )
93
+ if not status:
94
+ raise IOError(output.stderr)
95
+ container_id = output.stdout_lines[-1]
96
+
97
+ except PyinfraError as e:
98
+ raise ConnectError(e.args[0])
99
+
100
+ self.host.host_data["docker_container_id"] = container_id
101
+
102
+ @override
103
+ def disconnect(self) -> None:
104
+ container_id = self.host.host_data["docker_container_id"][:12]
105
+
106
+ with progress_spinner({"docker commit"}):
107
+ _, output = self.ssh.run_shell_command(StringCommand("docker", "commit", container_id))
108
+
109
+ # Last line is the image ID, get sha256:[XXXXXXXXXX]...
110
+ image_id = output.stdout_lines[-1][7:19]
111
+
112
+ with progress_spinner({"docker rm"}):
113
+ self.ssh.run_shell_command(
114
+ StringCommand("docker", "rm", "-f", container_id),
115
+ )
116
+
117
+ logger.info(
118
+ "{0}docker build complete, image ID: {1}".format(
119
+ self.host.print_prefix,
120
+ click.style(image_id, bold=True),
121
+ ),
122
+ )
123
+
124
+ @override
125
+ def run_shell_command(
126
+ self,
127
+ command,
128
+ print_output: bool = False,
129
+ print_input: bool = False,
130
+ **arguments: Unpack["ConnectorArguments"],
131
+ ):
132
+ local_arguments = extract_control_arguments(arguments)
133
+
134
+ container_id = self.host.host_data["docker_container_id"]
135
+
136
+ command = make_unix_command_for_host(self.state, self.host, command, **arguments)
137
+ command = QuoteString(command)
138
+
139
+ docker_flags = "-it" if local_arguments.get("_get_pty") else "-i"
140
+ docker_command = StringCommand(
141
+ "docker",
142
+ "exec",
143
+ docker_flags,
144
+ container_id,
145
+ "sh",
146
+ "-c",
147
+ command,
148
+ )
149
+
150
+ return self.ssh.run_shell_command(
151
+ docker_command,
152
+ print_output=print_output,
153
+ print_input=print_input,
154
+ **local_arguments,
155
+ )
156
+
157
+ @override
158
+ def put_file(
159
+ self,
160
+ filename_or_io,
161
+ remote_filename,
162
+ remote_temp_filename=None,
163
+ print_output: bool = False,
164
+ print_input: bool = False,
165
+ **kwargs, # ignored (sudo/etc)
166
+ ):
167
+ """
168
+ Upload a file/IO object to the target Docker container by copying it to a
169
+ temporary location and then uploading it into the container using ``docker cp``.
170
+ """
171
+
172
+ fd, local_temp_filename = mkstemp()
173
+ remote_temp_filename = remote_temp_filename or self.host.get_temp_filename(
174
+ local_temp_filename
175
+ )
176
+
177
+ # Load our file or IO object and write it to the temporary file
178
+ with get_file_io(filename_or_io) as file_io:
179
+ with open(local_temp_filename, "wb") as temp_f:
180
+ data = file_io.read()
181
+
182
+ if isinstance(data, str):
183
+ data = data.encode()
184
+
185
+ temp_f.write(data)
186
+
187
+ # upload file to remote server
188
+ ssh_status = self.ssh.put_file(local_temp_filename, remote_temp_filename)
189
+ if not ssh_status:
190
+ raise IOError("Failed to copy file over ssh")
191
+
192
+ try:
193
+ docker_id = self.host.host_data["docker_container_id"]
194
+ docker_command = StringCommand(
195
+ "docker",
196
+ "cp",
197
+ remote_temp_filename,
198
+ f"{docker_id}:{remote_filename}",
199
+ )
200
+
201
+ status, output = self.ssh.run_shell_command(
202
+ docker_command,
203
+ print_output=print_output,
204
+ print_input=print_input,
205
+ )
206
+ finally:
207
+ os.close(fd)
208
+ os.remove(local_temp_filename)
209
+ self.remote_remove(
210
+ local_temp_filename,
211
+ print_output=print_output,
212
+ print_input=print_input,
213
+ )
214
+
215
+ if not status:
216
+ raise IOError(output.stderr)
217
+
218
+ if print_output:
219
+ click.echo(
220
+ "{0}file uploaded to container: {1}".format(
221
+ self.host.print_prefix,
222
+ remote_filename,
223
+ ),
224
+ err=True,
225
+ )
226
+
227
+ return status
228
+
229
+ @override
230
+ def get_file(
231
+ self,
232
+ remote_filename,
233
+ filename_or_io,
234
+ remote_temp_filename=None,
235
+ print_output: bool = False,
236
+ print_input: bool = False,
237
+ **kwargs, # ignored (sudo/etc)
238
+ ):
239
+ """
240
+ Download a file from the target Docker container by copying it to a temporary
241
+ location and then reading that into our final file/IO object.
242
+ """
243
+
244
+ remote_temp_filename = remote_temp_filename or self.host.get_temp_filename(remote_filename)
245
+
246
+ try:
247
+ docker_id = self.host.host_data["docker_container_id"]
248
+ docker_command = StringCommand(
249
+ "docker",
250
+ "cp",
251
+ f"{docker_id}:{remote_filename}",
252
+ remote_temp_filename,
253
+ )
254
+
255
+ status, output = self.ssh.run_shell_command(
256
+ docker_command,
257
+ print_output=print_output,
258
+ print_input=print_input,
259
+ )
260
+
261
+ ssh_status = self.ssh.get_file(remote_temp_filename, filename_or_io)
262
+ finally:
263
+ self.remote_remove(
264
+ remote_temp_filename,
265
+ print_output=print_output,
266
+ print_input=print_input,
267
+ )
268
+
269
+ if not ssh_status:
270
+ raise IOError("failed to copy file over ssh")
271
+
272
+ if not status:
273
+ raise IOError(output.stderr)
274
+
275
+ if print_output:
276
+ click.echo(
277
+ "{0}file downloaded from container: {1}".format(
278
+ self.host.print_prefix,
279
+ remote_filename,
280
+ ),
281
+ err=True,
282
+ )
283
+
284
+ return status
285
+
286
+ def remote_remove(self, filename, print_output: bool = False, print_input: bool = False):
287
+ """
288
+ Deletes a file on a remote machine over ssh.
289
+ """
290
+ remove_status, output = self.ssh.run_shell_command(
291
+ StringCommand("rm", "-f", filename),
292
+ print_output=print_output,
293
+ print_input=print_input,
294
+ )
295
+
296
+ if not remove_status:
297
+ raise IOError(output.stderr)